added webhooks, moved app name and verison to simple config file

This commit is contained in:
2025-11-18 15:05:39 -06:00
parent 3c740268c4
commit 1d076a467a
8 changed files with 705 additions and 234 deletions

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}SneakyScanner{% endblock %}</title>
<title>{% block title %}{{ app_name }}{% endblock %}</title>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
@@ -34,7 +34,7 @@
<nav class="navbar navbar-expand-lg navbar-custom">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('main.dashboard') }}">
SneakyScanner
{{ app_name }}
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
@@ -58,13 +58,15 @@
href="{{ url_for('main.configs') }}">Configs</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% if request.endpoint and 'alert' in request.endpoint %}active{% endif %}"
<a class="nav-link dropdown-toggle {% if request.endpoint and ('alert' in request.endpoint or 'webhook' in request.endpoint) %}active{% endif %}"
href="#" id="alertsDropdown" role="button" data-bs-toggle="dropdown">
Alerts
</a>
<ul class="dropdown-menu" aria-labelledby="alertsDropdown">
<li><a class="dropdown-item" href="{{ url_for('main.alerts') }}">Alert History</a></li>
<li><a class="dropdown-item" href="{{ url_for('main.alert_rules') }}">Alert Rules</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('webhooks.list_webhooks') }}">Webhooks</a></li>
</ul>
</li>
</ul>
@@ -95,7 +97,7 @@
<div class="footer">
<div class="container-fluid">
SneakyScanner v1.0 - Phase 3 In Progress
<a href="{{ repo_url }}" target="_blank">{{ app_name }}</a> - v{{ app_version }}
</div>
</div>

View File

@@ -7,8 +7,8 @@ for sensitive values like passwords and API tokens.
import json
import os
from datetime import datetime
from typing import Any, Dict, List, Optional
from datetime import datetime, timezone
from typing import Any, Dict, Optional
import bcrypt
from cryptography.fernet import Fernet
@@ -32,6 +32,11 @@ class SettingsManager:
'encryption_key',
}
# Keys that are read-only (managed by developer, not user-editable)
READ_ONLY_KEYS = {
'encryption_key',
}
def __init__(self, db_session: Session, encryption_key: Optional[bytes] = None):
"""
Initialize the settings manager.
@@ -69,11 +74,11 @@ class SettingsManager:
return new_key
def _store_raw(self, key: str, value: str) -> None:
"""Store a setting without encryption (internal use only)."""
"""Store a setting without encryption (internal use only). Bypasses read-only check."""
setting = self.db.query(Setting).filter_by(key=key).first()
if setting:
setting.value = value
setting.updated_at = datetime.utcnow()
setting.updated_at = datetime.now(timezone.utc)
else:
setting = Setting(key=key, value=value)
self.db.add(setting)
@@ -128,7 +133,11 @@ class SettingsManager:
return value
def set(self, key: str, value: Any, encrypt: bool = None) -> None:
def _is_read_only(self, key: str) -> bool:
"""Check if a setting key is read-only."""
return key in self.READ_ONLY_KEYS
def set(self, key: str, value: Any, encrypt: bool = None, allow_read_only: bool = False) -> None:
"""
Set a setting value.
@@ -136,7 +145,15 @@ class SettingsManager:
key: Setting key
value: Setting value (will be JSON-encoded if dict/list)
encrypt: Force encryption on/off (None = auto-detect from ENCRYPTED_KEYS)
allow_read_only: If True, allows setting read-only keys (internal use only)
Raises:
ValueError: If attempting to set a read-only key without allow_read_only=True
"""
# Prevent modification of read-only keys unless explicitly allowed
if not allow_read_only and self._is_read_only(key):
raise ValueError(f"Setting '{key}' is read-only and cannot be modified via API")
# Convert complex types to JSON
if isinstance(value, (dict, list)):
value_str = json.dumps(value)
@@ -153,7 +170,7 @@ class SettingsManager:
setting = self.db.query(Setting).filter_by(key=key).first()
if setting:
setting.value = value_str
setting.updated_at = datetime.utcnow()
setting.updated_at = datetime.now(timezone.utc)
else:
setting = Setting(key=key, value=value_str)
self.db.add(setting)
@@ -251,7 +268,8 @@ class SettingsManager:
for key, value in defaults.items():
# Only set if doesn't exist
if self.db.query(Setting).filter_by(key=key).first() is None:
self.set(key, value)
# Use allow_read_only=True for initializing defaults
self.set(key, value, allow_read_only=True)
class PasswordManager: