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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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