phase3 #2
@@ -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
|
||||
|
||||
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:
|
||||
# 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
|
||||
|
||||
107
web/api/stats.py
107
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/<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:
|
||||
Rendered dashboard template
|
||||
"""
|
||||
# TODO: Phase 5 - Add dashboard stats and recent scans
|
||||
return render_template('dashboard.html')
|
||||
import os
|
||||
|
||||
# Get list of available config files
|
||||
configs_dir = '/app/configs'
|
||||
config_files = []
|
||||
|
||||
try:
|
||||
if os.path.exists(configs_dir):
|
||||
config_files = [f for f in os.listdir(configs_dir) if f.endswith(('.yaml', '.yml'))]
|
||||
config_files.sort()
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing config files: {e}")
|
||||
|
||||
return render_template('dashboard.html', config_files=config_files)
|
||||
|
||||
|
||||
@bp.route('/scans')
|
||||
@@ -48,8 +60,20 @@ def scans():
|
||||
Returns:
|
||||
Rendered scans list template
|
||||
"""
|
||||
# TODO: Phase 5 - Implement scans list page
|
||||
return render_template('scans.html')
|
||||
import os
|
||||
|
||||
# Get list of available config files
|
||||
configs_dir = '/app/configs'
|
||||
config_files = []
|
||||
|
||||
try:
|
||||
if os.path.exists(configs_dir):
|
||||
config_files = [f for f in os.listdir(configs_dir) if f.endswith(('.yaml', '.yml'))]
|
||||
config_files.sort()
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing config files: {e}")
|
||||
|
||||
return render_template('scans.html', config_files=config_files)
|
||||
|
||||
|
||||
@bp.route('/scans/<int:scan_id>')
|
||||
@@ -68,6 +92,22 @@ def scan_detail(scan_id):
|
||||
return render_template('scan_detail.html', scan_id=scan_id)
|
||||
|
||||
|
||||
@bp.route('/scans/<int:scan_id1>/compare/<int:scan_id2>')
|
||||
@login_required
|
||||
def compare_scans(scan_id1, scan_id2):
|
||||
"""
|
||||
Scan comparison page - shows differences between two scans.
|
||||
|
||||
Args:
|
||||
scan_id1: First (older) scan ID
|
||||
scan_id2: Second (newer) scan ID
|
||||
|
||||
Returns:
|
||||
Rendered comparison template
|
||||
"""
|
||||
return render_template('scan_compare.html', scan_id1=scan_id1, scan_id2=scan_id2)
|
||||
|
||||
|
||||
@bp.route('/schedules')
|
||||
@login_required
|
||||
def schedules():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -154,13 +154,19 @@
|
||||
<form id="trigger-scan-form">
|
||||
<div class="mb-3">
|
||||
<label for="config-file" class="form-label">Config File</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="config-file"
|
||||
name="config_file"
|
||||
placeholder="/app/configs/example.yaml"
|
||||
required>
|
||||
<div class="form-text text-muted">Path to YAML configuration file</div>
|
||||
<select class="form-select" id="config-file" name="config_file" required>
|
||||
<option value="">Select a config file...</option>
|
||||
{% for config in config_files %}
|
||||
<option value="{{ config }}">{{ config }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text text-muted">
|
||||
{% if config_files %}
|
||||
Select a scan configuration file
|
||||
{% else %}
|
||||
<span class="text-warning">No config files found in /app/configs/</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div id="trigger-error" class="alert alert-danger" style="display: none;"></div>
|
||||
</form>
|
||||
@@ -349,7 +355,7 @@
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to trigger scan');
|
||||
throw new Error(data.message || data.error || 'Failed to trigger scan');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
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>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-secondary" onclick="refreshScan()">
|
||||
<button class="btn btn-primary" onclick="compareWithPrevious()" id="compare-btn" style="display: none;">
|
||||
<i class="bi bi-arrow-left-right"></i> Compare with Previous
|
||||
</button>
|
||||
<button class="btn btn-secondary ms-2" onclick="refreshScan()">
|
||||
<span id="refresh-text">Refresh</span>
|
||||
<span id="refresh-spinner" class="spinner-border spinner-border-sm ms-1" style="display: none;"></span>
|
||||
</button>
|
||||
@@ -117,6 +120,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Historical Trend Chart -->
|
||||
<div class="row mb-4" id="historical-chart-row" style="display: none;">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0" style="color: #60a5fa;">
|
||||
<i class="bi bi-graph-up"></i> Port Count History
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted mb-3">
|
||||
Historical port count trend for scans using the same configuration
|
||||
</p>
|
||||
<canvas id="historyChart" height="80"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sites and IPs -->
|
||||
<div id="sites-container">
|
||||
<!-- Sites will be dynamically inserted here -->
|
||||
@@ -379,21 +401,180 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable delete button to prevent double-clicks
|
||||
const deleteBtn = document.getElementById('delete-btn');
|
||||
deleteBtn.disabled = true;
|
||||
deleteBtn.textContent = 'Deleting...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/scans/${scanId}`, {
|
||||
method: 'DELETE'
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
// Check status code first
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete scan');
|
||||
// Try to get error message from response
|
||||
let errorMessage = `HTTP ${response.status}: Failed to delete scan`;
|
||||
try {
|
||||
const data = await response.json();
|
||||
errorMessage = data.message || errorMessage;
|
||||
} catch (e) {
|
||||
// Ignore JSON parse errors for error responses
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// For successful responses, try to parse JSON but don't fail if it doesn't work
|
||||
try {
|
||||
await response.json();
|
||||
} catch (e) {
|
||||
console.warn('Response is not valid JSON, but deletion succeeded');
|
||||
}
|
||||
|
||||
// Wait 2 seconds to ensure deletion completes fully
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Redirect to scans list
|
||||
window.location.href = '{{ url_for("main.scans") }}';
|
||||
} catch (error) {
|
||||
console.error('Error deleting scan:', error);
|
||||
alert('Failed to delete scan. Please try again.');
|
||||
alert(`Failed to delete scan: ${error.message}`);
|
||||
|
||||
// Re-enable button on error
|
||||
deleteBtn.disabled = false;
|
||||
deleteBtn.textContent = 'Delete Scan';
|
||||
}
|
||||
}
|
||||
|
||||
// Find previous scan and show compare button
|
||||
let previousScanId = null;
|
||||
async function findPreviousScan() {
|
||||
try {
|
||||
// Get list of scans to find the previous one
|
||||
const response = await fetch('/api/scans?per_page=100&status=completed');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.scans && data.scans.length > 0) {
|
||||
// Find scans older than current scan
|
||||
const currentScanIndex = data.scans.findIndex(s => s.id === scanId);
|
||||
|
||||
if (currentScanIndex !== -1 && currentScanIndex < data.scans.length - 1) {
|
||||
// Get the next scan in the list (which is older due to desc order)
|
||||
previousScanId = data.scans[currentScanIndex + 1].id;
|
||||
|
||||
// Show the compare button
|
||||
const compareBtn = document.getElementById('compare-btn');
|
||||
if (compareBtn) {
|
||||
compareBtn.style.display = 'inline-block';
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error finding previous scan:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Compare with previous scan
|
||||
function compareWithPrevious() {
|
||||
if (previousScanId) {
|
||||
window.location.href = `/scans/${previousScanId}/compare/${scanId}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Load historical trend chart
|
||||
async function loadHistoricalChart() {
|
||||
try {
|
||||
const response = await fetch(`/api/stats/scan-history/${scanId}?limit=20`);
|
||||
const data = await response.json();
|
||||
|
||||
// Only show chart if there are multiple scans
|
||||
if (data.scans && data.scans.length > 1) {
|
||||
document.getElementById('historical-chart-row').style.display = 'block';
|
||||
|
||||
const ctx = document.getElementById('historyChart').getContext('2d');
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [{
|
||||
label: 'Open Ports',
|
||||
data: data.port_counts,
|
||||
borderColor: '#60a5fa',
|
||||
backgroundColor: 'rgba(96, 165, 250, 0.1)',
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
pointBackgroundColor: '#60a5fa',
|
||||
pointBorderColor: '#1e293b',
|
||||
pointBorderWidth: 2,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: '#1e293b',
|
||||
titleColor: '#e2e8f0',
|
||||
bodyColor: '#e2e8f0',
|
||||
borderColor: '#334155',
|
||||
borderWidth: 1,
|
||||
callbacks: {
|
||||
afterLabel: function(context) {
|
||||
const scan = data.scans[context.dataIndex];
|
||||
return `Scan ID: ${scan.id}\nIPs: ${scan.ip_count}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
color: '#94a3b8'
|
||||
},
|
||||
grid: {
|
||||
color: '#334155'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#94a3b8',
|
||||
maxRotation: 45,
|
||||
minRotation: 45
|
||||
},
|
||||
grid: {
|
||||
color: '#334155'
|
||||
}
|
||||
}
|
||||
},
|
||||
onClick: (event, elements) => {
|
||||
if (elements.length > 0) {
|
||||
const index = elements[0].index;
|
||||
const scan = data.scans[index];
|
||||
window.location.href = `/scans/${scan.id}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading historical chart:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize: find previous scan and load chart after loading current scan
|
||||
loadScan().then(() => {
|
||||
findPreviousScan();
|
||||
loadHistoricalChart();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -114,13 +114,19 @@
|
||||
<form id="trigger-scan-form">
|
||||
<div class="mb-3">
|
||||
<label for="config-file" class="form-label">Config File</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="config-file"
|
||||
name="config_file"
|
||||
placeholder="/app/configs/example.yaml"
|
||||
required>
|
||||
<div class="form-text text-muted">Path to YAML configuration file</div>
|
||||
<select class="form-select" id="config-file" name="config_file" required>
|
||||
<option value="">Select a config file...</option>
|
||||
{% for config in config_files %}
|
||||
<option value="{{ config }}">{{ config }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text text-muted">
|
||||
{% if config_files %}
|
||||
Select a scan configuration file
|
||||
{% else %}
|
||||
<span class="text-warning">No config files found in /app/configs/</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div id="trigger-error" class="alert alert-danger" style="display: none;"></div>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user