diff --git a/app/requirements-web.txt b/app/requirements-web.txt index 0e7ce4a..f2625f2 100644 --- a/app/requirements-web.txt +++ b/app/requirements-web.txt @@ -26,6 +26,9 @@ croniter==2.0.1 # Email Support (Phase 4) Flask-Mail==0.9.1 +# Webhook Support (Phase 5) +requests==2.31.0 + # Configuration Management python-dotenv==1.0.0 diff --git a/app/validate_phase1.py b/app/validate_phase1.py deleted file mode 100755 index 8303c7c..0000000 --- a/app/validate_phase1.py +++ /dev/null @@ -1,197 +0,0 @@ -#!/usr/bin/env python3 -""" -Phase 1 validation script. - -Validates that all Phase 1 deliverables are in place and code structure is correct. -Does not require dependencies to be installed. -""" - -import ast -import os -import sys -from pathlib import Path - - -def validate_file_exists(file_path, description): - """Check if a file exists.""" - if Path(file_path).exists(): - print(f"✓ {description}: {file_path}") - return True - else: - print(f"✗ {description} missing: {file_path}") - return False - - -def validate_directory_exists(dir_path, description): - """Check if a directory exists.""" - if Path(dir_path).is_dir(): - print(f"✓ {description}: {dir_path}") - return True - else: - print(f"✗ {description} missing: {dir_path}") - return False - - -def validate_python_syntax(file_path): - """Validate Python file syntax.""" - try: - with open(file_path, 'r') as f: - ast.parse(f.read()) - return True - except SyntaxError as e: - print(f" ✗ Syntax error in {file_path}: {e}") - return False - - -def main(): - """Run all validation checks.""" - print("=" * 70) - print("SneakyScanner Phase 1 Validation") - print("=" * 70) - - all_passed = True - - # Check project structure - print("\n1. Project Structure:") - print("-" * 70) - - structure_checks = [ - ("web/", "Web application directory"), - ("web/api/", "API blueprints directory"), - ("web/templates/", "Jinja2 templates directory"), - ("web/static/", "Static files directory"), - ("web/utils/", "Utility modules directory"), - ("migrations/", "Alembic migrations directory"), - ("migrations/versions/", "Migration versions directory"), - ] - - for path, desc in structure_checks: - if not validate_directory_exists(path, desc): - all_passed = False - - # Check core files - print("\n2. Core Files:") - print("-" * 70) - - core_files = [ - ("requirements-web.txt", "Web dependencies"), - ("alembic.ini", "Alembic configuration"), - ("init_db.py", "Database initialization script"), - ("docker-compose-web.yml", "Docker Compose for web app"), - ] - - for path, desc in core_files: - if not validate_file_exists(path, desc): - all_passed = False - - # Check Python modules - print("\n3. Python Modules:") - print("-" * 70) - - python_modules = [ - ("web/__init__.py", "Web package init"), - ("web/models.py", "SQLAlchemy models"), - ("web/app.py", "Flask application factory"), - ("web/utils/__init__.py", "Utils package init"), - ("web/utils/settings.py", "Settings manager"), - ("web/api/__init__.py", "API package init"), - ("web/api/scans.py", "Scans API blueprint"), - ("web/api/schedules.py", "Schedules API blueprint"), - ("web/api/alerts.py", "Alerts API blueprint"), - ("web/api/settings.py", "Settings API blueprint"), - ("migrations/env.py", "Alembic environment"), - ("migrations/script.py.mako", "Migration template"), - ("migrations/versions/001_initial_schema.py", "Initial migration"), - ] - - for path, desc in python_modules: - exists = validate_file_exists(path, desc) - if exists: - # Skip syntax check for .mako templates (they're not pure Python) - if not path.endswith('.mako'): - if not validate_python_syntax(path): - all_passed = False - else: - print(f" (Skipped syntax check for template file)") - else: - all_passed = False - - # Check models - print("\n4. Database Models (from models.py):") - print("-" * 70) - - try: - # Read models.py and look for class definitions - with open('web/models.py', 'r') as f: - content = f.read() - tree = ast.parse(content) - - models = [] - for node in ast.walk(tree): - if isinstance(node, ast.ClassDef) and node.name != 'Base': - models.append(node.name) - - expected_models = [ - 'Scan', 'ScanSite', 'ScanIP', 'ScanPort', 'ScanService', - 'ScanCertificate', 'ScanTLSVersion', 'Schedule', 'Alert', - 'AlertRule', 'Setting' - ] - - for model in expected_models: - if model in models: - print(f"✓ Model defined: {model}") - else: - print(f"✗ Model missing: {model}") - all_passed = False - - except Exception as e: - print(f"✗ Failed to parse models.py: {e}") - all_passed = False - - # Check API endpoints - print("\n5. API Blueprints:") - print("-" * 70) - - blueprints = { - 'web/api/scans.py': ['list_scans', 'get_scan', 'trigger_scan', 'delete_scan'], - 'web/api/schedules.py': ['list_schedules', 'get_schedule', 'create_schedule'], - 'web/api/alerts.py': ['list_alerts', 'list_alert_rules'], - 'web/api/settings.py': ['get_settings', 'update_settings'], - } - - for blueprint_file, expected_funcs in blueprints.items(): - try: - with open(blueprint_file, 'r') as f: - content = f.read() - tree = ast.parse(content) - - functions = [node.name for node in ast.walk(tree) if isinstance(node, ast.FunctionDef)] - - print(f"\n {blueprint_file}:") - for func in expected_funcs: - if func in functions: - print(f" ✓ Endpoint: {func}") - else: - print(f" ✗ Missing endpoint: {func}") - all_passed = False - except Exception as e: - print(f" ✗ Failed to parse {blueprint_file}: {e}") - all_passed = False - - # Summary - print("\n" + "=" * 70) - if all_passed: - print("✓ All Phase 1 validation checks passed!") - print("\nNext steps:") - print("1. Install dependencies: pip install -r requirements-web.txt") - print("2. Initialize database: python3 init_db.py --password YOUR_PASSWORD") - print("3. Run Flask app: python3 -m web.app") - print("4. Test API: curl http://localhost:5000/api/settings/health") - return 0 - else: - print("✗ Some validation checks failed. Please review errors above.") - return 1 - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/app/web/api/settings.py b/app/web/api/settings.py index fed6857..1aedab0 100644 --- a/app/web/api/settings.py +++ b/app/web/api/settings.py @@ -75,6 +75,12 @@ def update_settings(): 'status': 'success', 'message': f'Updated {len(settings_dict)} settings' }) + except ValueError as e: + # Handle read-only setting attempts + return jsonify({ + 'status': 'error', + 'message': str(e) + }), 403 except Exception as e: current_app.logger.error(f"Failed to update settings: {e}") return jsonify({ @@ -112,7 +118,8 @@ def get_setting(key): return jsonify({ 'status': 'success', 'key': key, - 'value': value + 'value': value, + 'read_only': settings_manager._is_read_only(key) }) except Exception as e: current_app.logger.error(f"Failed to retrieve setting {key}: {e}") @@ -154,6 +161,12 @@ def update_setting(key): 'status': 'success', 'message': f'Setting "{key}" updated' }) + except ValueError as e: + # Handle read-only setting attempts + return jsonify({ + 'status': 'error', + 'message': str(e) + }), 403 except Exception as e: current_app.logger.error(f"Failed to update setting {key}: {e}") return jsonify({ @@ -176,6 +189,14 @@ def delete_setting(key): """ try: settings_manager = get_settings_manager() + + # Prevent deletion of read-only settings + if settings_manager._is_read_only(key): + return jsonify({ + 'status': 'error', + 'message': f'Setting "{key}" is read-only and cannot be deleted' + }), 403 + deleted = settings_manager.delete(key) if not deleted: diff --git a/app/web/app.py b/app/web/app.py index 5b30cc2..6808ff4 100644 --- a/app/web/app.py +++ b/app/web/app.py @@ -95,6 +95,9 @@ def create_app(config: dict = None) -> Flask: # Register error handlers register_error_handlers(app) + # Register context processors + register_context_processors(app) + # Add request/response handlers register_request_handlers(app) @@ -328,11 +331,13 @@ def register_blueprints(app: Flask) -> None: from web.api.scans import bp as scans_bp from web.api.schedules import bp as schedules_bp from web.api.alerts import bp as alerts_bp + from web.api.webhooks import bp as webhooks_api_bp from web.api.settings import bp as settings_bp from web.api.stats import bp as stats_bp from web.api.configs import bp as configs_bp from web.auth.routes import bp as auth_bp from web.routes.main import bp as main_bp + from web.routes.webhooks import bp as webhooks_bp # Register authentication blueprint app.register_blueprint(auth_bp, url_prefix='/auth') @@ -340,10 +345,14 @@ def register_blueprints(app: Flask) -> None: # Register main web routes blueprint app.register_blueprint(main_bp, url_prefix='/') + # Register webhooks web routes blueprint + app.register_blueprint(webhooks_bp, url_prefix='/webhooks') + # Register API blueprints app.register_blueprint(scans_bp, url_prefix='/api/scans') app.register_blueprint(schedules_bp, url_prefix='/api/schedules') app.register_blueprint(alerts_bp, url_prefix='/api/alerts') + app.register_blueprint(webhooks_api_bp, url_prefix='/api/webhooks') app.register_blueprint(settings_bp, url_prefix='/api/settings') app.register_blueprint(stats_bp, url_prefix='/api/stats') app.register_blueprint(configs_bp, url_prefix='/api/configs') @@ -487,6 +496,35 @@ def register_error_handlers(app: Flask) -> None: return render_template('errors/500.html', error=error), 500 +def register_context_processors(app: Flask) -> None: + """ + Register template context processors. + + Makes common variables available to all templates without having to + pass them explicitly in every render_template call. + + Args: + app: Flask application instance + """ + @app.context_processor + def inject_app_settings(): + """ + Inject application metadata into all templates. + + Returns: + Dictionary of variables to add to template context + """ + from web.config import APP_NAME, APP_VERSION, REPO_URL + + return { + 'app_name': APP_NAME, + 'app_version': APP_VERSION, + 'repo_url': REPO_URL + } + + app.logger.info("Context processors registered") + + def register_request_handlers(app: Flask) -> None: """ Register request and response handlers. diff --git a/app/web/services/alert_service.py b/app/web/services/alert_service.py index 982decc..5d9c941 100644 --- a/app/web/services/alert_service.py +++ b/app/web/services/alert_service.py @@ -282,7 +282,8 @@ class AlertService: # Get all certificates from the scan certificates = ( self.db.query(ScanCertificate, ScanPort, ScanIP) - .join(ScanPort, ScanCertificate.port_id == ScanPort.id) + .join(ScanServiceModel, ScanCertificate.service_id == ScanServiceModel.id) + .join(ScanPort, ScanServiceModel.port_id == ScanPort.id) .join(ScanIP, ScanPort.ip_id == ScanIP.id) .filter(ScanPort.scan_id == scan.id) .all() @@ -329,29 +330,34 @@ class AlertService: # Get all TLS version data from the scan tls_versions = ( self.db.query(ScanTLSVersion, ScanPort, ScanIP) - .join(ScanPort, ScanTLSVersion.port_id == ScanPort.id) + .join(ScanCertificate, ScanTLSVersion.certificate_id == ScanCertificate.id) + .join(ScanServiceModel, ScanCertificate.service_id == ScanServiceModel.id) + .join(ScanPort, ScanServiceModel.port_id == ScanPort.id) .join(ScanIP, ScanPort.ip_id == ScanIP.id) .filter(ScanPort.scan_id == scan.id) .all() ) + # Group TLS versions by port/IP to create one alert per host + tls_by_host = {} for tls, port, ip in tls_versions: - weak_versions = [] + # Only alert on weak TLS versions that are supported + if tls.supported and tls.tls_version in ['TLS 1.0', 'TLS 1.1']: + key = (ip.ip_address, port.port) + if key not in tls_by_host: + tls_by_host[key] = {'ip': ip.ip_address, 'port': port.port, 'versions': []} + tls_by_host[key]['versions'].append(tls.tls_version) - if tls.tls_1_0: - weak_versions.append("TLS 1.0") - if tls.tls_1_1: - weak_versions.append("TLS 1.1") - - if weak_versions: - severity = rule.severity or 'warning' - alerts_to_create.append({ - 'alert_type': 'weak_tls', - 'severity': severity, - 'message': f"Weak TLS versions supported on {ip.ip_address}:{port.port}: {', '.join(weak_versions)}", - 'ip_address': ip.ip_address, - 'port': port.port - }) + # Create alerts for hosts with weak TLS + for host_key, host_data in tls_by_host.items(): + severity = rule.severity or 'warning' + alerts_to_create.append({ + 'alert_type': 'weak_tls', + 'severity': severity, + 'message': f"Weak TLS versions supported on {host_data['ip']}:{host_data['port']}: {', '.join(host_data['versions'])}", + 'ip_address': host_data['ip'], + 'port': host_data['port'] + }) return alerts_to_create @@ -437,10 +443,35 @@ class AlertService: logger.info(f"Email notification would be sent for alert {alert.id}") # TODO: Call email service - # Webhook notification will be implemented in webhook_service.py + # Webhook notification - queue for delivery if rule.webhook_enabled: - logger.info(f"Webhook notification would be sent for alert {alert.id}") - # TODO: Call webhook service + try: + from flask import current_app + from .webhook_service import WebhookService + + webhook_service = WebhookService(self.db) + + # Get matching webhooks for this alert + matching_webhooks = webhook_service.get_matching_webhooks(alert) + + if matching_webhooks: + # Get scheduler from app context + scheduler = getattr(current_app, 'scheduler', None) + + # Queue delivery for each matching webhook + for webhook in matching_webhooks: + webhook_service.queue_webhook_delivery( + webhook.id, + alert.id, + scheduler_service=scheduler + ) + logger.info(f"Queued webhook {webhook.id} ({webhook.name}) for alert {alert.id}") + else: + logger.debug(f"No matching webhooks found for alert {alert.id}") + + except Exception as e: + logger.error(f"Failed to queue webhook notifications for alert {alert.id}: {e}", exc_info=True) + # Don't fail alert creation if webhook queueing fails def acknowledge_alert(self, alert_id: int, acknowledged_by: str = "system") -> bool: """ diff --git a/app/web/templates/base.html b/app/web/templates/base.html index e817d33..307d0e1 100644 --- a/app/web/templates/base.html +++ b/app/web/templates/base.html @@ -3,7 +3,7 @@ - {% block title %}SneakyScanner{% endblock %} + {% block title %}{{ app_name }}{% endblock %} @@ -34,7 +34,7 @@