diff --git a/configs/dmz.yaml b/configs/dmz.yaml deleted file mode 100644 index c818d63..0000000 --- a/configs/dmz.yaml +++ /dev/null @@ -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: [] \ No newline at end of file diff --git a/configs/example-site.yaml b/configs/example-site.yaml deleted file mode 100644 index 21a6869..0000000 --- a/configs/example-site.yaml +++ /dev/null @@ -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: [] \ No newline at end of file diff --git a/tests/test_config_service.py b/tests/test_config_service.py index 3fee4f8..ab759dd 100644 --- a/tests/test_config_service.py +++ b/tests/test_config_service.py @@ -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""" diff --git a/web/api/configs.py b/web/api/configs.py index 6813dd5..e40a0bb 100644 --- a/web/api/configs.py +++ b/web/api/configs.py @@ -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({ diff --git a/web/services/config_service.py b/web/services/config_service.py index a86999f..dba56d3 100644 --- a/web/services/config_service.py +++ b/web/services/config_service.py @@ -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: diff --git a/web/services/scan_service.py b/web/services/scan_service.py index 4acaa30..f35ead0 100644 --- a/web/services/scan_service.py +++ b/web/services/scan_service.py @@ -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, diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html index c106a62..bb9d559 100644 --- a/web/templates/dashboard.html +++ b/web/templates/dashboard.html @@ -154,26 +154,33 @@
- {% for config in config_files %} {% endfor %} + {% if config_files %}
- {% if config_files %} - Select a scan configuration file - {% else %} - No config files found in /app/configs/ - {% endif %} + Select a scan configuration file
+ {% else %} + + {% endif %}