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:
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
|
||||
|
||||
Reference in New Issue
Block a user