Allow users to add ports to expected list directly from scan results page instead of navigating through site config pages. The button appears next to unexpected ports and updates the site IP configuration via the API. - Add site_id and site_ip_id to scan result data for linking to config - Add "Mark Expected" button next to unexpected ports in scan detail view - Implement markPortExpected() JS function to update site IP settings
1081 lines
44 KiB
HTML
1081 lines
44 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;
|
|
|
|
// Build status cell with optional "Mark Expected" button
|
|
let statusCell;
|
|
if (port.expected) {
|
|
statusCell = '<span class="badge badge-good">Expected</span>';
|
|
} else {
|
|
// Show "Unexpected" badge with "Mark Expected" button if site_id and site_ip_id are available
|
|
const canMarkExpected = site.site_id && ip.site_ip_id;
|
|
statusCell = `<span class="badge badge-warning">Unexpected</span>`;
|
|
if (canMarkExpected) {
|
|
statusCell += ` <button class="btn btn-sm btn-outline-success ms-1" onclick="markPortExpected(${site.site_id}, ${ip.site_ip_id}, ${port.port}, '${port.protocol}')" title="Add to expected ports"><i class="bi bi-plus-circle"></i></button>`;
|
|
}
|
|
}
|
|
|
|
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>${statusCell}</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';
|
|
}
|
|
}
|
|
|
|
// Mark a port as expected in the site config
|
|
async function markPortExpected(siteId, ipId, portNumber, protocol) {
|
|
try {
|
|
// First, get the current IP settings - fetch all IPs with high per_page to find the one we need
|
|
const getResponse = await fetch(`/api/sites/${siteId}/ips?per_page=200`);
|
|
if (!getResponse.ok) {
|
|
throw new Error('Failed to get site IPs');
|
|
}
|
|
const ipsData = await getResponse.json();
|
|
|
|
// Find the IP in the site
|
|
const ipData = ipsData.ips.find(ip => ip.id === ipId);
|
|
if (!ipData) {
|
|
throw new Error('IP not found in site');
|
|
}
|
|
|
|
// Get current expected ports
|
|
let expectedTcpPorts = ipData.expected_tcp_ports || [];
|
|
let expectedUdpPorts = ipData.expected_udp_ports || [];
|
|
|
|
// Add the new port to the appropriate list
|
|
if (protocol.toLowerCase() === 'tcp') {
|
|
if (!expectedTcpPorts.includes(portNumber)) {
|
|
expectedTcpPorts.push(portNumber);
|
|
expectedTcpPorts.sort((a, b) => a - b);
|
|
}
|
|
} else if (protocol.toLowerCase() === 'udp') {
|
|
if (!expectedUdpPorts.includes(portNumber)) {
|
|
expectedUdpPorts.push(portNumber);
|
|
expectedUdpPorts.sort((a, b) => a - b);
|
|
}
|
|
}
|
|
|
|
// Update the IP settings
|
|
const updateResponse = await fetch(`/api/sites/${siteId}/ips/${ipId}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
expected_tcp_ports: expectedTcpPorts,
|
|
expected_udp_ports: expectedUdpPorts
|
|
})
|
|
});
|
|
|
|
if (!updateResponse.ok) {
|
|
let errorMessage = 'Failed to update IP settings';
|
|
try {
|
|
const errorData = await updateResponse.json();
|
|
errorMessage = errorData.message || errorMessage;
|
|
} catch (e) {
|
|
// Ignore JSON parse errors
|
|
}
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
// Show success message
|
|
showAlert('success', `Port ${portNumber}/${protocol.toUpperCase()} added to expected ports for this IP. Refresh the page to see updated status.`);
|
|
|
|
// Optionally refresh the scan data to show the change
|
|
// Note: The scan data itself won't change, but the user knows it's been updated
|
|
|
|
} catch (error) {
|
|
console.error('Error marking port as expected:', error);
|
|
showAlert('danger', `Failed to mark port as expected: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
// 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 %}
|