added webhooks, moved app name and verison to simple config file
This commit is contained in:
@@ -26,6 +26,9 @@ croniter==2.0.1
|
|||||||
# Email Support (Phase 4)
|
# Email Support (Phase 4)
|
||||||
Flask-Mail==0.9.1
|
Flask-Mail==0.9.1
|
||||||
|
|
||||||
|
# Webhook Support (Phase 5)
|
||||||
|
requests==2.31.0
|
||||||
|
|
||||||
# Configuration Management
|
# Configuration Management
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
|
||||||
@@ -75,6 +75,12 @@ def update_settings():
|
|||||||
'status': 'success',
|
'status': 'success',
|
||||||
'message': f'Updated {len(settings_dict)} settings'
|
'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:
|
except Exception as e:
|
||||||
current_app.logger.error(f"Failed to update settings: {e}")
|
current_app.logger.error(f"Failed to update settings: {e}")
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -112,7 +118,8 @@ def get_setting(key):
|
|||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'key': key,
|
'key': key,
|
||||||
'value': value
|
'value': value,
|
||||||
|
'read_only': settings_manager._is_read_only(key)
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
current_app.logger.error(f"Failed to retrieve setting {key}: {e}")
|
current_app.logger.error(f"Failed to retrieve setting {key}: {e}")
|
||||||
@@ -154,6 +161,12 @@ def update_setting(key):
|
|||||||
'status': 'success',
|
'status': 'success',
|
||||||
'message': f'Setting "{key}" updated'
|
'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:
|
except Exception as e:
|
||||||
current_app.logger.error(f"Failed to update setting {key}: {e}")
|
current_app.logger.error(f"Failed to update setting {key}: {e}")
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -176,6 +189,14 @@ def delete_setting(key):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
settings_manager = get_settings_manager()
|
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)
|
deleted = settings_manager.delete(key)
|
||||||
|
|
||||||
if not deleted:
|
if not deleted:
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ def create_app(config: dict = None) -> Flask:
|
|||||||
# Register error handlers
|
# Register error handlers
|
||||||
register_error_handlers(app)
|
register_error_handlers(app)
|
||||||
|
|
||||||
|
# Register context processors
|
||||||
|
register_context_processors(app)
|
||||||
|
|
||||||
# Add request/response handlers
|
# Add request/response handlers
|
||||||
register_request_handlers(app)
|
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.scans import bp as scans_bp
|
||||||
from web.api.schedules import bp as schedules_bp
|
from web.api.schedules import bp as schedules_bp
|
||||||
from web.api.alerts import bp as alerts_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.settings import bp as settings_bp
|
||||||
from web.api.stats import bp as stats_bp
|
from web.api.stats import bp as stats_bp
|
||||||
from web.api.configs import bp as configs_bp
|
from web.api.configs import bp as configs_bp
|
||||||
from web.auth.routes import bp as auth_bp
|
from web.auth.routes import bp as auth_bp
|
||||||
from web.routes.main import bp as main_bp
|
from web.routes.main import bp as main_bp
|
||||||
|
from web.routes.webhooks import bp as webhooks_bp
|
||||||
|
|
||||||
# Register authentication blueprint
|
# Register authentication blueprint
|
||||||
app.register_blueprint(auth_bp, url_prefix='/auth')
|
app.register_blueprint(auth_bp, url_prefix='/auth')
|
||||||
@@ -340,10 +345,14 @@ def register_blueprints(app: Flask) -> None:
|
|||||||
# Register main web routes blueprint
|
# Register main web routes blueprint
|
||||||
app.register_blueprint(main_bp, url_prefix='/')
|
app.register_blueprint(main_bp, url_prefix='/')
|
||||||
|
|
||||||
|
# Register webhooks web routes blueprint
|
||||||
|
app.register_blueprint(webhooks_bp, url_prefix='/webhooks')
|
||||||
|
|
||||||
# Register API blueprints
|
# Register API blueprints
|
||||||
app.register_blueprint(scans_bp, url_prefix='/api/scans')
|
app.register_blueprint(scans_bp, url_prefix='/api/scans')
|
||||||
app.register_blueprint(schedules_bp, url_prefix='/api/schedules')
|
app.register_blueprint(schedules_bp, url_prefix='/api/schedules')
|
||||||
app.register_blueprint(alerts_bp, url_prefix='/api/alerts')
|
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(settings_bp, url_prefix='/api/settings')
|
||||||
app.register_blueprint(stats_bp, url_prefix='/api/stats')
|
app.register_blueprint(stats_bp, url_prefix='/api/stats')
|
||||||
app.register_blueprint(configs_bp, url_prefix='/api/configs')
|
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
|
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:
|
def register_request_handlers(app: Flask) -> None:
|
||||||
"""
|
"""
|
||||||
Register request and response handlers.
|
Register request and response handlers.
|
||||||
|
|||||||
@@ -282,7 +282,8 @@ class AlertService:
|
|||||||
# Get all certificates from the scan
|
# Get all certificates from the scan
|
||||||
certificates = (
|
certificates = (
|
||||||
self.db.query(ScanCertificate, ScanPort, ScanIP)
|
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)
|
.join(ScanIP, ScanPort.ip_id == ScanIP.id)
|
||||||
.filter(ScanPort.scan_id == scan.id)
|
.filter(ScanPort.scan_id == scan.id)
|
||||||
.all()
|
.all()
|
||||||
@@ -329,29 +330,34 @@ class AlertService:
|
|||||||
# Get all TLS version data from the scan
|
# Get all TLS version data from the scan
|
||||||
tls_versions = (
|
tls_versions = (
|
||||||
self.db.query(ScanTLSVersion, ScanPort, ScanIP)
|
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)
|
.join(ScanIP, ScanPort.ip_id == ScanIP.id)
|
||||||
.filter(ScanPort.scan_id == scan.id)
|
.filter(ScanPort.scan_id == scan.id)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Group TLS versions by port/IP to create one alert per host
|
||||||
|
tls_by_host = {}
|
||||||
for tls, port, ip in tls_versions:
|
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:
|
# Create alerts for hosts with weak TLS
|
||||||
weak_versions.append("TLS 1.0")
|
for host_key, host_data in tls_by_host.items():
|
||||||
if tls.tls_1_1:
|
severity = rule.severity or 'warning'
|
||||||
weak_versions.append("TLS 1.1")
|
alerts_to_create.append({
|
||||||
|
'alert_type': 'weak_tls',
|
||||||
if weak_versions:
|
'severity': severity,
|
||||||
severity = rule.severity or 'warning'
|
'message': f"Weak TLS versions supported on {host_data['ip']}:{host_data['port']}: {', '.join(host_data['versions'])}",
|
||||||
alerts_to_create.append({
|
'ip_address': host_data['ip'],
|
||||||
'alert_type': 'weak_tls',
|
'port': host_data['port']
|
||||||
'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
|
|
||||||
})
|
|
||||||
|
|
||||||
return alerts_to_create
|
return alerts_to_create
|
||||||
|
|
||||||
@@ -437,10 +443,35 @@ class AlertService:
|
|||||||
logger.info(f"Email notification would be sent for alert {alert.id}")
|
logger.info(f"Email notification would be sent for alert {alert.id}")
|
||||||
# TODO: Call email service
|
# TODO: Call email service
|
||||||
|
|
||||||
# Webhook notification will be implemented in webhook_service.py
|
# Webhook notification - queue for delivery
|
||||||
if rule.webhook_enabled:
|
if rule.webhook_enabled:
|
||||||
logger.info(f"Webhook notification would be sent for alert {alert.id}")
|
try:
|
||||||
# TODO: Call webhook service
|
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:
|
def acknowledge_alert(self, alert_id: int, acknowledged_by: str = "system") -> bool:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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 -->
|
<!-- Bootstrap 5 CSS -->
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
<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">
|
<nav class="navbar navbar-expand-lg navbar-custom">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<a class="navbar-brand" href="{{ url_for('main.dashboard') }}">
|
<a class="navbar-brand" href="{{ url_for('main.dashboard') }}">
|
||||||
SneakyScanner
|
{{ app_name }}
|
||||||
</a>
|
</a>
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
@@ -58,13 +58,15 @@
|
|||||||
href="{{ url_for('main.configs') }}">Configs</a>
|
href="{{ url_for('main.configs') }}">Configs</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item dropdown">
|
<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">
|
href="#" id="alertsDropdown" role="button" data-bs-toggle="dropdown">
|
||||||
Alerts
|
Alerts
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu" aria-labelledby="alertsDropdown">
|
<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.alerts') }}">Alert History</a></li>
|
||||||
<li><a class="dropdown-item" href="{{ url_for('main.alert_rules') }}">Alert Rules</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>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -95,7 +97,7 @@
|
|||||||
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<div class="container-fluid">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ for sensitive values like passwords and API tokens.
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
@@ -32,6 +32,11 @@ class SettingsManager:
|
|||||||
'encryption_key',
|
'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):
|
def __init__(self, db_session: Session, encryption_key: Optional[bytes] = None):
|
||||||
"""
|
"""
|
||||||
Initialize the settings manager.
|
Initialize the settings manager.
|
||||||
@@ -69,11 +74,11 @@ class SettingsManager:
|
|||||||
return new_key
|
return new_key
|
||||||
|
|
||||||
def _store_raw(self, key: str, value: str) -> None:
|
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()
|
setting = self.db.query(Setting).filter_by(key=key).first()
|
||||||
if setting:
|
if setting:
|
||||||
setting.value = value
|
setting.value = value
|
||||||
setting.updated_at = datetime.utcnow()
|
setting.updated_at = datetime.now(timezone.utc)
|
||||||
else:
|
else:
|
||||||
setting = Setting(key=key, value=value)
|
setting = Setting(key=key, value=value)
|
||||||
self.db.add(setting)
|
self.db.add(setting)
|
||||||
@@ -128,7 +133,11 @@ class SettingsManager:
|
|||||||
|
|
||||||
return value
|
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.
|
Set a setting value.
|
||||||
|
|
||||||
@@ -136,7 +145,15 @@ class SettingsManager:
|
|||||||
key: Setting key
|
key: Setting key
|
||||||
value: Setting value (will be JSON-encoded if dict/list)
|
value: Setting value (will be JSON-encoded if dict/list)
|
||||||
encrypt: Force encryption on/off (None = auto-detect from ENCRYPTED_KEYS)
|
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
|
# Convert complex types to JSON
|
||||||
if isinstance(value, (dict, list)):
|
if isinstance(value, (dict, list)):
|
||||||
value_str = json.dumps(value)
|
value_str = json.dumps(value)
|
||||||
@@ -153,7 +170,7 @@ class SettingsManager:
|
|||||||
setting = self.db.query(Setting).filter_by(key=key).first()
|
setting = self.db.query(Setting).filter_by(key=key).first()
|
||||||
if setting:
|
if setting:
|
||||||
setting.value = value_str
|
setting.value = value_str
|
||||||
setting.updated_at = datetime.utcnow()
|
setting.updated_at = datetime.now(timezone.utc)
|
||||||
else:
|
else:
|
||||||
setting = Setting(key=key, value=value_str)
|
setting = Setting(key=key, value=value_str)
|
||||||
self.db.add(setting)
|
self.db.add(setting)
|
||||||
@@ -251,7 +268,8 @@ class SettingsManager:
|
|||||||
for key, value in defaults.items():
|
for key, value in defaults.items():
|
||||||
# Only set if doesn't exist
|
# Only set if doesn't exist
|
||||||
if self.db.query(Setting).filter_by(key=key).first() is None:
|
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:
|
class PasswordManager:
|
||||||
|
|||||||
@@ -13,9 +13,10 @@
|
|||||||
5. [Stats API](#stats-api)
|
5. [Stats API](#stats-api)
|
||||||
6. [Settings API](#settings-api)
|
6. [Settings API](#settings-api)
|
||||||
7. [Alerts API](#alerts-api)
|
7. [Alerts API](#alerts-api)
|
||||||
8. [Error Handling](#error-handling)
|
8. [Webhooks API](#webhooks-api)
|
||||||
9. [Status Codes](#status-codes)
|
9. [Error Handling](#error-handling)
|
||||||
10. [Request/Response Examples](#request-response-examples)
|
10. [Status Codes](#status-codes)
|
||||||
|
11. [Request/Response Examples](#request-response-examples)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2045,6 +2046,559 @@ curl -X GET http://localhost:5000/api/alerts/health
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Webhooks API
|
||||||
|
|
||||||
|
Manage webhook configurations for alert notifications with support for various authentication methods and delivery tracking.
|
||||||
|
|
||||||
|
### List Webhooks
|
||||||
|
|
||||||
|
List all configured webhooks with pagination.
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/webhooks`
|
||||||
|
|
||||||
|
**Authentication:** Required
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Default | Description |
|
||||||
|
|-----------|------|----------|---------|-------------|
|
||||||
|
| `page` | integer | No | 1 | Page number (1-indexed) |
|
||||||
|
| `per_page` | integer | No | 20 | Items per page (1-100) |
|
||||||
|
| `enabled` | boolean | No | - | Filter by enabled status (true/false) |
|
||||||
|
|
||||||
|
**Success Response (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"webhooks": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Slack Notifications",
|
||||||
|
"url": "https://hooks.slack.com/services/XXX/YYY/ZZZ",
|
||||||
|
"enabled": true,
|
||||||
|
"auth_type": "none",
|
||||||
|
"auth_token": null,
|
||||||
|
"custom_headers": null,
|
||||||
|
"alert_types": ["cert_expiry", "drift_detection"],
|
||||||
|
"severity_filter": ["critical", "warning"],
|
||||||
|
"timeout": 10,
|
||||||
|
"retry_count": 3,
|
||||||
|
"created_at": "2025-11-18T10:00:00Z",
|
||||||
|
"updated_at": "2025-11-18T10:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1,
|
||||||
|
"page": 1,
|
||||||
|
"per_page": 20,
|
||||||
|
"pages": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage Examples:**
|
||||||
|
```bash
|
||||||
|
# List all webhooks
|
||||||
|
curl -X GET http://localhost:5000/api/webhooks \
|
||||||
|
-b cookies.txt
|
||||||
|
|
||||||
|
# List only enabled webhooks
|
||||||
|
curl -X GET "http://localhost:5000/api/webhooks?enabled=true" \
|
||||||
|
-b cookies.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Webhook
|
||||||
|
|
||||||
|
Get details for a specific webhook.
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/webhooks/{id}`
|
||||||
|
|
||||||
|
**Authentication:** Required
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|------|----------|-------------|
|
||||||
|
| `id` | integer | Yes | Webhook ID |
|
||||||
|
|
||||||
|
**Success Response (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"webhook": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "Slack Notifications",
|
||||||
|
"url": "https://hooks.slack.com/services/XXX/YYY/ZZZ",
|
||||||
|
"enabled": true,
|
||||||
|
"auth_type": "bearer",
|
||||||
|
"auth_token": "***ENCRYPTED***",
|
||||||
|
"custom_headers": null,
|
||||||
|
"alert_types": ["cert_expiry"],
|
||||||
|
"severity_filter": ["critical"],
|
||||||
|
"timeout": 10,
|
||||||
|
"retry_count": 3,
|
||||||
|
"created_at": "2025-11-18T10:00:00Z",
|
||||||
|
"updated_at": "2025-11-18T10:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Responses:**
|
||||||
|
|
||||||
|
*404 Not Found* - Webhook doesn't exist:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "Webhook 1 not found"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage Example:**
|
||||||
|
```bash
|
||||||
|
curl -X GET http://localhost:5000/api/webhooks/1 \
|
||||||
|
-b cookies.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create Webhook
|
||||||
|
|
||||||
|
Create a new webhook configuration.
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/webhooks`
|
||||||
|
|
||||||
|
**Authentication:** Required
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Slack Notifications",
|
||||||
|
"url": "https://hooks.slack.com/services/XXX/YYY/ZZZ",
|
||||||
|
"enabled": true,
|
||||||
|
"auth_type": "bearer",
|
||||||
|
"auth_token": "your-secret-token",
|
||||||
|
"custom_headers": {
|
||||||
|
"X-Custom-Header": "value"
|
||||||
|
},
|
||||||
|
"alert_types": ["cert_expiry", "drift_detection"],
|
||||||
|
"severity_filter": ["critical", "warning"],
|
||||||
|
"timeout": 10,
|
||||||
|
"retry_count": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body Fields:**
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `name` | string | Yes | Webhook name |
|
||||||
|
| `url` | string | Yes | Webhook URL |
|
||||||
|
| `enabled` | boolean | No | Whether webhook is enabled (default: true) |
|
||||||
|
| `auth_type` | string | No | Authentication type: none, bearer, basic, custom (default: none) |
|
||||||
|
| `auth_token` | string | No | Authentication token (encrypted when stored) |
|
||||||
|
| `custom_headers` | object | No | JSON object with custom HTTP headers |
|
||||||
|
| `alert_types` | array | No | Array of alert types to filter (empty = all types) |
|
||||||
|
| `severity_filter` | array | No | Array of severities to filter (empty = all severities) |
|
||||||
|
| `timeout` | integer | No | Request timeout in seconds (default: 10) |
|
||||||
|
| `retry_count` | integer | No | Number of retry attempts (default: 3) |
|
||||||
|
|
||||||
|
**Success Response (201 Created):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Webhook created successfully",
|
||||||
|
"webhook": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "Slack Notifications",
|
||||||
|
"url": "https://hooks.slack.com/services/XXX/YYY/ZZZ",
|
||||||
|
"enabled": true,
|
||||||
|
"auth_type": "bearer",
|
||||||
|
"alert_types": ["cert_expiry"],
|
||||||
|
"severity_filter": ["critical"],
|
||||||
|
"custom_headers": null,
|
||||||
|
"timeout": 10,
|
||||||
|
"retry_count": 3,
|
||||||
|
"created_at": "2025-11-18T10:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Responses:**
|
||||||
|
|
||||||
|
*400 Bad Request* - Missing required fields:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "name is required"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
*400 Bad Request* - Invalid auth type:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "Invalid auth_type. Must be one of: none, bearer, basic, custom"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage Examples:**
|
||||||
|
```bash
|
||||||
|
# Create webhook with no authentication
|
||||||
|
curl -X POST http://localhost:5000/api/webhooks \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "Slack Notifications",
|
||||||
|
"url": "https://hooks.slack.com/services/XXX/YYY/ZZZ",
|
||||||
|
"alert_types": ["cert_expiry", "drift_detection"],
|
||||||
|
"severity_filter": ["critical"]
|
||||||
|
}' \
|
||||||
|
-b cookies.txt
|
||||||
|
|
||||||
|
# Create webhook with bearer token authentication
|
||||||
|
curl -X POST http://localhost:5000/api/webhooks \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "Custom API",
|
||||||
|
"url": "https://api.example.com/webhook",
|
||||||
|
"auth_type": "bearer",
|
||||||
|
"auth_token": "your-secret-token"
|
||||||
|
}' \
|
||||||
|
-b cookies.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Webhook
|
||||||
|
|
||||||
|
Update an existing webhook configuration.
|
||||||
|
|
||||||
|
**Endpoint:** `PUT /api/webhooks/{id}`
|
||||||
|
|
||||||
|
**Authentication:** Required
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|------|----------|-------------|
|
||||||
|
| `id` | integer | Yes | Webhook ID |
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Updated Name",
|
||||||
|
"enabled": false,
|
||||||
|
"timeout": 15
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** All fields are optional. Only provided fields will be updated.
|
||||||
|
|
||||||
|
**Success Response (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Webhook updated successfully",
|
||||||
|
"webhook": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "Updated Name",
|
||||||
|
"url": "https://hooks.slack.com/services/XXX/YYY/ZZZ",
|
||||||
|
"enabled": false,
|
||||||
|
"auth_type": "none",
|
||||||
|
"alert_types": ["cert_expiry"],
|
||||||
|
"severity_filter": ["critical"],
|
||||||
|
"custom_headers": null,
|
||||||
|
"timeout": 15,
|
||||||
|
"retry_count": 3,
|
||||||
|
"updated_at": "2025-11-18T11:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Responses:**
|
||||||
|
|
||||||
|
*404 Not Found* - Webhook doesn't exist:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "Webhook 1 not found"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage Example:**
|
||||||
|
```bash
|
||||||
|
# Disable a webhook
|
||||||
|
curl -X PUT http://localhost:5000/api/webhooks/1 \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"enabled":false}' \
|
||||||
|
-b cookies.txt
|
||||||
|
|
||||||
|
# Update timeout and retry count
|
||||||
|
curl -X PUT http://localhost:5000/api/webhooks/1 \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"timeout":20,"retry_count":5}' \
|
||||||
|
-b cookies.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete Webhook
|
||||||
|
|
||||||
|
Delete a webhook and all associated delivery logs.
|
||||||
|
|
||||||
|
**Endpoint:** `DELETE /api/webhooks/{id}`
|
||||||
|
|
||||||
|
**Authentication:** Required
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|------|----------|-------------|
|
||||||
|
| `id` | integer | Yes | Webhook ID |
|
||||||
|
|
||||||
|
**Success Response (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Webhook 1 deleted successfully"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Responses:**
|
||||||
|
|
||||||
|
*404 Not Found* - Webhook doesn't exist:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "Webhook 1 not found"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage Example:**
|
||||||
|
```bash
|
||||||
|
curl -X DELETE http://localhost:5000/api/webhooks/1 \
|
||||||
|
-b cookies.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Webhook
|
||||||
|
|
||||||
|
Send a test payload to a webhook to verify configuration.
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/webhooks/{id}/test`
|
||||||
|
|
||||||
|
**Authentication:** Required
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|------|----------|-------------|
|
||||||
|
| `id` | integer | Yes | Webhook ID to test |
|
||||||
|
|
||||||
|
**Success Response (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "HTTP 200",
|
||||||
|
"status_code": 200,
|
||||||
|
"response_body": "ok"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Response (failure to connect):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "Connection error: Failed to resolve hostname",
|
||||||
|
"status_code": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test Payload Format:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "webhook.test",
|
||||||
|
"message": "This is a test webhook from SneakyScanner",
|
||||||
|
"timestamp": "2025-11-18T10:00:00Z",
|
||||||
|
"webhook": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "Slack Notifications"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage Example:**
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:5000/api/webhooks/1/test \
|
||||||
|
-b cookies.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Webhook Delivery Logs
|
||||||
|
|
||||||
|
Get delivery history for a specific webhook.
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/webhooks/{id}/logs`
|
||||||
|
|
||||||
|
**Authentication:** Required
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|------|----------|-------------|
|
||||||
|
| `id` | integer | Yes | Webhook ID |
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Default | Description |
|
||||||
|
|-----------|------|----------|---------|-------------|
|
||||||
|
| `page` | integer | No | 1 | Page number (1-indexed) |
|
||||||
|
| `per_page` | integer | No | 20 | Items per page (1-100) |
|
||||||
|
| `status` | string | No | - | Filter by status (success/failed) |
|
||||||
|
|
||||||
|
**Success Response (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"webhook_id": 1,
|
||||||
|
"webhook_name": "Slack Notifications",
|
||||||
|
"logs": [
|
||||||
|
{
|
||||||
|
"id": 101,
|
||||||
|
"alert_id": 42,
|
||||||
|
"alert_type": "cert_expiry",
|
||||||
|
"alert_message": "Certificate expires in 15 days",
|
||||||
|
"status": "success",
|
||||||
|
"response_code": 200,
|
||||||
|
"response_body": "ok",
|
||||||
|
"error_message": null,
|
||||||
|
"attempt_number": 1,
|
||||||
|
"delivered_at": "2025-11-18T10:30:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 100,
|
||||||
|
"alert_id": 41,
|
||||||
|
"alert_type": "drift_detection",
|
||||||
|
"alert_message": "Port drift detected",
|
||||||
|
"status": "failed",
|
||||||
|
"response_code": null,
|
||||||
|
"response_body": null,
|
||||||
|
"error_message": "Request timeout after 10 seconds",
|
||||||
|
"attempt_number": 3,
|
||||||
|
"delivered_at": "2025-11-18T09:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 2,
|
||||||
|
"page": 1,
|
||||||
|
"per_page": 20,
|
||||||
|
"pages": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Responses:**
|
||||||
|
|
||||||
|
*404 Not Found* - Webhook doesn't exist:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "Webhook 1 not found"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage Examples:**
|
||||||
|
```bash
|
||||||
|
# Get all delivery logs
|
||||||
|
curl -X GET http://localhost:5000/api/webhooks/1/logs \
|
||||||
|
-b cookies.txt
|
||||||
|
|
||||||
|
# Get only failed deliveries
|
||||||
|
curl -X GET "http://localhost:5000/api/webhooks/1/logs?status=failed" \
|
||||||
|
-b cookies.txt
|
||||||
|
|
||||||
|
# Get page 2
|
||||||
|
curl -X GET "http://localhost:5000/api/webhooks/1/logs?page=2" \
|
||||||
|
-b cookies.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Webhook Payload Format
|
||||||
|
|
||||||
|
When alerts are triggered, webhooks receive JSON payloads in this format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "alert.created",
|
||||||
|
"alert": {
|
||||||
|
"id": 123,
|
||||||
|
"type": "cert_expiry",
|
||||||
|
"severity": "warning",
|
||||||
|
"message": "Certificate for 192.168.1.10:443 expires in 15 days",
|
||||||
|
"ip_address": "192.168.1.10",
|
||||||
|
"port": 443,
|
||||||
|
"acknowledged": false,
|
||||||
|
"created_at": "2025-11-18T10:30:00Z"
|
||||||
|
},
|
||||||
|
"scan": {
|
||||||
|
"id": 42,
|
||||||
|
"title": "Production Network Scan",
|
||||||
|
"timestamp": "2025-11-18T10:00:00Z",
|
||||||
|
"status": "completed"
|
||||||
|
},
|
||||||
|
"rule": {
|
||||||
|
"id": 3,
|
||||||
|
"name": "Certificate Expiry Warning",
|
||||||
|
"type": "cert_expiry",
|
||||||
|
"threshold": 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication Types
|
||||||
|
|
||||||
|
**None:**
|
||||||
|
- No authentication headers added
|
||||||
|
|
||||||
|
**Bearer Token:**
|
||||||
|
- Adds `Authorization: Bearer <token>` header
|
||||||
|
- Token is encrypted in database
|
||||||
|
|
||||||
|
**Basic Authentication:**
|
||||||
|
- Format: `username:password` in auth_token field
|
||||||
|
- Automatically converts to HTTP Basic Auth
|
||||||
|
- Credentials encrypted in database
|
||||||
|
|
||||||
|
**Custom Headers:**
|
||||||
|
- Define any custom HTTP headers
|
||||||
|
- Useful for API keys or custom authentication schemes
|
||||||
|
- Example:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"X-API-Key": "your-api-key",
|
||||||
|
"X-Custom-Header": "value"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Retry Logic
|
||||||
|
|
||||||
|
Failed webhook deliveries are automatically retried with exponential backoff:
|
||||||
|
|
||||||
|
- **Attempt 1:** Immediate
|
||||||
|
- **Attempt 2:** After 2 seconds
|
||||||
|
- **Attempt 3:** After 4 seconds
|
||||||
|
- **Attempt 4:** After 8 seconds
|
||||||
|
- **Maximum delay:** 60 seconds
|
||||||
|
|
||||||
|
Retry count is configurable per webhook (0-5 attempts).
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
|
||||||
|
Check API health status.
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/webhooks/health`
|
||||||
|
|
||||||
|
**Authentication:** Not required
|
||||||
|
|
||||||
|
**Success Response (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "healthy",
|
||||||
|
"api": "webhooks",
|
||||||
|
"version": "1.0.0-phase5"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage Example:**
|
||||||
|
```bash
|
||||||
|
curl -X GET http://localhost:5000/api/webhooks/health
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|
||||||
### Error Response Format
|
### Error Response Format
|
||||||
@@ -2283,10 +2837,11 @@ API versioning will be implemented in future phases. For now, the API is conside
|
|||||||
- **Stats API** - Trends, summaries, historical data
|
- **Stats API** - Trends, summaries, historical data
|
||||||
- **Settings API** - Application configuration, password management
|
- **Settings API** - Application configuration, password management
|
||||||
- **Alerts API** - Full implementation with filtering, acknowledgment, rules management, and statistics
|
- **Alerts API** - Full implementation with filtering, acknowledgment, rules management, and statistics
|
||||||
|
- **Webhooks API** - Webhook management, delivery tracking, authentication support, retry logic
|
||||||
|
|
||||||
### Endpoint Count
|
### Endpoint Count
|
||||||
- Total endpoints: 55+
|
- Total endpoints: 65+
|
||||||
- Authenticated endpoints: 50+
|
- Authenticated endpoints: 60+
|
||||||
- Public endpoints: 5 (login, setup, health checks)
|
- Public endpoints: 5 (login, setup, health checks)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user