Phase 3 Step 7: Scan Comparison Features & UX Improvements

Implemented comprehensive scan comparison functionality with historical
analysis and improved user experience for scan triggering.

Features Added:
- Scan comparison engine with ports, services, and certificates analysis
- Drift score calculation (0.0-1.0 scale) for infrastructure changes
- Side-by-side comparison UI with color-coded changes (added/removed/changed)
- Historical trend charts showing port counts over time
- "Compare with Previous" button on scan detail pages
- Scan history API endpoint for trending data

API Endpoints:
- GET /api/scans/<id1>/compare/<id2> - Compare two scans
- GET /api/stats/scan-history/<id> - Historical scan data for charts

UI Improvements:
- Replaced config file text inputs with dropdown selectors
- Added config file selection to dashboard and scans pages
- Improved delete scan confirmation with proper async handling
- Enhanced error messages with detailed validation feedback
- Added 2-second delay before redirect to ensure deletion completes

Comparison Features:
- Port changes: tracks added, removed, and unchanged ports
- Service changes: detects version updates and service modifications
- Certificate changes: monitors SSL/TLS certificate updates
- Interactive historical charts with clickable data points
- Automatic detection of previous scan for comparison

Bug Fixes:
- Fixed scan deletion UI alert appearing on successful deletion
- Prevented config file path duplication (configs/configs/...)
- Improved error handling for failed API responses
- Added proper JSON response parsing with fallback handling

Testing:
- Created comprehensive test suite for comparison functionality
- Tests cover comparison API, service methods, and drift scoring
- Added edge case tests for identical scans and missing data
This commit is contained in:
2025-11-14 16:15:13 -06:00
parent 9b88f42297
commit 6792d69eb1
10 changed files with 1581 additions and 36 deletions

View File

@@ -154,13 +154,19 @@
<form id="trigger-scan-form">
<div class="mb-3">
<label for="config-file" class="form-label">Config File</label>
<input type="text"
class="form-control"
id="config-file"
name="config_file"
placeholder="/app/configs/example.yaml"
required>
<div class="form-text text-muted">Path to YAML configuration file</div>
<select class="form-select" id="config-file" name="config_file" required>
<option value="">Select a config file...</option>
{% for config in config_files %}
<option value="{{ config }}">{{ config }}</option>
{% endfor %}
</select>
<div class="form-text text-muted">
{% if config_files %}
Select a scan configuration file
{% else %}
<span class="text-warning">No config files found in /app/configs/</span>
{% endif %}
</div>
</div>
<div id="trigger-error" class="alert alert-danger" style="display: none;"></div>
</form>
@@ -349,7 +355,7 @@
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to trigger scan');
throw new Error(data.message || data.error || 'Failed to trigger scan');
}
const data = await response.json();

View File

@@ -0,0 +1,526 @@
{% extends "base.html" %}
{% block title %}Compare Scans - 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 Comparison</h1>
<p class="text-muted">Comparing Scan #{{ scan_id1 }} vs Scan #{{ scan_id2 }}</p>
</div>
</div>
</div>
</div>
<!-- Loading State -->
<div id="comparison-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 comparison...</p>
</div>
<!-- Error State -->
<div id="comparison-error" class="alert alert-danger" style="display: none;"></div>
<!-- Comparison Content -->
<div id="comparison-content" style="display: none;">
<!-- Drift Score Card -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Infrastructure Drift Analysis</h5>
</div>
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-3">
<div class="text-center">
<div class="display-4 mb-2" id="drift-score" style="color: #60a5fa;">-</div>
<div class="text-muted">Drift Score</div>
<small class="text-muted d-block mt-1">(0.0 = identical, 1.0 = completely different)</small>
</div>
</div>
<div class="col-md-9">
<div class="row">
<div class="col-md-4">
<div class="mb-3">
<label class="form-label text-muted">Older Scan (#<span id="scan1-id"></span>)</label>
<div id="scan1-title" class="fw-bold">-</div>
<small class="text-muted" id="scan1-timestamp">-</small>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label text-muted">Newer Scan (#<span id="scan2-id"></span>)</label>
<div id="scan2-title" class="fw-bold">-</div>
<small class="text-muted" id="scan2-timestamp">-</small>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label text-muted">Quick Actions</label>
<div>
<a href="/scans/{{ scan_id1 }}" class="btn btn-sm btn-secondary">View Scan #{{ scan_id1 }}</a>
<a href="/scans/{{ scan_id2 }}" class="btn btn-sm btn-secondary">View Scan #{{ scan_id2 }}</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Ports Comparison -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">
<i class="bi bi-hdd-network"></i> Port Changes
</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-4">
<div class="stat-card" style="background-color: #065f46; border-color: #6ee7b7;">
<div class="stat-value" id="ports-added-count">0</div>
<div class="stat-label">Ports Added</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card" style="background-color: #7f1d1d; border-color: #fca5a5;">
<div class="stat-value" id="ports-removed-count">0</div>
<div class="stat-label">Ports Removed</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card">
<div class="stat-value" id="ports-unchanged-count">0</div>
<div class="stat-label">Ports Unchanged</div>
</div>
</div>
</div>
<!-- Added Ports -->
<div id="ports-added-section" style="display: none;">
<h6 class="text-success mb-2"><i class="bi bi-plus-circle"></i> Added Ports</h6>
<div class="table-responsive mb-3">
<table class="table table-sm">
<thead>
<tr>
<th>IP Address</th>
<th>Port</th>
<th>Protocol</th>
<th>State</th>
</tr>
</thead>
<tbody id="ports-added-tbody"></tbody>
</table>
</div>
</div>
<!-- Removed Ports -->
<div id="ports-removed-section" style="display: none;">
<h6 class="text-danger mb-2"><i class="bi bi-dash-circle"></i> Removed Ports</h6>
<div class="table-responsive mb-3">
<table class="table table-sm">
<thead>
<tr>
<th>IP Address</th>
<th>Port</th>
<th>Protocol</th>
<th>State</th>
</tr>
</thead>
<tbody id="ports-removed-tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Services Comparison -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">
<i class="bi bi-gear"></i> Service Changes
</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-4">
<div class="stat-card" style="background-color: #065f46; border-color: #6ee7b7;">
<div class="stat-value" id="services-added-count">0</div>
<div class="stat-label">Services Added</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card" style="background-color: #7f1d1d; border-color: #fca5a5;">
<div class="stat-value" id="services-removed-count">0</div>
<div class="stat-label">Services Removed</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card" style="background-color: #78350f; border-color: #fcd34d;">
<div class="stat-value" id="services-changed-count">0</div>
<div class="stat-label">Services Changed</div>
</div>
</div>
</div>
<!-- Changed Services -->
<div id="services-changed-section" style="display: none;">
<h6 class="text-warning mb-2"><i class="bi bi-arrow-left-right"></i> Changed Services</h6>
<div class="table-responsive mb-3">
<table class="table table-sm">
<thead>
<tr>
<th>IP:Port</th>
<th>Old Service</th>
<th>New Service</th>
<th>Old Version</th>
<th>New Version</th>
</tr>
</thead>
<tbody id="services-changed-tbody"></tbody>
</table>
</div>
</div>
<!-- Added Services -->
<div id="services-added-section" style="display: none;">
<h6 class="text-success mb-2"><i class="bi bi-plus-circle"></i> Added Services</h6>
<div class="table-responsive mb-3">
<table class="table table-sm">
<thead>
<tr>
<th>IP Address</th>
<th>Port</th>
<th>Service</th>
<th>Product</th>
<th>Version</th>
</tr>
</thead>
<tbody id="services-added-tbody"></tbody>
</table>
</div>
</div>
<!-- Removed Services -->
<div id="services-removed-section" style="display: none;">
<h6 class="text-danger mb-2"><i class="bi bi-dash-circle"></i> Removed Services</h6>
<div class="table-responsive mb-3">
<table class="table table-sm">
<thead>
<tr>
<th>IP Address</th>
<th>Port</th>
<th>Service</th>
<th>Product</th>
<th>Version</th>
</tr>
</thead>
<tbody id="services-removed-tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Certificates Comparison -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">
<i class="bi bi-shield-lock"></i> Certificate Changes
</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-4">
<div class="stat-card" style="background-color: #065f46; border-color: #6ee7b7;">
<div class="stat-value" id="certs-added-count">0</div>
<div class="stat-label">Certificates Added</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card" style="background-color: #7f1d1d; border-color: #fca5a5;">
<div class="stat-value" id="certs-removed-count">0</div>
<div class="stat-label">Certificates Removed</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card" style="background-color: #78350f; border-color: #fcd34d;">
<div class="stat-value" id="certs-changed-count">0</div>
<div class="stat-label">Certificates Changed</div>
</div>
</div>
</div>
<!-- Changed Certificates -->
<div id="certs-changed-section" style="display: none;">
<h6 class="text-warning mb-2"><i class="bi bi-arrow-left-right"></i> Changed Certificates</h6>
<div class="table-responsive mb-3">
<table class="table table-sm">
<thead>
<tr>
<th>IP:Port</th>
<th>Old Subject</th>
<th>New Subject</th>
<th>Old Expiry</th>
<th>New Expiry</th>
</tr>
</thead>
<tbody id="certs-changed-tbody"></tbody>
</table>
</div>
</div>
<!-- Added/Removed Certificates (shown if any) -->
<div id="certs-added-removed-info" style="display: none;">
<p class="text-muted mb-0">
<i class="bi bi-info-circle"></i>
Additional certificate additions and removals correspond to the port changes shown above.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
const scanId1 = {{ scan_id1 }};
const scanId2 = {{ scan_id2 }};
// Load comparison data
async function loadComparison() {
const loadingDiv = document.getElementById('comparison-loading');
const errorDiv = document.getElementById('comparison-error');
const contentDiv = document.getElementById('comparison-content');
try {
const response = await fetch(`/api/scans/${scanId1}/compare/${scanId2}`);
if (!response.ok) {
throw new Error('Failed to load comparison');
}
const data = await response.json();
// Hide loading, show content
loadingDiv.style.display = 'none';
contentDiv.style.display = 'block';
// Populate comparison UI
populateComparison(data);
} catch (error) {
console.error('Error loading comparison:', error);
loadingDiv.style.display = 'none';
errorDiv.textContent = `Error: ${error.message}`;
errorDiv.style.display = 'block';
}
}
function populateComparison(data) {
// Drift score
const driftScore = data.drift_score || 0;
document.getElementById('drift-score').textContent = driftScore.toFixed(3);
// Color code drift score
const driftElement = document.getElementById('drift-score');
if (driftScore < 0.1) {
driftElement.style.color = '#6ee7b7'; // Green - minimal drift
} else if (driftScore < 0.3) {
driftElement.style.color = '#fcd34d'; // Yellow - moderate drift
} else {
driftElement.style.color = '#fca5a5'; // Red - significant drift
}
// Scan metadata
document.getElementById('scan1-id').textContent = data.scan1.id;
document.getElementById('scan1-title').textContent = data.scan1.title || 'Untitled Scan';
document.getElementById('scan1-timestamp').textContent = new Date(data.scan1.timestamp).toLocaleString();
document.getElementById('scan2-id').textContent = data.scan2.id;
document.getElementById('scan2-title').textContent = data.scan2.title || 'Untitled Scan';
document.getElementById('scan2-timestamp').textContent = new Date(data.scan2.timestamp).toLocaleString();
// Ports comparison
populatePortsComparison(data.ports);
// Services comparison
populateServicesComparison(data.services);
// Certificates comparison
populateCertificatesComparison(data.certificates);
}
function populatePortsComparison(ports) {
const addedCount = ports.added.length;
const removedCount = ports.removed.length;
const unchangedCount = ports.unchanged.length;
document.getElementById('ports-added-count').textContent = addedCount;
document.getElementById('ports-removed-count').textContent = removedCount;
document.getElementById('ports-unchanged-count').textContent = unchangedCount;
// Show added ports
if (addedCount > 0) {
document.getElementById('ports-added-section').style.display = 'block';
const tbody = document.getElementById('ports-added-tbody');
tbody.innerHTML = '';
ports.added.forEach(port => {
const row = document.createElement('tr');
row.classList.add('scan-row');
row.innerHTML = `
<td>${port.ip}</td>
<td class="mono">${port.port}</td>
<td>${port.protocol.toUpperCase()}</td>
<td>${port.state}</td>
`;
tbody.appendChild(row);
});
}
// Show removed ports
if (removedCount > 0) {
document.getElementById('ports-removed-section').style.display = 'block';
const tbody = document.getElementById('ports-removed-tbody');
tbody.innerHTML = '';
ports.removed.forEach(port => {
const row = document.createElement('tr');
row.classList.add('scan-row');
row.innerHTML = `
<td>${port.ip}</td>
<td class="mono">${port.port}</td>
<td>${port.protocol.toUpperCase()}</td>
<td>${port.state}</td>
`;
tbody.appendChild(row);
});
}
}
function populateServicesComparison(services) {
const addedCount = services.added.length;
const removedCount = services.removed.length;
const changedCount = services.changed.length;
document.getElementById('services-added-count').textContent = addedCount;
document.getElementById('services-removed-count').textContent = removedCount;
document.getElementById('services-changed-count').textContent = changedCount;
// Show changed services
if (changedCount > 0) {
document.getElementById('services-changed-section').style.display = 'block';
const tbody = document.getElementById('services-changed-tbody');
tbody.innerHTML = '';
services.changed.forEach(svc => {
const row = document.createElement('tr');
row.classList.add('scan-row');
row.innerHTML = `
<td class="mono">${svc.ip}:${svc.port}</td>
<td>${svc.old.service_name || '-'}</td>
<td class="text-warning">${svc.new.service_name || '-'}</td>
<td>${svc.old.version || '-'}</td>
<td class="text-warning">${svc.new.version || '-'}</td>
`;
tbody.appendChild(row);
});
}
// Show added services
if (addedCount > 0) {
document.getElementById('services-added-section').style.display = 'block';
const tbody = document.getElementById('services-added-tbody');
tbody.innerHTML = '';
services.added.forEach(svc => {
const row = document.createElement('tr');
row.classList.add('scan-row');
row.innerHTML = `
<td>${svc.ip}</td>
<td class="mono">${svc.port}</td>
<td>${svc.service_name || '-'}</td>
<td>${svc.product || '-'}</td>
<td>${svc.version || '-'}</td>
`;
tbody.appendChild(row);
});
}
// Show removed services
if (removedCount > 0) {
document.getElementById('services-removed-section').style.display = 'block';
const tbody = document.getElementById('services-removed-tbody');
tbody.innerHTML = '';
services.removed.forEach(svc => {
const row = document.createElement('tr');
row.classList.add('scan-row');
row.innerHTML = `
<td>${svc.ip}</td>
<td class="mono">${svc.port}</td>
<td>${svc.service_name || '-'}</td>
<td>${svc.product || '-'}</td>
<td>${svc.version || '-'}</td>
`;
tbody.appendChild(row);
});
}
}
function populateCertificatesComparison(certs) {
const addedCount = certs.added.length;
const removedCount = certs.removed.length;
const changedCount = certs.changed.length;
document.getElementById('certs-added-count').textContent = addedCount;
document.getElementById('certs-removed-count').textContent = removedCount;
document.getElementById('certs-changed-count').textContent = changedCount;
// Show changed certificates
if (changedCount > 0) {
document.getElementById('certs-changed-section').style.display = 'block';
const tbody = document.getElementById('certs-changed-tbody');
tbody.innerHTML = '';
certs.changed.forEach(cert => {
const row = document.createElement('tr');
row.classList.add('scan-row');
row.innerHTML = `
<td class="mono">${cert.ip}:${cert.port}</td>
<td>${cert.old.subject || '-'}</td>
<td class="text-warning">${cert.new.subject || '-'}</td>
<td>${cert.old.not_valid_after ? new Date(cert.old.not_valid_after).toLocaleDateString() : '-'}</td>
<td class="text-warning">${cert.new.not_valid_after ? new Date(cert.new.not_valid_after).toLocaleDateString() : '-'}</td>
`;
tbody.appendChild(row);
});
}
// Show info if there are added/removed certs
if (addedCount > 0 || removedCount > 0) {
document.getElementById('certs-added-removed-info').style.display = 'block';
}
}
// Load comparison on page load
loadComparison();
</script>
{% endblock %}

View File

@@ -13,7 +13,10 @@
<h1 style="color: #60a5fa;">Scan #<span id="scan-id">{{ scan_id }}</span></h1>
</div>
<div>
<button class="btn btn-secondary" onclick="refreshScan()">
<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>
@@ -117,6 +120,25 @@
</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>
<canvas id="historyChart" height="80"></canvas>
</div>
</div>
</div>
</div>
<!-- Sites and IPs -->
<div id="sites-container">
<!-- Sites will be dynamically inserted here -->
@@ -379,21 +401,180 @@
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'
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
});
// Check status code first
if (!response.ok) {
throw new Error('Failed to delete scan');
// 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. Please try again.');
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;
async function findPreviousScan() {
try {
// Get list of scans to find the previous one
const response = await fetch('/api/scans?per_page=100&status=completed');
const data = await response.json();
if (data.scans && data.scans.length > 0) {
// Find scans older than current scan
const currentScanIndex = data.scans.findIndex(s => s.id === scanId);
if (currentScanIndex !== -1 && currentScanIndex < data.scans.length - 1) {
// Get the next scan in the list (which is older due to desc order)
previousScanId = data.scans[currentScanIndex + 1].id;
// Show the compare button
const compareBtn = document.getElementById('compare-btn');
if (compareBtn) {
compareBtn.style.display = 'inline-block';
}
}
}
} 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';
const ctx = document.getElementById('historyChart').getContext('2d');
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);
}
}
// Initialize: find previous scan and load chart after loading current scan
loadScan().then(() => {
findPreviousScan();
loadHistoricalChart();
});
</script>
{% endblock %}

View File

@@ -114,13 +114,19 @@
<form id="trigger-scan-form">
<div class="mb-3">
<label for="config-file" class="form-label">Config File</label>
<input type="text"
class="form-control"
id="config-file"
name="config_file"
placeholder="/app/configs/example.yaml"
required>
<div class="form-text text-muted">Path to YAML configuration file</div>
<select class="form-select" id="config-file" name="config_file" required>
<option value="">Select a config file...</option>
{% for config in config_files %}
<option value="{{ config }}">{{ config }}</option>
{% endfor %}
</select>
<div class="form-text text-muted">
{% if config_files %}
Select a scan configuration file
{% else %}
<span class="text-warning">No config files found in /app/configs/</span>
{% endif %}
</div>
</div>
<div id="trigger-error" class="alert alert-danger" style="display: none;"></div>
</form>