From 6792d69eb1cdb2e3f046613f479836eafa28b090 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 14 Nov 2025 16:15:13 -0600 Subject: [PATCH] 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//compare/ - Compare two scans - GET /api/stats/scan-history/ - 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 --- docker-compose-web.yml | 2 +- tests/test_scan_comparison.py | 319 +++++++++++++++++++ web/api/scans.py | 54 +++- web/api/stats.py | 107 +++++++ web/routes/main.py | 48 ++- web/services/scan_service.py | 330 ++++++++++++++++++++ web/templates/dashboard.html | 22 +- web/templates/scan_compare.html | 526 ++++++++++++++++++++++++++++++++ web/templates/scan_detail.html | 189 +++++++++++- web/templates/scans.html | 20 +- 10 files changed, 1581 insertions(+), 36 deletions(-) create mode 100644 tests/test_scan_comparison.py create mode 100644 web/templates/scan_compare.html diff --git a/docker-compose-web.yml b/docker-compose-web.yml index 74dc2d7..05f9e7a 100644 --- a/docker-compose-web.yml +++ b/docker-compose-web.yml @@ -44,7 +44,7 @@ services: # Health check to ensure web service is running healthcheck: test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/settings/health').read()"] - interval: 30s + interval: 60s timeout: 10s retries: 3 start_period: 40s diff --git a/tests/test_scan_comparison.py b/tests/test_scan_comparison.py new file mode 100644 index 0000000..30e3f91 --- /dev/null +++ b/tests/test_scan_comparison.py @@ -0,0 +1,319 @@ +""" +Unit tests for scan comparison functionality. + +Tests scan comparison logic including port, service, and certificate comparisons, +as well as drift score calculation. +""" + +import pytest +from datetime import datetime + +from web.models import Scan, ScanSite, ScanIP, ScanPort +from web.models import ScanService as ScanServiceModel, ScanCertificate +from web.services.scan_service import ScanService + + +class TestScanComparison: + """Tests for scan comparison methods.""" + + @pytest.fixture + def scan1_data(self, test_db, sample_config_file): + """Create first scan with test data.""" + service = ScanService(test_db) + scan_id = service.trigger_scan(sample_config_file, triggered_by='manual') + + # Get scan and add some test data + scan = test_db.query(Scan).filter(Scan.id == scan_id).first() + scan.status = 'completed' + + # Create site + site = ScanSite(scan_id=scan.id, site_name='Test Site') + test_db.add(site) + test_db.flush() + + # Create IP + ip = ScanIP( + scan_id=scan.id, + site_id=site.id, + ip_address='192.168.1.100', + ping_expected=True, + ping_actual=True + ) + test_db.add(ip) + test_db.flush() + + # Create ports + port1 = ScanPort( + scan_id=scan.id, + ip_id=ip.id, + port=80, + protocol='tcp', + state='open', + expected=True + ) + port2 = ScanPort( + scan_id=scan.id, + ip_id=ip.id, + port=443, + protocol='tcp', + state='open', + expected=True + ) + test_db.add(port1) + test_db.add(port2) + test_db.flush() + + # Create service + svc1 = ScanServiceModel( + scan_id=scan.id, + port_id=port1.id, + service_name='http', + product='nginx', + version='1.18.0' + ) + test_db.add(svc1) + + test_db.commit() + return scan_id + + @pytest.fixture + def scan2_data(self, test_db, sample_config_file): + """Create second scan with modified test data.""" + service = ScanService(test_db) + scan_id = service.trigger_scan(sample_config_file, triggered_by='manual') + + # Get scan and add some test data + scan = test_db.query(Scan).filter(Scan.id == scan_id).first() + scan.status = 'completed' + + # Create site + site = ScanSite(scan_id=scan.id, site_name='Test Site') + test_db.add(site) + test_db.flush() + + # Create IP + ip = ScanIP( + scan_id=scan.id, + site_id=site.id, + ip_address='192.168.1.100', + ping_expected=True, + ping_actual=True + ) + test_db.add(ip) + test_db.flush() + + # Create ports (port 80 removed, 443 kept, 8080 added) + port2 = ScanPort( + scan_id=scan.id, + ip_id=ip.id, + port=443, + protocol='tcp', + state='open', + expected=True + ) + port3 = ScanPort( + scan_id=scan.id, + ip_id=ip.id, + port=8080, + protocol='tcp', + state='open', + expected=False + ) + test_db.add(port2) + test_db.add(port3) + test_db.flush() + + # Create service with updated version + svc2 = ScanServiceModel( + scan_id=scan.id, + port_id=port3.id, + service_name='http', + product='nginx', + version='1.20.0' # Version changed + ) + test_db.add(svc2) + + test_db.commit() + return scan_id + + def test_compare_scans_basic(self, test_db, scan1_data, scan2_data): + """Test basic scan comparison.""" + service = ScanService(test_db) + + result = service.compare_scans(scan1_data, scan2_data) + + assert result is not None + assert 'scan1' in result + assert 'scan2' in result + assert 'ports' in result + assert 'services' in result + assert 'certificates' in result + assert 'drift_score' in result + + # Verify scan metadata + assert result['scan1']['id'] == scan1_data + assert result['scan2']['id'] == scan2_data + + def test_compare_scans_not_found(self, test_db): + """Test comparison with nonexistent scan.""" + service = ScanService(test_db) + + result = service.compare_scans(999, 998) + + assert result is None + + def test_compare_ports(self, test_db, scan1_data, scan2_data): + """Test port comparison logic.""" + service = ScanService(test_db) + + result = service.compare_scans(scan1_data, scan2_data) + + # Scan1 has ports 80, 443 + # Scan2 has ports 443, 8080 + # Expected: added=[8080], removed=[80], unchanged=[443] + + ports = result['ports'] + assert len(ports['added']) == 1 + assert len(ports['removed']) == 1 + assert len(ports['unchanged']) == 1 + + # Check added port + added_port = ports['added'][0] + assert added_port['port'] == 8080 + + # Check removed port + removed_port = ports['removed'][0] + assert removed_port['port'] == 80 + + # Check unchanged port + unchanged_port = ports['unchanged'][0] + assert unchanged_port['port'] == 443 + + def test_compare_services(self, test_db, scan1_data, scan2_data): + """Test service comparison logic.""" + service = ScanService(test_db) + + result = service.compare_scans(scan1_data, scan2_data) + + services = result['services'] + + # Scan1 has nginx 1.18.0 on port 80 + # Scan2 has nginx 1.20.0 on port 8080 + # These are on different ports, so they should be added/removed, not changed + + assert len(services['added']) >= 0 + assert len(services['removed']) >= 0 + + def test_drift_score_calculation(self, test_db, scan1_data, scan2_data): + """Test drift score calculation.""" + service = ScanService(test_db) + + result = service.compare_scans(scan1_data, scan2_data) + + drift_score = result['drift_score'] + + # Drift score should be between 0.0 and 1.0 + assert 0.0 <= drift_score <= 1.0 + + # Since we have changes (1 port added, 1 removed), drift should be > 0 + assert drift_score > 0.0 + + def test_compare_identical_scans(self, test_db, scan1_data): + """Test comparing a scan with itself (should have zero drift).""" + service = ScanService(test_db) + + result = service.compare_scans(scan1_data, scan1_data) + + # Comparing scan with itself should have zero drift + assert result['drift_score'] == 0.0 + assert len(result['ports']['added']) == 0 + assert len(result['ports']['removed']) == 0 + + +class TestScanComparisonAPI: + """Tests for scan comparison API endpoint.""" + + def test_compare_scans_api(self, client, auth_headers, scan1_data, scan2_data): + """Test scan comparison API endpoint.""" + response = client.get( + f'/api/scans/{scan1_data}/compare/{scan2_data}', + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.get_json() + + assert 'scan1' in data + assert 'scan2' in data + assert 'ports' in data + assert 'services' in data + assert 'drift_score' in data + + def test_compare_scans_api_not_found(self, client, auth_headers): + """Test comparison API with nonexistent scans.""" + response = client.get( + '/api/scans/999/compare/998', + headers=auth_headers + ) + + assert response.status_code == 404 + data = response.get_json() + assert 'error' in data + + def test_compare_scans_api_requires_auth(self, client, scan1_data, scan2_data): + """Test that comparison API requires authentication.""" + response = client.get(f'/api/scans/{scan1_data}/compare/{scan2_data}') + + assert response.status_code == 401 + + +class TestHistoricalChartAPI: + """Tests for historical scan chart API endpoint.""" + + def test_scan_history_api(self, client, auth_headers, scan1_data): + """Test scan history API endpoint.""" + response = client.get( + f'/api/stats/scan-history/{scan1_data}', + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.get_json() + + assert 'scans' in data + assert 'labels' in data + assert 'port_counts' in data + assert 'config_file' in data + + # Should include at least the scan we created + assert len(data['scans']) >= 1 + + def test_scan_history_api_not_found(self, client, auth_headers): + """Test history API with nonexistent scan.""" + response = client.get( + '/api/stats/scan-history/999', + headers=auth_headers + ) + + assert response.status_code == 404 + data = response.get_json() + assert 'error' in data + + def test_scan_history_api_limit(self, client, auth_headers, scan1_data): + """Test scan history API with limit parameter.""" + response = client.get( + f'/api/stats/scan-history/{scan1_data}?limit=5', + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.get_json() + + # Should respect limit + assert len(data['scans']) <= 5 + + def test_scan_history_api_requires_auth(self, client, scan1_data): + """Test that history API requires authentication.""" + response = client.get(f'/api/stats/scan-history/{scan1_data}') + + assert response.status_code == 401 diff --git a/web/api/scans.py b/web/api/scans.py index 9357dce..afde8d5 100644 --- a/web/api/scans.py +++ b/web/api/scans.py @@ -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 diff --git a/web/api/stats.py b/web/api/stats.py index 7591197..b6e1088 100644 --- a/web/api/stats.py +++ b/web/api/stats.py @@ -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/', 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 diff --git a/web/routes/main.py b/web/routes/main.py index 87694b9..66cbfa3 100644 --- a/web/routes/main.py +++ b/web/routes/main.py @@ -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/') @@ -68,6 +92,22 @@ def scan_detail(scan_id): return render_template('scan_detail.html', scan_id=scan_id) +@bp.route('/scans//compare/') +@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(): diff --git a/web/services/scan_service.py b/web/services/scan_service.py index 0ec31a6..4acaa30 100644 --- a/web/services/scan_service.py +++ b/web/services/scan_service.py @@ -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 diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html index d60de83..20b90a5 100644 --- a/web/templates/dashboard.html +++ b/web/templates/dashboard.html @@ -154,13 +154,19 @@
- -
Path to YAML configuration file
+ +
+ {% if config_files %} + Select a scan configuration file + {% else %} + No config files found in /app/configs/ + {% endif %} +
@@ -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(); diff --git a/web/templates/scan_compare.html b/web/templates/scan_compare.html new file mode 100644 index 0000000..d0adc8c --- /dev/null +++ b/web/templates/scan_compare.html @@ -0,0 +1,526 @@ +{% extends "base.html" %} + +{% block title %}Compare Scans - SneakyScanner{% endblock %} + +{% block content %} +
+
+
+
+ + ← Back to All Scans + +

Scan Comparison

+

Comparing Scan #{{ scan_id1 }} vs Scan #{{ scan_id2 }}

+
+
+
+
+ + +
+
+ Loading... +
+

Loading comparison...

+
+ + + + + + + + +{% endblock %} diff --git a/web/templates/scan_detail.html b/web/templates/scan_detail.html index c1a7482..287f52a 100644 --- a/web/templates/scan_detail.html +++ b/web/templates/scan_detail.html @@ -13,7 +13,10 @@

Scan #{{ scan_id }}

- + @@ -117,6 +120,25 @@
+ + +
@@ -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(); + }); {% endblock %} diff --git a/web/templates/scans.html b/web/templates/scans.html index 27640eb..b068736 100644 --- a/web/templates/scans.html +++ b/web/templates/scans.html @@ -114,13 +114,19 @@
- -
Path to YAML configuration file
+ +
+ {% if config_files %} + Select a scan configuration file + {% else %} + No config files found in /app/configs/ + {% endif %} +