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:
@@ -44,7 +44,7 @@ services:
|
|||||||
# Health check to ensure web service is running
|
# Health check to ensure web service is running
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/settings/health').read()"]
|
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/settings/health').read()"]
|
||||||
interval: 30s
|
interval: 60s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
|||||||
319
tests/test_scan_comparison.py
Normal file
319
tests/test_scan_comparison.py
Normal file
@@ -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
|
||||||
@@ -165,10 +165,12 @@ def trigger_scan():
|
|||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
# Config file validation error
|
# 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({
|
return jsonify({
|
||||||
'error': 'Invalid request',
|
'error': 'Invalid request',
|
||||||
'message': str(e)
|
'message': error_message
|
||||||
}), 400
|
}), 400
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
logger.error(f"Database error triggering scan: {str(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.
|
Compare two scans and show differences.
|
||||||
|
|
||||||
|
Compares ports, services, and certificates between two scans,
|
||||||
|
highlighting added, removed, and changed items.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
scan_id1: First scan ID
|
scan_id1: First (older) scan ID
|
||||||
scan_id2: Second scan ID
|
scan_id2: Second (newer) scan ID
|
||||||
|
|
||||||
Returns:
|
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
|
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({
|
return jsonify({
|
||||||
'scan_id1': scan_id1,
|
'error': 'Not found',
|
||||||
'scan_id2': scan_id2,
|
'message': 'One or both scans not found'
|
||||||
'diff': {},
|
}), 404
|
||||||
'message': 'Scan comparison endpoint - to be implemented in Phase 4'
|
|
||||||
})
|
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
|
# Health check endpoint
|
||||||
|
|||||||
107
web/api/stats.py
107
web/api/stats.py
@@ -149,3 +149,110 @@ def summary():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in summary: {str(e)}")
|
logger.error(f"Error in summary: {str(e)}")
|
||||||
return jsonify({'error': 'An error occurred'}), 500
|
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
|
||||||
|
|||||||
@@ -35,8 +35,20 @@ def dashboard():
|
|||||||
Returns:
|
Returns:
|
||||||
Rendered dashboard template
|
Rendered dashboard template
|
||||||
"""
|
"""
|
||||||
# TODO: Phase 5 - Add dashboard stats and recent scans
|
import os
|
||||||
return render_template('dashboard.html')
|
|
||||||
|
# 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')
|
@bp.route('/scans')
|
||||||
@@ -48,8 +60,20 @@ def scans():
|
|||||||
Returns:
|
Returns:
|
||||||
Rendered scans list template
|
Rendered scans list template
|
||||||
"""
|
"""
|
||||||
# TODO: Phase 5 - Implement scans list page
|
import os
|
||||||
return render_template('scans.html')
|
|
||||||
|
# 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>')
|
@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)
|
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')
|
@bp.route('/schedules')
|
||||||
@login_required
|
@login_required
|
||||||
def schedules():
|
def schedules():
|
||||||
|
|||||||
@@ -658,3 +658,333 @@ class ScanService:
|
|||||||
result['cipher_suites'] = []
|
result['cipher_suites'] = []
|
||||||
|
|
||||||
return result
|
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
|
||||||
|
|||||||
@@ -154,13 +154,19 @@
|
|||||||
<form id="trigger-scan-form">
|
<form id="trigger-scan-form">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="config-file" class="form-label">Config File</label>
|
<label for="config-file" class="form-label">Config File</label>
|
||||||
<input type="text"
|
<select class="form-select" id="config-file" name="config_file" required>
|
||||||
class="form-control"
|
<option value="">Select a config file...</option>
|
||||||
id="config-file"
|
{% for config in config_files %}
|
||||||
name="config_file"
|
<option value="{{ config }}">{{ config }}</option>
|
||||||
placeholder="/app/configs/example.yaml"
|
{% endfor %}
|
||||||
required>
|
</select>
|
||||||
<div class="form-text text-muted">Path to YAML configuration file</div>
|
<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>
|
||||||
<div id="trigger-error" class="alert alert-danger" style="display: none;"></div>
|
<div id="trigger-error" class="alert alert-danger" style="display: none;"></div>
|
||||||
</form>
|
</form>
|
||||||
@@ -349,7 +355,7 @@
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const data = await response.json();
|
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();
|
const data = await response.json();
|
||||||
|
|||||||
526
web/templates/scan_compare.html
Normal file
526
web/templates/scan_compare.html
Normal 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 %}
|
||||||
@@ -13,7 +13,10 @@
|
|||||||
<h1 style="color: #60a5fa;">Scan #<span id="scan-id">{{ scan_id }}</span></h1>
|
<h1 style="color: #60a5fa;">Scan #<span id="scan-id">{{ scan_id }}</span></h1>
|
||||||
</div>
|
</div>
|
||||||
<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-text">Refresh</span>
|
||||||
<span id="refresh-spinner" class="spinner-border spinner-border-sm ms-1" style="display: none;"></span>
|
<span id="refresh-spinner" class="spinner-border spinner-border-sm ms-1" style="display: none;"></span>
|
||||||
</button>
|
</button>
|
||||||
@@ -117,6 +120,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Historical Trend Chart -->
|
||||||
|
<div class="row mb-4" id="historical-chart-row" style="display: none;">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0" style="color: #60a5fa;">
|
||||||
|
<i class="bi bi-graph-up"></i> Port Count History
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted mb-3">
|
||||||
|
Historical port count trend for scans using the same configuration
|
||||||
|
</p>
|
||||||
|
<canvas id="historyChart" height="80"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Sites and IPs -->
|
<!-- Sites and IPs -->
|
||||||
<div id="sites-container">
|
<div id="sites-container">
|
||||||
<!-- Sites will be dynamically inserted here -->
|
<!-- Sites will be dynamically inserted here -->
|
||||||
@@ -379,21 +401,180 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Disable delete button to prevent double-clicks
|
||||||
|
const deleteBtn = document.getElementById('delete-btn');
|
||||||
|
deleteBtn.disabled = true;
|
||||||
|
deleteBtn.textContent = 'Deleting...';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/scans/${scanId}`, {
|
const response = await fetch(`/api/scans/${scanId}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check status code first
|
||||||
if (!response.ok) {
|
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
|
// Redirect to scans list
|
||||||
window.location.href = '{{ url_for("main.scans") }}';
|
window.location.href = '{{ url_for("main.scans") }}';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting scan:', 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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -114,13 +114,19 @@
|
|||||||
<form id="trigger-scan-form">
|
<form id="trigger-scan-form">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="config-file" class="form-label">Config File</label>
|
<label for="config-file" class="form-label">Config File</label>
|
||||||
<input type="text"
|
<select class="form-select" id="config-file" name="config_file" required>
|
||||||
class="form-control"
|
<option value="">Select a config file...</option>
|
||||||
id="config-file"
|
{% for config in config_files %}
|
||||||
name="config_file"
|
<option value="{{ config }}">{{ config }}</option>
|
||||||
placeholder="/app/configs/example.yaml"
|
{% endfor %}
|
||||||
required>
|
</select>
|
||||||
<div class="form-text text-muted">Path to YAML configuration file</div>
|
<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>
|
||||||
<div id="trigger-error" class="alert alert-danger" style="display: none;"></div>
|
<div id="trigger-error" class="alert alert-danger" style="display: none;"></div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user