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 @@