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

@@ -165,10 +165,12 @@ def trigger_scan():
except ValueError as e:
# Config file validation error
logger.warning(f"Invalid config file: {str(e)}")
error_message = str(e)
logger.warning(f"Invalid config file: {error_message}")
logger.warning(f"Request data: config_file='{config_file}'")
return jsonify({
'error': 'Invalid request',
'message': str(e)
'message': error_message
}), 400
except SQLAlchemyError as e:
logger.error(f"Database error triggering scan: {str(e)}")
@@ -276,20 +278,48 @@ def compare_scans(scan_id1, scan_id2):
"""
Compare two scans and show differences.
Compares ports, services, and certificates between two scans,
highlighting added, removed, and changed items.
Args:
scan_id1: First scan ID
scan_id2: Second scan ID
scan_id1: First (older) scan ID
scan_id2: Second (newer) scan ID
Returns:
JSON response with comparison results
JSON response with comparison results including:
- scan1, scan2: Metadata for both scans
- ports: Added, removed, and unchanged ports
- services: Added, removed, and changed services
- certificates: Added, removed, and changed certificates
- drift_score: Overall drift metric (0.0-1.0)
"""
# TODO: Implement in Phase 4
return jsonify({
'scan_id1': scan_id1,
'scan_id2': scan_id2,
'diff': {},
'message': 'Scan comparison endpoint - to be implemented in Phase 4'
})
try:
# Compare scans using service
scan_service = ScanService(current_app.db_session)
comparison = scan_service.compare_scans(scan_id1, scan_id2)
if not comparison:
logger.warning(f"Scan comparison failed: one or both scans not found ({scan_id1}, {scan_id2})")
return jsonify({
'error': 'Not found',
'message': 'One or both scans not found'
}), 404
logger.info(f"Compared scans {scan_id1} and {scan_id2}: drift_score={comparison['drift_score']}")
return jsonify(comparison), 200
except SQLAlchemyError as e:
logger.error(f"Database error comparing scans {scan_id1} and {scan_id2}: {str(e)}")
return jsonify({
'error': 'Database error',
'message': 'Failed to compare scans'
}), 500
except Exception as e:
logger.error(f"Unexpected error comparing scans {scan_id1} and {scan_id2}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
# Health check endpoint

View File

@@ -149,3 +149,110 @@ def summary():
except Exception as e:
logger.error(f"Error in summary: {str(e)}")
return jsonify({'error': 'An error occurred'}), 500
@bp.route('/scan-history/<int:scan_id>', methods=['GET'])
@api_auth_required
def scan_history(scan_id):
"""
Get historical trend data for scans with the same config file.
Returns port counts and other metrics over time for the same
configuration/target as the specified scan.
Args:
scan_id: Reference scan ID
Query params:
limit: Maximum number of historical scans to include (default: 10, max: 50)
Returns:
JSON response with historical scan data
{
"scans": [
{
"id": 123,
"timestamp": "2025-01-01T12:00:00",
"title": "Scan title",
"port_count": 25,
"ip_count": 5
},
...
],
"labels": ["2025-01-01", ...],
"port_counts": [25, 26, 24, ...]
}
"""
try:
# Get query parameters
limit = request.args.get('limit', 10, type=int)
if limit > 50:
limit = 50
db_session = current_app.db_session
# Get the reference scan to find its config file
from web.models import ScanPort
reference_scan = db_session.query(Scan).filter(Scan.id == scan_id).first()
if not reference_scan:
return jsonify({'error': 'Scan not found'}), 404
config_file = reference_scan.config_file
# Query historical scans with the same config file
historical_scans = (
db_session.query(Scan)
.filter(Scan.config_file == config_file)
.filter(Scan.status == 'completed')
.order_by(Scan.timestamp.desc())
.limit(limit)
.all()
)
# Build result data
scans_data = []
labels = []
port_counts = []
for scan in reversed(historical_scans): # Reverse to get chronological order
# Count ports for this scan
port_count = (
db_session.query(func.count(ScanPort.id))
.filter(ScanPort.scan_id == scan.id)
.scalar() or 0
)
# Count unique IPs for this scan
from web.models import ScanIP
ip_count = (
db_session.query(func.count(ScanIP.id))
.filter(ScanIP.scan_id == scan.id)
.scalar() or 0
)
scans_data.append({
'id': scan.id,
'timestamp': scan.timestamp.isoformat() if scan.timestamp else None,
'title': scan.title,
'port_count': port_count,
'ip_count': ip_count
})
# For chart data
labels.append(scan.timestamp.strftime('%Y-%m-%d %H:%M') if scan.timestamp else '')
port_counts.append(port_count)
return jsonify({
'scans': scans_data,
'labels': labels,
'port_counts': port_counts,
'config_file': config_file
}), 200
except SQLAlchemyError as e:
logger.error(f"Database error in scan_history: {str(e)}")
return jsonify({'error': 'Database error occurred'}), 500
except Exception as e:
logger.error(f"Error in scan_history: {str(e)}")
return jsonify({'error': 'An error occurred'}), 500

View File

@@ -35,8 +35,20 @@ def dashboard():
Returns:
Rendered dashboard template
"""
# TODO: Phase 5 - Add dashboard stats and recent scans
return render_template('dashboard.html')
import os
# Get list of available config files
configs_dir = '/app/configs'
config_files = []
try:
if os.path.exists(configs_dir):
config_files = [f for f in os.listdir(configs_dir) if f.endswith(('.yaml', '.yml'))]
config_files.sort()
except Exception as e:
logger.error(f"Error listing config files: {e}")
return render_template('dashboard.html', config_files=config_files)
@bp.route('/scans')
@@ -48,8 +60,20 @@ def scans():
Returns:
Rendered scans list template
"""
# TODO: Phase 5 - Implement scans list page
return render_template('scans.html')
import os
# Get list of available config files
configs_dir = '/app/configs'
config_files = []
try:
if os.path.exists(configs_dir):
config_files = [f for f in os.listdir(configs_dir) if f.endswith(('.yaml', '.yml'))]
config_files.sort()
except Exception as e:
logger.error(f"Error listing config files: {e}")
return render_template('scans.html', config_files=config_files)
@bp.route('/scans/<int:scan_id>')
@@ -68,6 +92,22 @@ def scan_detail(scan_id):
return render_template('scan_detail.html', scan_id=scan_id)
@bp.route('/scans/<int:scan_id1>/compare/<int:scan_id2>')
@login_required
def compare_scans(scan_id1, scan_id2):
"""
Scan comparison page - shows differences between two scans.
Args:
scan_id1: First (older) scan ID
scan_id2: Second (newer) scan ID
Returns:
Rendered comparison template
"""
return render_template('scan_compare.html', scan_id1=scan_id1, scan_id2=scan_id2)
@bp.route('/schedules')
@login_required
def schedules():

View File

@@ -658,3 +658,333 @@ class ScanService:
result['cipher_suites'] = []
return result
def compare_scans(self, scan1_id: int, scan2_id: int) -> Optional[Dict[str, Any]]:
"""
Compare two scans and return the differences.
Compares ports, services, and certificates between two scans,
highlighting added, removed, and changed items.
Args:
scan1_id: ID of the first (older) scan
scan2_id: ID of the second (newer) scan
Returns:
Dictionary with comparison results, or None if either scan not found
{
'scan1': {...}, # Scan 1 summary
'scan2': {...}, # Scan 2 summary
'ports': {
'added': [...],
'removed': [...],
'unchanged': [...]
},
'services': {
'added': [...],
'removed': [...],
'changed': [...]
},
'certificates': {
'added': [...],
'removed': [...],
'changed': [...]
},
'drift_score': 0.0-1.0
}
"""
# Get both scans
scan1 = self.get_scan(scan1_id)
scan2 = self.get_scan(scan2_id)
if not scan1 or not scan2:
return None
# Extract port data
ports1 = self._extract_ports_from_scan(scan1)
ports2 = self._extract_ports_from_scan(scan2)
# Compare ports
ports_comparison = self._compare_ports(ports1, ports2)
# Extract service data
services1 = self._extract_services_from_scan(scan1)
services2 = self._extract_services_from_scan(scan2)
# Compare services
services_comparison = self._compare_services(services1, services2)
# Extract certificate data
certs1 = self._extract_certificates_from_scan(scan1)
certs2 = self._extract_certificates_from_scan(scan2)
# Compare certificates
certificates_comparison = self._compare_certificates(certs1, certs2)
# Calculate drift score (0.0 = identical, 1.0 = completely different)
drift_score = self._calculate_drift_score(
ports_comparison,
services_comparison,
certificates_comparison
)
return {
'scan1': {
'id': scan1['id'],
'timestamp': scan1['timestamp'],
'title': scan1['title'],
'status': scan1['status']
},
'scan2': {
'id': scan2['id'],
'timestamp': scan2['timestamp'],
'title': scan2['title'],
'status': scan2['status']
},
'ports': ports_comparison,
'services': services_comparison,
'certificates': certificates_comparison,
'drift_score': drift_score
}
def _extract_ports_from_scan(self, scan: Dict[str, Any]) -> Dict[str, Any]:
"""
Extract port information from a scan.
Returns:
Dictionary mapping "ip:port:protocol" to port details
"""
ports = {}
for site in scan.get('sites', []):
for ip_data in site.get('ips', []):
ip_addr = ip_data['address']
for port_data in ip_data.get('ports', []):
key = f"{ip_addr}:{port_data['port']}:{port_data['protocol']}"
ports[key] = {
'ip': ip_addr,
'port': port_data['port'],
'protocol': port_data['protocol'],
'state': port_data.get('state', 'unknown'),
'expected': port_data.get('expected')
}
return ports
def _extract_services_from_scan(self, scan: Dict[str, Any]) -> Dict[str, Any]:
"""
Extract service information from a scan.
Returns:
Dictionary mapping "ip:port:protocol" to service details
"""
services = {}
for site in scan.get('sites', []):
for ip_data in site.get('ips', []):
ip_addr = ip_data['address']
for port_data in ip_data.get('ports', []):
port_num = port_data['port']
protocol = port_data['protocol']
key = f"{ip_addr}:{port_num}:{protocol}"
# Get first service (usually only one per port)
port_services = port_data.get('services', [])
if port_services:
svc = port_services[0]
services[key] = {
'ip': ip_addr,
'port': port_num,
'protocol': protocol,
'service_name': svc.get('service_name'),
'product': svc.get('product'),
'version': svc.get('version'),
'extrainfo': svc.get('extrainfo')
}
return services
def _extract_certificates_from_scan(self, scan: Dict[str, Any]) -> Dict[str, Any]:
"""
Extract certificate information from a scan.
Returns:
Dictionary mapping "ip:port" to certificate details
"""
certificates = {}
for site in scan.get('sites', []):
for ip_data in site.get('ips', []):
ip_addr = ip_data['address']
for port_data in ip_data.get('ports', []):
port_num = port_data['port']
protocol = port_data['protocol']
# Get certificates from services
for svc in port_data.get('services', []):
if svc.get('certificates'):
for cert in svc['certificates']:
key = f"{ip_addr}:{port_num}"
certificates[key] = {
'ip': ip_addr,
'port': port_num,
'subject': cert.get('subject'),
'issuer': cert.get('issuer'),
'not_valid_after': cert.get('not_valid_after'),
'days_until_expiry': cert.get('days_until_expiry'),
'is_self_signed': cert.get('is_self_signed')
}
return certificates
def _compare_ports(self, ports1: Dict, ports2: Dict) -> Dict[str, List]:
"""
Compare port sets between two scans.
Returns:
Dictionary with added, removed, and unchanged ports
"""
keys1 = set(ports1.keys())
keys2 = set(ports2.keys())
added_keys = keys2 - keys1
removed_keys = keys1 - keys2
unchanged_keys = keys1 & keys2
return {
'added': [ports2[k] for k in sorted(added_keys)],
'removed': [ports1[k] for k in sorted(removed_keys)],
'unchanged': [ports2[k] for k in sorted(unchanged_keys)]
}
def _compare_services(self, services1: Dict, services2: Dict) -> Dict[str, List]:
"""
Compare services between two scans.
Returns:
Dictionary with added, removed, and changed services
"""
keys1 = set(services1.keys())
keys2 = set(services2.keys())
added_keys = keys2 - keys1
removed_keys = keys1 - keys2
common_keys = keys1 & keys2
# Find changed services (same port, different version/product)
changed = []
for key in sorted(common_keys):
svc1 = services1[key]
svc2 = services2[key]
# Check if service details changed
if (svc1.get('product') != svc2.get('product') or
svc1.get('version') != svc2.get('version') or
svc1.get('service_name') != svc2.get('service_name')):
changed.append({
'ip': svc2['ip'],
'port': svc2['port'],
'protocol': svc2['protocol'],
'old': {
'service_name': svc1.get('service_name'),
'product': svc1.get('product'),
'version': svc1.get('version')
},
'new': {
'service_name': svc2.get('service_name'),
'product': svc2.get('product'),
'version': svc2.get('version')
}
})
return {
'added': [services2[k] for k in sorted(added_keys)],
'removed': [services1[k] for k in sorted(removed_keys)],
'changed': changed
}
def _compare_certificates(self, certs1: Dict, certs2: Dict) -> Dict[str, List]:
"""
Compare certificates between two scans.
Returns:
Dictionary with added, removed, and changed certificates
"""
keys1 = set(certs1.keys())
keys2 = set(certs2.keys())
added_keys = keys2 - keys1
removed_keys = keys1 - keys2
common_keys = keys1 & keys2
# Find changed certificates (same IP:port, different cert details)
changed = []
for key in sorted(common_keys):
cert1 = certs1[key]
cert2 = certs2[key]
# Check if certificate changed
if (cert1.get('subject') != cert2.get('subject') or
cert1.get('issuer') != cert2.get('issuer') or
cert1.get('not_valid_after') != cert2.get('not_valid_after')):
changed.append({
'ip': cert2['ip'],
'port': cert2['port'],
'old': {
'subject': cert1.get('subject'),
'issuer': cert1.get('issuer'),
'not_valid_after': cert1.get('not_valid_after'),
'days_until_expiry': cert1.get('days_until_expiry')
},
'new': {
'subject': cert2.get('subject'),
'issuer': cert2.get('issuer'),
'not_valid_after': cert2.get('not_valid_after'),
'days_until_expiry': cert2.get('days_until_expiry')
}
})
return {
'added': [certs2[k] for k in sorted(added_keys)],
'removed': [certs1[k] for k in sorted(removed_keys)],
'changed': changed
}
def _calculate_drift_score(self, ports_comp: Dict, services_comp: Dict,
certs_comp: Dict) -> float:
"""
Calculate drift score based on comparison results.
Returns:
Float between 0.0 (identical) and 1.0 (completely different)
"""
# Count total items in both scans
total_ports = (
len(ports_comp['added']) +
len(ports_comp['removed']) +
len(ports_comp['unchanged'])
)
total_services = (
len(services_comp['added']) +
len(services_comp['removed']) +
len(services_comp['changed']) +
max(0, len(ports_comp['unchanged']) - len(services_comp['changed']))
)
# Count changed items
changed_ports = len(ports_comp['added']) + len(ports_comp['removed'])
changed_services = (
len(services_comp['added']) +
len(services_comp['removed']) +
len(services_comp['changed'])
)
changed_certs = (
len(certs_comp['added']) +
len(certs_comp['removed']) +
len(certs_comp['changed'])
)
# Calculate weighted drift score
# Ports have 50% weight, services 30%, certificates 20%
port_drift = changed_ports / max(total_ports, 1)
service_drift = changed_services / max(total_services, 1)
cert_drift = changed_certs / max(len(certs_comp['added']) + len(certs_comp['removed']) + len(certs_comp['changed']), 1)
drift_score = (port_drift * 0.5) + (service_drift * 0.3) + (cert_drift * 0.2)
return round(min(drift_score, 1.0), 3) # Cap at 1.0 and round to 3 decimals

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>