hot fixes for several UI and logic issues

This commit is contained in:
2025-11-17 15:41:51 -06:00
parent 5f2314a532
commit 72c4f3d29b
13 changed files with 233 additions and 93 deletions

View File

@@ -1,14 +0,0 @@
title: DMZ
sites:
- name: DMZ Reverse Proxys
ips:
- address: 10.10.99.10
expected:
ping: true
tcp_ports: [22,80,443]
udp_ports: []
- address: 10.10.99.20
expected:
ping: true
tcp_ports: [22,80,443]
udp_ports: []

View File

@@ -1,16 +0,0 @@
title: "Sneaky Infra Scan"
sites:
- name: "Production Web Servers"
ips:
- address: "10.10.20.4"
expected:
ping: true
tcp_ports: [22, 53, 80]
udp_ports: [53]
# Optional: specify expected services (detected automatically)
services: ["ssh", "domain", "http"]
- address: "10.10.20.11"
expected:
ping: true
tcp_ports: [22, 111, 3128, 8006]
udp_ports: []

View File

@@ -215,23 +215,63 @@ Missing,Columns,Here
service.delete_config('nonexistent.yaml')
def test_delete_config_used_by_schedule(self, service, temp_configs_dir, sample_yaml_config, monkeypatch):
"""Test deleting config that is used by schedules"""
"""Test deleting config that is used by schedules - should cascade delete schedules"""
# Create a config file
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
with open(config_path, 'w') as f:
f.write(sample_yaml_config)
# Mock get_schedules_using_config to return schedules
def mock_get_schedules(filename):
return ['Daily Scan', 'Weekly Audit']
# Mock schedule service interactions
deleted_schedule_ids = []
monkeypatch.setattr(service, 'get_schedules_using_config', mock_get_schedules)
class MockScheduleService:
def __init__(self, db):
self.db = db
with pytest.raises(ValueError, match="used by the following schedules"):
service.delete_config('test-scan.yaml')
def list_schedules(self, page=1, per_page=10000):
return {
'schedules': [
{
'id': 1,
'name': 'Daily Scan',
'config_file': 'test-scan.yaml',
'enabled': True
},
{
'id': 2,
'name': 'Weekly Audit',
'config_file': 'test-scan.yaml',
'enabled': False # Disabled schedule should also be deleted
}
]
}
# Config should still exist
assert service.config_exists('test-scan.yaml')
def delete_schedule(self, schedule_id):
deleted_schedule_ids.append(schedule_id)
return True
# Mock the ScheduleService import
import sys
from unittest.mock import MagicMock
mock_module = MagicMock()
mock_module.ScheduleService = MockScheduleService
monkeypatch.setitem(sys.modules, 'web.services.schedule_service', mock_module)
# Mock current_app
mock_app = MagicMock()
mock_app.db_session = MagicMock()
import flask
monkeypatch.setattr(flask, 'current_app', mock_app)
# Delete the config - should cascade delete associated schedules
service.delete_config('test-scan.yaml')
# Config should be deleted
assert not service.config_exists('test-scan.yaml')
# Both schedules (enabled and disabled) should be deleted
assert deleted_schedule_ids == [1, 2]
def test_validate_config_content_valid(self, service):
"""Test validating valid config content"""

View File

@@ -404,7 +404,10 @@ def update_config(filename: str):
@api_auth_required
def delete_config(filename: str):
"""
Delete config file.
Delete config file and cascade delete associated schedules.
When a config is deleted, all schedules using that config (both enabled
and disabled) are automatically deleted as well.
Args:
filename: Config filename
@@ -418,7 +421,6 @@ def delete_config(filename: str):
Error responses:
- 404: Config file not found
- 422: Config is used by schedules (cannot delete)
- 500: Internal server error
"""
try:
@@ -442,14 +444,6 @@ def delete_config(filename: str):
'message': str(e)
}), 404
except ValueError as e:
# Config is used by schedules
logger.warning(f"Cannot delete config {filename}: {str(e)}")
return jsonify({
'error': 'Config in use',
'message': str(e)
}), 422
except Exception as e:
logger.error(f"Unexpected error deleting config {filename}: {str(e)}", exc_info=True)
return jsonify({

View File

@@ -301,26 +301,71 @@ class ConfigService:
def delete_config(self, filename: str) -> None:
"""
Delete config file if not used by schedules.
Delete config file and cascade delete any associated schedules.
When a config is deleted, all schedules using that config (both enabled
and disabled) are automatically deleted as well, since they would be
invalid without the config file.
Args:
filename: Config filename to delete
Raises:
FileNotFoundError: If config doesn't exist
ValueError: If config used by active schedules
"""
filepath = os.path.join(self.configs_dir, filename)
if not os.path.exists(filepath):
raise FileNotFoundError(f"Config file '{filename}' not found")
# Check if used by schedules
schedules = self.get_schedules_using_config(filename)
if schedules:
schedule_list = ', '.join(schedules)
raise ValueError(
f"Cannot delete config '{filename}' because it is used by the following schedules: {schedule_list}"
# Delete any schedules using this config (both enabled and disabled)
try:
from web.services.schedule_service import ScheduleService
from flask import current_app
# Get database session from Flask app
db = current_app.db_session
# Get all schedules
schedule_service = ScheduleService(db)
result = schedule_service.list_schedules(page=1, per_page=10000)
schedules = result.get('schedules', [])
# Build full path for comparison
config_path = os.path.join(self.configs_dir, filename)
# Find and delete all schedules using this config (enabled or disabled)
deleted_schedules = []
for schedule in schedules:
schedule_config = schedule.get('config_file', '')
# Handle both absolute paths and just filenames
if schedule_config == filename or schedule_config == config_path:
schedule_id = schedule.get('id')
schedule_name = schedule.get('name', 'Unknown')
try:
schedule_service.delete_schedule(schedule_id)
deleted_schedules.append(schedule_name)
except Exception as e:
import logging
logging.getLogger(__name__).warning(
f"Failed to delete schedule {schedule_id} ('{schedule_name}'): {e}"
)
if deleted_schedules:
import logging
logging.getLogger(__name__).info(
f"Cascade deleted {len(deleted_schedules)} schedule(s) associated with config '{filename}': {', '.join(deleted_schedules)}"
)
except ImportError:
# If ScheduleService doesn't exist yet, skip schedule deletion
pass
except Exception as e:
# Log error but continue with config deletion
import logging
logging.getLogger(__name__).error(
f"Error deleting schedules for config {filename}: {e}", exc_info=True
)
# Delete file
@@ -404,30 +449,42 @@ class ConfigService:
# Import here to avoid circular dependency
try:
from web.services.schedule_service import ScheduleService
schedule_service = ScheduleService()
from flask import current_app
# Get all schedules
schedules = schedule_service.list_schedules()
# Get database session from Flask app
db = current_app.db_session
# Get all schedules (use large per_page to get all)
schedule_service = ScheduleService(db)
result = schedule_service.list_schedules(page=1, per_page=10000)
# Extract schedules list from paginated result
schedules = result.get('schedules', [])
# Build full path for comparison
config_path = os.path.join(self.configs_dir, filename)
# Find schedules using this config
# Find schedules using this config (only enabled schedules)
using_schedules = []
for schedule in schedules:
schedule_config = schedule.get('config_file', '')
# Handle both absolute paths and just filenames
if schedule_config == filename or schedule_config == config_path:
using_schedules.append(schedule.get('name', 'Unknown'))
# Only count enabled schedules
if schedule.get('enabled', False):
using_schedules.append(schedule.get('name', 'Unknown'))
return using_schedules
except ImportError:
# If ScheduleService doesn't exist yet, return empty list
return []
except Exception:
except Exception as e:
# If any error occurs, return empty list (safer than failing)
# Log the error for debugging
import logging
logging.getLogger(__name__).error(f"Error getting schedules using config {filename}: {e}", exc_info=True)
return []
def generate_filename_from_title(self, title: str) -> str:

View File

@@ -561,6 +561,7 @@ class ScanService:
'duration': scan.duration,
'status': scan.status,
'title': scan.title,
'config_file': scan.config_file,
'triggered_by': scan.triggered_by,
'created_at': scan.created_at.isoformat() if scan.created_at else None
}
@@ -675,6 +676,8 @@ class ScanService:
{
'scan1': {...}, # Scan 1 summary
'scan2': {...}, # Scan 2 summary
'same_config': bool, # Whether both scans used the same config
'config_warning': str | None, # Warning message if configs differ
'ports': {
'added': [...],
'removed': [...],
@@ -700,6 +703,22 @@ class ScanService:
if not scan1 or not scan2:
return None
# Check if scans use the same configuration
config1 = scan1.get('config_file', '')
config2 = scan2.get('config_file', '')
same_config = (config1 == config2) and (config1 != '')
# Generate warning message if configs differ
config_warning = None
if not same_config:
config_warning = (
f"These scans use different configurations. "
f"Scan #{scan1_id} used '{config1 or 'unknown'}' and "
f"Scan #{scan2_id} used '{config2 or 'unknown'}'. "
f"The comparison may show all changes as additions/removals if the scans "
f"cover different IP ranges or infrastructure."
)
# Extract port data
ports1 = self._extract_ports_from_scan(scan1)
ports2 = self._extract_ports_from_scan(scan2)
@@ -733,14 +752,18 @@ class ScanService:
'id': scan1['id'],
'timestamp': scan1['timestamp'],
'title': scan1['title'],
'status': scan1['status']
'status': scan1['status'],
'config_file': config1
},
'scan2': {
'id': scan2['id'],
'timestamp': scan2['timestamp'],
'title': scan2['title'],
'status': scan2['status']
'status': scan2['status'],
'config_file': config2
},
'same_config': same_config,
'config_warning': config_warning,
'ports': ports_comparison,
'services': services_comparison,
'certificates': certificates_comparison,

View File

@@ -154,26 +154,33 @@
<form id="trigger-scan-form">
<div class="mb-3">
<label for="config-file" class="form-label">Config File</label>
<select class="form-select" id="config-file" name="config_file" required>
<select class="form-select" id="config-file" name="config_file" required {% if not config_files %}disabled{% endif %}>
<option value="">Select a config file...</option>
{% for config in config_files %}
<option value="{{ config }}">{{ config }}</option>
{% endfor %}
</select>
{% if config_files %}
<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 %}
Select a scan configuration file
</div>
{% else %}
<div class="alert alert-warning mt-2 mb-0" role="alert">
<i class="bi bi-exclamation-triangle"></i>
<strong>No configurations available</strong>
<p class="mb-2 mt-2">You need to create a configuration file before you can trigger a scan.</p>
<a href="{{ url_for('main.upload_config') }}" class="btn btn-sm btn-primary">
<i class="bi bi-plus-circle"></i> Create Configuration
</a>
</div>
{% endif %}
</div>
<div id="trigger-error" class="alert alert-danger" style="display: none;"></div>
</form>
</div>
<div class="modal-footer" style="border-top: 1px solid #334155;">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="triggerScan()">
<button type="button" class="btn btn-primary" onclick="triggerScan()" {% if not config_files %}disabled{% endif %}>
<span id="modal-trigger-text">Trigger Scan</span>
<span id="modal-trigger-spinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
</button>

View File

@@ -28,6 +28,13 @@
<!-- Error State -->
<div id="comparison-error" class="alert alert-danger" style="display: none;"></div>
<!-- Config Warning -->
<div id="config-warning" class="alert alert-warning" style="display: none;">
<i class="bi bi-exclamation-triangle"></i>
<strong>Different Configurations Detected</strong>
<p class="mb-0" id="config-warning-message"></p>
</div>
<!-- Comparison Content -->
<div id="comparison-content" style="display: none;">
<!-- Drift Score Card -->
@@ -52,14 +59,16 @@
<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>
<small class="text-muted d-block" id="scan1-timestamp">-</small>
<small class="text-muted d-block"><i class="bi bi-file-earmark-text"></i> <span id="scan1-config">-</span></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>
<small class="text-muted d-block" id="scan2-timestamp">-</small>
<small class="text-muted d-block"><i class="bi bi-file-earmark-text"></i> <span id="scan2-config">-</span></small>
</div>
</div>
<div class="col-md-4">
@@ -340,6 +349,14 @@
}
function populateComparison(data) {
// Show config warning if configs differ
if (data.config_warning) {
const warningDiv = document.getElementById('config-warning');
const warningMessage = document.getElementById('config-warning-message');
warningMessage.textContent = data.config_warning;
warningDiv.style.display = 'block';
}
// Drift score
const driftScore = data.drift_score || 0;
document.getElementById('drift-score').textContent = driftScore.toFixed(3);
@@ -358,10 +375,12 @@
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('scan1-config').textContent = data.scan1.config_file || 'Unknown';
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();
document.getElementById('scan2-config').textContent = data.scan2.config_file || 'Unknown';
// Ports comparison
populatePortsComparison(data.ports);

View File

@@ -457,24 +457,44 @@
// Find previous scan and show compare button
let previousScanId = null;
let currentConfigFile = null;
async function findPreviousScan() {
try {
// Get list of scans to find the previous one
// Get current scan details first to know which config it used
const currentScanResponse = await fetch(`/api/scans/${scanId}`);
const currentScanData = await currentScanResponse.json();
currentConfigFile = currentScanData.config_file;
// Get list of completed scans
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
// Find the 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;
if (currentScanIndex !== -1) {
// Look for the most recent previous scan with the SAME config file
for (let i = currentScanIndex + 1; i < data.scans.length; i++) {
const previousScan = data.scans[i];
// Show the compare button
const compareBtn = document.getElementById('compare-btn');
if (compareBtn) {
compareBtn.style.display = 'inline-block';
// Check if this scan uses the same config
if (previousScan.config_file === currentConfigFile) {
previousScanId = previousScan.id;
// Show the compare button
const compareBtn = document.getElementById('compare-btn');
if (compareBtn) {
compareBtn.style.display = 'inline-block';
compareBtn.title = `Compare with Scan #${previousScanId} (same config)`;
}
break; // Found the most recent matching scan
}
}
// If no matching config found, don't show compare button
if (!previousScanId) {
console.log('No previous scans found with the same configuration');
}
}
}

View File

@@ -114,26 +114,33 @@
<form id="trigger-scan-form">
<div class="mb-3">
<label for="config-file" class="form-label">Config File</label>
<select class="form-select" id="config-file" name="config_file" required>
<select class="form-select" id="config-file" name="config_file" required {% if not config_files %}disabled{% endif %}>
<option value="">Select a config file...</option>
{% for config in config_files %}
<option value="{{ config }}">{{ config }}</option>
{% endfor %}
</select>
{% if config_files %}
<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 %}
Select a scan configuration file
</div>
{% else %}
<div class="alert alert-warning mt-2 mb-0" role="alert">
<i class="bi bi-exclamation-triangle"></i>
<strong>No configurations available</strong>
<p class="mb-2 mt-2">You need to create a configuration file before you can trigger a scan.</p>
<a href="{{ url_for('main.upload_config') }}" class="btn btn-sm btn-primary">
<i class="bi bi-plus-circle"></i> Create Configuration
</a>
</div>
{% endif %}
</div>
<div id="trigger-error" class="alert alert-danger" style="display: none;"></div>
</form>
</div>
<div class="modal-footer" style="border-top: 1px solid #334155;">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="triggerScan()">
<button type="button" class="btn btn-primary" onclick="triggerScan()" {% if not config_files %}disabled{% endif %}>
<span id="modal-trigger-text">Trigger Scan</span>
<span id="modal-trigger-spinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
</button>

View File

@@ -420,7 +420,8 @@ document.getElementById('create-schedule-form').addEventListener('submit', async
// Show notification
function showNotification(message, type = 'info') {
const notification = document.createElement('div');
notification.className = `alert alert-${type} alert-dismissible fade show position-fixed`;
notification.className = `alert alert-${type} alert-dismissible fade show`;
notification.style.position = 'fixed';
notification.style.top = '20px';
notification.style.right = '20px';
notification.style.zIndex = '9999';

View File

@@ -555,7 +555,8 @@ async function deleteSchedule() {
// Show notification
function showNotification(message, type = 'info') {
const notification = document.createElement('div');
notification.className = `alert alert-${type} alert-dismissible fade show position-fixed`;
notification.className = `alert alert-${type} alert-dismissible fade show`;
notification.style.position = 'fixed';
notification.style.top = '20px';
notification.style.right = '20px';
notification.style.zIndex = '9999';

View File

@@ -354,7 +354,8 @@ async function deleteSchedule(scheduleId) {
function showNotification(message, type = 'info') {
// Create notification element
const notification = document.createElement('div');
notification.className = `alert alert-${type} alert-dismissible fade show position-fixed`;
notification.className = `alert alert-${type} alert-dismissible fade show`;
notification.style.position = 'fixed';
notification.style.top = '20px';
notification.style.right = '20px';
notification.style.zIndex = '9999';