Files
SneakyScan/app/web/templates/scan_detail.html
Phillip Tarrant 0ec338e252 Migrate from file-based configs to database with per-IP site configuration
Major architectural changes:
   - Replace YAML config files with database-stored ScanConfig model
   - Remove CIDR block support in favor of individual IP addresses per site
   - Each IP now has its own expected_ping, expected_tcp_ports, expected_udp_ports
   - AlertRule now uses config_id FK instead of config_file string

   API changes:
   - POST /api/scans now requires config_id instead of config_file
   - Alert rules API uses config_id with validation
   - All config dropdowns fetch from /api/configs dynamically

   Template updates:
   - scans.html, dashboard.html, alert_rules.html load configs via API
   - Display format: Config Title (X sites) in dropdowns
   - Removed Jinja2 config_files loops

   Migrations:
   - 008: Expand CIDRs to individual IPs with per-IP port configs
   - 009: Remove CIDR-related columns
   - 010: Add config_id to alert_rules, remove config_file
2025-11-19 19:40:34 -06:00

598 lines
23 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-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>
<!-- 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>
{% endblock %}
{% block scripts %}
<script>
const scanId = {{ scan_id }};
let scanData = null;
let historyChart = null; // Store chart instance to prevent duplicates
// Load scan on page load
document.addEventListener('DOMContentLoaded', function() {
loadScan().then(() => {
findPreviousScan();
loadHistoricalChart();
});
// Auto-refresh every 10 seconds if scan is running
setInterval(function() {
if (scanData && scanData.status === 'running') {
loadScan();
}
}, 10000);
});
// 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';
document.getElementById('scan-config-file').textContent = scan.config_file || '-';
// 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;
} else if (scan.status === 'failed') {
statusBadge = '<span class="badge badge-danger">Failed</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>
</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="7" 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 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>
`;
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);
alert(`Failed to delete scan: ${error.message}`);
// Re-enable button on error
deleteBtn.disabled = false;
deleteBtn.textContent = 'Delete Scan';
}
}
// Find previous scan and show compare button
let previousScanId = null;
let currentConfigFile = 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();
currentConfigFile = currentScanData.config_file;
// 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 file
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_file === currentConfigFile) {
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);
}
}
</script>
{% endblock %}