- Replace subprocess.run() with Popen for cancellable processes - Add cancel() method to SneakyScanner with process termination - Track running scanners in registry for stop signal delivery - Handle ScanCancelledError to set scan status to 'cancelled' - Add POST /api/scans/<id>/stop endpoint - Add 'cancelled' as valid scan status - Add Stop button to scans list and detail views - Show cancelled status with warning badge in UI
1000 lines
41 KiB
HTML
1000 lines
41 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Scan #{{ scan_id }} - SneakyScanner{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="row mt-4">
|
|
<div class="col-12">
|
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
<div>
|
|
<a href="{{ url_for('main.scans') }}" class="text-muted text-decoration-none mb-2 d-inline-block">
|
|
← Back to All Scans
|
|
</a>
|
|
<h1 style="color: #60a5fa;">Scan #<span id="scan-id">{{ scan_id }}</span></h1>
|
|
</div>
|
|
<div>
|
|
<button class="btn btn-primary" onclick="compareWithPrevious()" id="compare-btn" style="display: none;">
|
|
<i class="bi bi-arrow-left-right"></i> Compare with Previous
|
|
</button>
|
|
<button class="btn btn-secondary ms-2" onclick="refreshScan()">
|
|
<span id="refresh-text">Refresh</span>
|
|
<span id="refresh-spinner" class="spinner-border spinner-border-sm ms-1" style="display: none;"></span>
|
|
</button>
|
|
<button class="btn btn-warning ms-2" onclick="stopScan()" id="stop-btn" style="display: none;">
|
|
<span id="stop-text">Stop Scan</span>
|
|
<span id="stop-spinner" class="spinner-border spinner-border-sm ms-1" style="display: none;"></span>
|
|
</button>
|
|
<button class="btn btn-danger ms-2" onclick="deleteScan()" id="delete-btn">Delete Scan</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading State -->
|
|
<div id="scan-loading" class="text-center py-5">
|
|
<div class="spinner-border" role="status">
|
|
<span class="visually-hidden">Loading...</span>
|
|
</div>
|
|
<p class="mt-3 text-muted">Loading scan details...</p>
|
|
</div>
|
|
|
|
<!-- Error State -->
|
|
<div id="scan-error" class="alert alert-danger" style="display: none;"></div>
|
|
|
|
<!-- Scan Content -->
|
|
<div id="scan-content" style="display: none;">
|
|
<!-- Summary Card -->
|
|
<div class="row mb-4">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 class="mb-0" style="color: #60a5fa;">Scan Summary</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="row">
|
|
<div class="col-md-3">
|
|
<div class="mb-3">
|
|
<label class="form-label text-muted">Title</label>
|
|
<div id="scan-title" class="fw-bold">-</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="mb-3">
|
|
<label class="form-label text-muted">Timestamp</label>
|
|
<div id="scan-timestamp" class="mono">-</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<div class="mb-3">
|
|
<label class="form-label text-muted">Duration</label>
|
|
<div id="scan-duration" class="mono">-</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<div class="mb-3">
|
|
<label class="form-label text-muted">Status</label>
|
|
<div id="scan-status">-</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<div class="mb-3">
|
|
<label class="form-label text-muted">Triggered By</label>
|
|
<div id="scan-triggered-by">-</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Progress Section (shown when scan is running) -->
|
|
<div class="row mb-4" id="progress-section" style="display: none;">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 class="mb-0" style="color: #60a5fa;">
|
|
<i class="bi bi-hourglass-split"></i> Scan Progress
|
|
</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<!-- Phase and Progress Bar -->
|
|
<div class="mb-3">
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
<span>Current Phase: <strong id="current-phase">Initializing...</strong></span>
|
|
<span id="progress-count">0 / 0 IPs</span>
|
|
</div>
|
|
<div class="progress" style="height: 20px; background-color: #334155;">
|
|
<div id="progress-bar" class="progress-bar bg-info" role="progressbar" style="width: 0%"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Per-IP Results Table -->
|
|
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
|
|
<table class="table table-sm">
|
|
<thead style="position: sticky; top: 0; background-color: #1e293b;">
|
|
<tr>
|
|
<th>Site</th>
|
|
<th>IP Address</th>
|
|
<th>Ping</th>
|
|
<th>TCP Ports</th>
|
|
<th>UDP Ports</th>
|
|
<th>Services</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="progress-table-body">
|
|
<tr><td colspan="6" class="text-center text-muted">Waiting for results...</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stats Row -->
|
|
<div class="row mb-4">
|
|
<div class="col-md-3">
|
|
<div class="stat-card">
|
|
<div class="stat-value" id="total-sites">0</div>
|
|
<div class="stat-label">Sites</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="stat-card">
|
|
<div class="stat-value" id="total-ips">0</div>
|
|
<div class="stat-label">IP Addresses</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="stat-card">
|
|
<div class="stat-value" id="total-ports">0</div>
|
|
<div class="stat-label">Open Ports</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="stat-card">
|
|
<div class="stat-value" id="total-services">0</div>
|
|
<div class="stat-label">Services</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Historical Trend Chart -->
|
|
<div class="row mb-4" id="historical-chart-row" style="display: none;">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 class="mb-0" style="color: #60a5fa;">
|
|
<i class="bi bi-graph-up"></i> Port Count History
|
|
</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<p class="text-muted mb-3">
|
|
Historical port count trend for scans using the same configuration
|
|
</p>
|
|
<div style="position: relative; height: 300px;">
|
|
<canvas id="historyChart"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sites and IPs -->
|
|
<div id="sites-container">
|
|
<!-- Sites will be dynamically inserted here -->
|
|
</div>
|
|
|
|
<!-- Output Files -->
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 class="mb-0" style="color: #60a5fa;">Output Files</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<div id="output-files" class="d-flex gap-2">
|
|
<!-- File links will be dynamically inserted here -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Certificate Details Modal -->
|
|
<div class="modal fade" id="certificateModal" tabindex="-1">
|
|
<div class="modal-dialog modal-lg">
|
|
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
|
|
<div class="modal-header" style="border-bottom: 1px solid #334155;">
|
|
<h5 class="modal-title" style="color: #60a5fa;">
|
|
<i class="bi bi-shield-lock"></i> Certificate Details
|
|
</h5>
|
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="row mb-3">
|
|
<div class="col-md-6">
|
|
<label class="form-label text-muted">Subject</label>
|
|
<div id="cert-subject" class="mono" style="word-break: break-all;">-</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label text-muted">Issuer</label>
|
|
<div id="cert-issuer" class="mono" style="word-break: break-all;">-</div>
|
|
</div>
|
|
</div>
|
|
<div class="row mb-3">
|
|
<div class="col-md-4">
|
|
<label class="form-label text-muted">Valid From</label>
|
|
<div id="cert-valid-from" class="mono">-</div>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label text-muted">Valid Until</label>
|
|
<div id="cert-valid-until" class="mono">-</div>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label text-muted">Days Until Expiry</label>
|
|
<div id="cert-days-expiry">-</div>
|
|
</div>
|
|
</div>
|
|
<div class="row mb-3">
|
|
<div class="col-md-6">
|
|
<label class="form-label text-muted">Serial Number</label>
|
|
<div id="cert-serial" class="mono" style="word-break: break-all;">-</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label text-muted">Self-Signed</label>
|
|
<div id="cert-self-signed">-</div>
|
|
</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label text-muted">Subject Alternative Names (SANs)</label>
|
|
<div id="cert-sans">-</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label text-muted">TLS Version Support</label>
|
|
<div id="cert-tls-versions">-</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer" style="border-top: 1px solid #334155;">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
const scanId = {{ scan_id }};
|
|
let scanData = null;
|
|
let historyChart = null; // Store chart instance to prevent duplicates
|
|
let progressInterval = null; // Store progress polling interval
|
|
|
|
// Show alert notification
|
|
function showAlert(type, message) {
|
|
const container = document.getElementById('notification-container');
|
|
const notification = document.createElement('div');
|
|
notification.className = `alert alert-${type} alert-dismissible fade show mb-2`;
|
|
|
|
notification.innerHTML = `
|
|
${message}
|
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
|
`;
|
|
|
|
container.appendChild(notification);
|
|
|
|
// Auto-dismiss after 5 seconds
|
|
setTimeout(() => {
|
|
notification.remove();
|
|
}, 5000);
|
|
}
|
|
|
|
// Load scan on page load
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
loadScan().then(() => {
|
|
findPreviousScan();
|
|
loadHistoricalChart();
|
|
|
|
// Start progress polling if scan is running
|
|
if (scanData && scanData.status === 'running') {
|
|
startProgressPolling();
|
|
}
|
|
});
|
|
|
|
});
|
|
|
|
// Start polling for progress updates
|
|
function startProgressPolling() {
|
|
// Show progress section
|
|
document.getElementById('progress-section').style.display = 'block';
|
|
|
|
// Initial load
|
|
loadProgress();
|
|
|
|
// Poll every 3 seconds
|
|
progressInterval = setInterval(loadProgress, 3000);
|
|
}
|
|
|
|
// Stop polling for progress updates
|
|
function stopProgressPolling() {
|
|
if (progressInterval) {
|
|
clearInterval(progressInterval);
|
|
progressInterval = null;
|
|
}
|
|
// Hide progress section when scan completes
|
|
document.getElementById('progress-section').style.display = 'none';
|
|
}
|
|
|
|
// Load progress data
|
|
async function loadProgress() {
|
|
try {
|
|
const response = await fetch(`/api/scans/${scanId}/progress`);
|
|
if (!response.ok) return;
|
|
|
|
const progress = await response.json();
|
|
|
|
// Check if scan is still running
|
|
if (progress.status !== 'running') {
|
|
stopProgressPolling();
|
|
loadScan(); // Refresh full scan data
|
|
return;
|
|
}
|
|
|
|
renderProgress(progress);
|
|
} catch (error) {
|
|
console.error('Error loading progress:', error);
|
|
}
|
|
}
|
|
|
|
// Render progress data
|
|
function renderProgress(progress) {
|
|
// Update phase display
|
|
const phaseNames = {
|
|
'pending': 'Initializing',
|
|
'ping': 'Ping Scan',
|
|
'tcp_scan': 'TCP Port Scan',
|
|
'udp_scan': 'UDP Port Scan',
|
|
'service_detection': 'Service Detection',
|
|
'http_analysis': 'HTTP/HTTPS Analysis',
|
|
'completed': 'Completing'
|
|
};
|
|
|
|
const phaseName = phaseNames[progress.current_phase] || progress.current_phase;
|
|
document.getElementById('current-phase').textContent = phaseName;
|
|
|
|
// Update progress count and bar
|
|
const total = progress.total_ips || 0;
|
|
const completed = progress.completed_ips || 0;
|
|
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
|
|
|
|
document.getElementById('progress-count').textContent = `${completed} / ${total} IPs`;
|
|
document.getElementById('progress-bar').style.width = `${percent}%`;
|
|
|
|
// Update progress table
|
|
const tbody = document.getElementById('progress-table-body');
|
|
const entries = progress.progress_entries || [];
|
|
|
|
if (entries.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted">Waiting for results...</td></tr>';
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
entries.forEach(entry => {
|
|
// Ping result
|
|
let pingDisplay = '-';
|
|
if (entry.ping_result !== null && entry.ping_result !== undefined) {
|
|
pingDisplay = entry.ping_result
|
|
? '<span class="badge badge-success">Yes</span>'
|
|
: '<span class="badge badge-danger">No</span>';
|
|
}
|
|
|
|
// TCP ports
|
|
const tcpPorts = entry.tcp_ports || [];
|
|
let tcpDisplay = tcpPorts.length > 0
|
|
? `<span class="badge bg-info">${tcpPorts.length}</span> <small class="text-muted">${tcpPorts.slice(0, 5).join(', ')}${tcpPorts.length > 5 ? '...' : ''}</small>`
|
|
: '-';
|
|
|
|
// UDP ports
|
|
const udpPorts = entry.udp_ports || [];
|
|
let udpDisplay = udpPorts.length > 0
|
|
? `<span class="badge bg-info">${udpPorts.length}</span>`
|
|
: '-';
|
|
|
|
// Services
|
|
const services = entry.services || [];
|
|
let svcDisplay = '-';
|
|
if (services.length > 0) {
|
|
const svcNames = services.map(s => s.service || 'unknown').slice(0, 3);
|
|
svcDisplay = `<span class="badge bg-info">${services.length}</span> <small class="text-muted">${svcNames.join(', ')}${services.length > 3 ? '...' : ''}</small>`;
|
|
}
|
|
|
|
html += `
|
|
<tr class="scan-row">
|
|
<td>${entry.site_name || '-'}</td>
|
|
<td class="mono">${entry.ip_address}</td>
|
|
<td>${pingDisplay}</td>
|
|
<td>${tcpDisplay}</td>
|
|
<td>${udpDisplay}</td>
|
|
<td>${svcDisplay}</td>
|
|
</tr>
|
|
`;
|
|
});
|
|
|
|
tbody.innerHTML = html;
|
|
}
|
|
|
|
// Load scan details
|
|
async function loadScan() {
|
|
const loadingEl = document.getElementById('scan-loading');
|
|
const errorEl = document.getElementById('scan-error');
|
|
const contentEl = document.getElementById('scan-content');
|
|
|
|
// Show loading state
|
|
loadingEl.style.display = 'block';
|
|
errorEl.style.display = 'none';
|
|
contentEl.style.display = 'none';
|
|
|
|
try {
|
|
const response = await fetch(`/api/scans/${scanId}`);
|
|
if (!response.ok) {
|
|
if (response.status === 404) {
|
|
throw new Error('Scan not found');
|
|
}
|
|
throw new Error('Failed to load scan');
|
|
}
|
|
|
|
scanData = await response.json();
|
|
|
|
loadingEl.style.display = 'none';
|
|
contentEl.style.display = 'block';
|
|
|
|
renderScan(scanData);
|
|
} catch (error) {
|
|
console.error('Error loading scan:', error);
|
|
loadingEl.style.display = 'none';
|
|
errorEl.textContent = error.message;
|
|
errorEl.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
// Render scan details
|
|
function renderScan(scan) {
|
|
// Summary
|
|
document.getElementById('scan-title').textContent = scan.title || 'Untitled Scan';
|
|
document.getElementById('scan-timestamp').textContent = new Date(scan.timestamp).toLocaleString();
|
|
document.getElementById('scan-duration').textContent = scan.duration ? `${scan.duration.toFixed(1)}s` : '-';
|
|
document.getElementById('scan-triggered-by').textContent = scan.triggered_by || 'manual';
|
|
|
|
// Status badge
|
|
let statusBadge = '';
|
|
if (scan.status === 'completed') {
|
|
statusBadge = '<span class="badge badge-success">Completed</span>';
|
|
} else if (scan.status === 'running') {
|
|
statusBadge = '<span class="badge badge-info">Running</span>';
|
|
document.getElementById('delete-btn').disabled = true;
|
|
document.getElementById('stop-btn').style.display = 'inline-block';
|
|
} else if (scan.status === 'failed') {
|
|
statusBadge = '<span class="badge badge-danger">Failed</span>';
|
|
} else if (scan.status === 'cancelled') {
|
|
statusBadge = '<span class="badge badge-warning">Cancelled</span>';
|
|
} else {
|
|
statusBadge = `<span class="badge badge-info">${scan.status}</span>`;
|
|
}
|
|
document.getElementById('scan-status').innerHTML = statusBadge;
|
|
|
|
// Stats
|
|
const sites = scan.sites || [];
|
|
let totalIps = 0;
|
|
let totalPorts = 0;
|
|
let totalServices = 0;
|
|
|
|
sites.forEach(site => {
|
|
const ips = site.ips || [];
|
|
totalIps += ips.length;
|
|
|
|
ips.forEach(ip => {
|
|
const ports = ip.ports || [];
|
|
totalPorts += ports.length;
|
|
|
|
ports.forEach(port => {
|
|
totalServices += (port.services || []).length;
|
|
});
|
|
});
|
|
});
|
|
|
|
document.getElementById('total-sites').textContent = sites.length;
|
|
document.getElementById('total-ips').textContent = totalIps;
|
|
document.getElementById('total-ports').textContent = totalPorts;
|
|
document.getElementById('total-services').textContent = totalServices;
|
|
|
|
// Sites
|
|
renderSites(sites);
|
|
|
|
// Output files
|
|
renderOutputFiles(scan);
|
|
}
|
|
|
|
// Render sites
|
|
function renderSites(sites) {
|
|
const container = document.getElementById('sites-container');
|
|
container.innerHTML = '';
|
|
|
|
sites.forEach((site, siteIdx) => {
|
|
const siteCard = document.createElement('div');
|
|
siteCard.className = 'row mb-4';
|
|
siteCard.innerHTML = `
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 class="mb-0" style="color: #60a5fa;">${site.name}</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<div id="site-${siteIdx}-ips"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
container.appendChild(siteCard);
|
|
|
|
// Render IPs for this site
|
|
const ipsContainer = document.getElementById(`site-${siteIdx}-ips`);
|
|
const ips = site.ips || [];
|
|
|
|
ips.forEach((ip, ipIdx) => {
|
|
const ipDiv = document.createElement('div');
|
|
ipDiv.className = 'mb-3';
|
|
ipDiv.innerHTML = `
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
<h6 class="mono mb-0">${ip.address}</h6>
|
|
<div>
|
|
${ip.ping_actual ? '<span class="badge badge-success">Ping: Responsive</span>' : '<span class="badge badge-danger">Ping: No Response</span>'}
|
|
</div>
|
|
</div>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>Port</th>
|
|
<th>Protocol</th>
|
|
<th>State</th>
|
|
<th>Service</th>
|
|
<th>Product</th>
|
|
<th>Version</th>
|
|
<th>Status</th>
|
|
<th>Screenshot</th>
|
|
<th>Certificate</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="site-${siteIdx}-ip-${ipIdx}-ports"></tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
ipsContainer.appendChild(ipDiv);
|
|
|
|
// Render ports for this IP
|
|
const portsContainer = document.getElementById(`site-${siteIdx}-ip-${ipIdx}-ports`);
|
|
const ports = ip.ports || [];
|
|
|
|
if (ports.length === 0) {
|
|
portsContainer.innerHTML = '<tr class="scan-row"><td colspan="9" class="text-center text-muted">No ports found</td></tr>';
|
|
} else {
|
|
ports.forEach(port => {
|
|
const service = port.services && port.services.length > 0 ? port.services[0] : null;
|
|
const screenshotPath = service && service.screenshot_path ? service.screenshot_path : null;
|
|
const certificate = service && service.certificates && service.certificates.length > 0 ? service.certificates[0] : null;
|
|
|
|
const row = document.createElement('tr');
|
|
row.classList.add('scan-row'); // Fix white row bug
|
|
row.innerHTML = `
|
|
<td class="mono">${port.port}</td>
|
|
<td>${port.protocol.toUpperCase()}</td>
|
|
<td><span class="badge badge-success">${port.state || 'open'}</span></td>
|
|
<td>${service ? service.service_name : '-'}</td>
|
|
<td>${service ? service.product || '-' : '-'}</td>
|
|
<td class="mono">${service ? service.version || '-' : '-'}</td>
|
|
<td>${port.expected ? '<span class="badge badge-good">Expected</span>' : '<span class="badge badge-warning">Unexpected</span>'}</td>
|
|
<td>${screenshotPath ? `<a href="/output/${screenshotPath.replace(/^\/?(?:app\/)?output\/?/, '')}" target="_blank" class="btn btn-sm btn-outline-primary" title="View Screenshot"><i class="bi bi-image"></i></a>` : '-'}</td>
|
|
<td>${certificate ? `<button class="btn btn-sm btn-outline-info" onclick='showCertificateModal(${JSON.stringify(certificate).replace(/'/g, "'")})' title="View Certificate"><i class="bi bi-shield-lock"></i></button>` : '-'}</td>
|
|
`;
|
|
portsContainer.appendChild(row);
|
|
});
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Render output files
|
|
function renderOutputFiles(scan) {
|
|
const container = document.getElementById('output-files');
|
|
container.innerHTML = '';
|
|
|
|
const files = [];
|
|
if (scan.json_path) {
|
|
files.push({ label: 'JSON', path: scan.json_path, icon: '📄' });
|
|
}
|
|
if (scan.html_path) {
|
|
files.push({ label: 'HTML Report', path: scan.html_path, icon: '🌐' });
|
|
}
|
|
if (scan.zip_path) {
|
|
files.push({ label: 'ZIP Archive', path: scan.zip_path, icon: '📦' });
|
|
}
|
|
|
|
if (files.length === 0) {
|
|
container.innerHTML = '<p class="text-muted mb-0">No output files generated yet.</p>';
|
|
} else {
|
|
files.forEach(file => {
|
|
const link = document.createElement('a');
|
|
link.href = `/output/${file.path.split('/').pop()}`;
|
|
link.className = 'btn btn-secondary';
|
|
link.target = '_blank';
|
|
link.innerHTML = `${file.icon} ${file.label}`;
|
|
container.appendChild(link);
|
|
});
|
|
}
|
|
}
|
|
|
|
// Refresh scan
|
|
function refreshScan() {
|
|
const refreshBtn = document.getElementById('refresh-text');
|
|
const refreshSpinner = document.getElementById('refresh-spinner');
|
|
|
|
refreshBtn.style.display = 'none';
|
|
refreshSpinner.style.display = 'inline-block';
|
|
|
|
loadScan().finally(() => {
|
|
refreshBtn.style.display = 'inline';
|
|
refreshSpinner.style.display = 'none';
|
|
});
|
|
}
|
|
|
|
// Delete scan
|
|
async function deleteScan() {
|
|
if (!confirm(`Are you sure you want to delete scan ${scanId}?`)) {
|
|
return;
|
|
}
|
|
|
|
// Disable delete button to prevent double-clicks
|
|
const deleteBtn = document.getElementById('delete-btn');
|
|
deleteBtn.disabled = true;
|
|
deleteBtn.textContent = 'Deleting...';
|
|
|
|
try {
|
|
const response = await fetch(`/api/scans/${scanId}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
|
|
// Check status code first
|
|
if (!response.ok) {
|
|
// Try to get error message from response
|
|
let errorMessage = `HTTP ${response.status}: Failed to delete scan`;
|
|
try {
|
|
const data = await response.json();
|
|
errorMessage = data.message || errorMessage;
|
|
} catch (e) {
|
|
// Ignore JSON parse errors for error responses
|
|
}
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
// For successful responses, try to parse JSON but don't fail if it doesn't work
|
|
try {
|
|
await response.json();
|
|
} catch (e) {
|
|
console.warn('Response is not valid JSON, but deletion succeeded');
|
|
}
|
|
|
|
// Wait 2 seconds to ensure deletion completes fully
|
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
|
|
// Redirect to scans list
|
|
window.location.href = '{{ url_for("main.scans") }}';
|
|
} catch (error) {
|
|
console.error('Error deleting scan:', error);
|
|
showAlert('danger', `Failed to delete scan: ${error.message}`);
|
|
|
|
// Re-enable button on error
|
|
deleteBtn.disabled = false;
|
|
deleteBtn.textContent = 'Delete Scan';
|
|
}
|
|
}
|
|
|
|
// Stop scan
|
|
async function stopScan() {
|
|
if (!confirm(`Are you sure you want to stop scan ${scanId}?`)) {
|
|
return;
|
|
}
|
|
|
|
const stopBtn = document.getElementById('stop-btn');
|
|
const stopText = document.getElementById('stop-text');
|
|
const stopSpinner = document.getElementById('stop-spinner');
|
|
|
|
// Show loading state
|
|
stopBtn.disabled = true;
|
|
stopText.style.display = 'none';
|
|
stopSpinner.style.display = 'inline-block';
|
|
|
|
try {
|
|
const response = await fetch(`/api/scans/${scanId}/stop`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
let errorMessage = `HTTP ${response.status}: Failed to stop scan`;
|
|
try {
|
|
const data = await response.json();
|
|
errorMessage = data.message || errorMessage;
|
|
} catch (e) {
|
|
// Ignore JSON parse errors
|
|
}
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
// Show success message
|
|
showAlert('success', `Stop signal sent to scan ${scanId}.`);
|
|
|
|
// Refresh scan data after a short delay
|
|
setTimeout(() => {
|
|
loadScan();
|
|
}, 1000);
|
|
|
|
} catch (error) {
|
|
console.error('Error stopping scan:', error);
|
|
showAlert('danger', `Failed to stop scan: ${error.message}`);
|
|
|
|
// Re-enable button on error
|
|
stopBtn.disabled = false;
|
|
stopText.style.display = 'inline';
|
|
stopSpinner.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// Find previous scan and show compare button
|
|
let previousScanId = null;
|
|
let currentConfigId = null;
|
|
async function findPreviousScan() {
|
|
try {
|
|
// Get current scan details first to know which config it used
|
|
const currentScanResponse = await fetch(`/api/scans/${scanId}`);
|
|
const currentScanData = await currentScanResponse.json();
|
|
currentConfigId = currentScanData.config_id;
|
|
|
|
// Get list of completed scans
|
|
const response = await fetch('/api/scans?per_page=100&status=completed');
|
|
const data = await response.json();
|
|
|
|
if (data.scans && data.scans.length > 0) {
|
|
// Find the current scan
|
|
const currentScanIndex = data.scans.findIndex(s => s.id === scanId);
|
|
|
|
if (currentScanIndex !== -1) {
|
|
// Look for the most recent previous scan with the SAME config
|
|
for (let i = currentScanIndex + 1; i < data.scans.length; i++) {
|
|
const previousScan = data.scans[i];
|
|
|
|
// Check if this scan uses the same config
|
|
if (previousScan.config_id === currentConfigId) {
|
|
previousScanId = previousScan.id;
|
|
|
|
// Show the compare button
|
|
const compareBtn = document.getElementById('compare-btn');
|
|
if (compareBtn) {
|
|
compareBtn.style.display = 'inline-block';
|
|
compareBtn.title = `Compare with Scan #${previousScanId} (same config)`;
|
|
}
|
|
break; // Found the most recent matching scan
|
|
}
|
|
}
|
|
|
|
// If no matching config found, don't show compare button
|
|
if (!previousScanId) {
|
|
console.log('No previous scans found with the same configuration');
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error finding previous scan:', error);
|
|
}
|
|
}
|
|
|
|
// Compare with previous scan
|
|
function compareWithPrevious() {
|
|
if (previousScanId) {
|
|
window.location.href = `/scans/${previousScanId}/compare/${scanId}`;
|
|
}
|
|
}
|
|
|
|
// Load historical trend chart
|
|
async function loadHistoricalChart() {
|
|
try {
|
|
const response = await fetch(`/api/stats/scan-history/${scanId}?limit=20`);
|
|
const data = await response.json();
|
|
|
|
// Only show chart if there are multiple scans
|
|
if (data.scans && data.scans.length > 1) {
|
|
document.getElementById('historical-chart-row').style.display = 'block';
|
|
|
|
// Destroy existing chart to prevent canvas growth bug
|
|
if (historyChart) {
|
|
historyChart.destroy();
|
|
}
|
|
|
|
const ctx = document.getElementById('historyChart').getContext('2d');
|
|
historyChart = new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: data.labels,
|
|
datasets: [{
|
|
label: 'Open Ports',
|
|
data: data.port_counts,
|
|
borderColor: '#60a5fa',
|
|
backgroundColor: 'rgba(96, 165, 250, 0.1)',
|
|
tension: 0.3,
|
|
fill: true,
|
|
pointBackgroundColor: '#60a5fa',
|
|
pointBorderColor: '#1e293b',
|
|
pointBorderWidth: 2,
|
|
pointRadius: 4,
|
|
pointHoverRadius: 6
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: {
|
|
display: false
|
|
},
|
|
tooltip: {
|
|
backgroundColor: '#1e293b',
|
|
titleColor: '#e2e8f0',
|
|
bodyColor: '#e2e8f0',
|
|
borderColor: '#334155',
|
|
borderWidth: 1,
|
|
callbacks: {
|
|
afterLabel: function(context) {
|
|
const scan = data.scans[context.dataIndex];
|
|
return `Scan ID: ${scan.id}\nIPs: ${scan.ip_count}`;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true,
|
|
ticks: {
|
|
stepSize: 1,
|
|
color: '#94a3b8'
|
|
},
|
|
grid: {
|
|
color: '#334155'
|
|
}
|
|
},
|
|
x: {
|
|
ticks: {
|
|
color: '#94a3b8',
|
|
maxRotation: 45,
|
|
minRotation: 45
|
|
},
|
|
grid: {
|
|
color: '#334155'
|
|
}
|
|
}
|
|
},
|
|
onClick: (event, elements) => {
|
|
if (elements.length > 0) {
|
|
const index = elements[0].index;
|
|
const scan = data.scans[index];
|
|
window.location.href = `/scans/${scan.id}`;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading historical chart:', error);
|
|
}
|
|
}
|
|
|
|
// Show certificate details modal
|
|
function showCertificateModal(cert) {
|
|
// Populate modal fields
|
|
document.getElementById('cert-subject').textContent = cert.subject || '-';
|
|
document.getElementById('cert-issuer').textContent = cert.issuer || '-';
|
|
document.getElementById('cert-serial').textContent = cert.serial_number || '-';
|
|
|
|
// Format dates
|
|
document.getElementById('cert-valid-from').textContent = cert.not_valid_before
|
|
? new Date(cert.not_valid_before).toLocaleString()
|
|
: '-';
|
|
document.getElementById('cert-valid-until').textContent = cert.not_valid_after
|
|
? new Date(cert.not_valid_after).toLocaleString()
|
|
: '-';
|
|
|
|
// Days until expiry with color coding
|
|
if (cert.days_until_expiry !== null && cert.days_until_expiry !== undefined) {
|
|
let badgeClass = 'badge-success';
|
|
if (cert.days_until_expiry < 0) {
|
|
badgeClass = 'badge-danger';
|
|
} else if (cert.days_until_expiry < 30) {
|
|
badgeClass = 'badge-warning';
|
|
}
|
|
document.getElementById('cert-days-expiry').innerHTML =
|
|
`<span class="badge ${badgeClass}">${cert.days_until_expiry} days</span>`;
|
|
} else {
|
|
document.getElementById('cert-days-expiry').textContent = '-';
|
|
}
|
|
|
|
// Self-signed indicator
|
|
document.getElementById('cert-self-signed').innerHTML = cert.is_self_signed
|
|
? '<span class="badge badge-warning">Yes</span>'
|
|
: '<span class="badge badge-success">No</span>';
|
|
|
|
// SANs
|
|
if (cert.sans && cert.sans.length > 0) {
|
|
document.getElementById('cert-sans').innerHTML = cert.sans
|
|
.map(san => `<span class="badge bg-secondary me-1 mb-1">${san}</span>`)
|
|
.join('');
|
|
} else {
|
|
document.getElementById('cert-sans').textContent = 'None';
|
|
}
|
|
|
|
// TLS versions
|
|
if (cert.tls_versions && cert.tls_versions.length > 0) {
|
|
let tlsHtml = '<div class="table-responsive"><table class="table table-sm mb-0">';
|
|
tlsHtml += '<thead><tr><th>Version</th><th>Status</th><th>Cipher Suites</th></tr></thead><tbody>';
|
|
|
|
cert.tls_versions.forEach(tls => {
|
|
const statusBadge = tls.supported
|
|
? '<span class="badge badge-success">Supported</span>'
|
|
: '<span class="badge badge-danger">Not Supported</span>';
|
|
|
|
let ciphers = '-';
|
|
if (tls.cipher_suites && tls.cipher_suites.length > 0) {
|
|
ciphers = `<small class="text-muted">${tls.cipher_suites.length} cipher(s)</small>
|
|
<button class="btn btn-sm btn-link p-0 ms-1" onclick="toggleCiphers(this, '${tls.tls_version}')" data-ciphers='${JSON.stringify(tls.cipher_suites).replace(/'/g, "'")}'>
|
|
<i class="bi bi-chevron-down"></i>
|
|
</button>
|
|
<div class="cipher-list" style="display:none; font-size: 0.75rem; max-height: 100px; overflow-y: auto;"></div>`;
|
|
}
|
|
|
|
tlsHtml += `<tr class="scan-row"><td>${tls.tls_version}</td><td>${statusBadge}</td><td>${ciphers}</td></tr>`;
|
|
});
|
|
|
|
tlsHtml += '</tbody></table></div>';
|
|
document.getElementById('cert-tls-versions').innerHTML = tlsHtml;
|
|
} else {
|
|
document.getElementById('cert-tls-versions').textContent = 'No TLS information available';
|
|
}
|
|
|
|
// Show modal
|
|
const modal = new bootstrap.Modal(document.getElementById('certificateModal'));
|
|
modal.show();
|
|
}
|
|
|
|
// Toggle cipher suites display
|
|
function toggleCiphers(btn, version) {
|
|
const cipherList = btn.nextElementSibling;
|
|
const icon = btn.querySelector('i');
|
|
|
|
if (cipherList.style.display === 'none') {
|
|
const ciphers = JSON.parse(btn.dataset.ciphers);
|
|
cipherList.innerHTML = ciphers.map(c => `<div class="mono">${c}</div>`).join('');
|
|
cipherList.style.display = 'block';
|
|
icon.className = 'bi bi-chevron-up';
|
|
} else {
|
|
cipherList.style.display = 'none';
|
|
icon.className = 'bi bi-chevron-down';
|
|
}
|
|
}
|
|
</script>
|
|
{% endblock %}
|