diff --git a/app/web/api/webhooks.py b/app/web/api/webhooks.py new file mode 100644 index 0000000..23984eb --- /dev/null +++ b/app/web/api/webhooks.py @@ -0,0 +1,500 @@ +""" +Webhooks API blueprint. + +Handles endpoints for managing webhook configurations and viewing delivery logs. +""" + +import json +from datetime import datetime, timezone +from flask import Blueprint, jsonify, request, current_app + +from web.auth.decorators import api_auth_required +from web.models import Webhook, WebhookDeliveryLog, Alert +from web.services.webhook_service import WebhookService + +bp = Blueprint('webhooks_api', __name__) + + +@bp.route('', methods=['GET']) +@api_auth_required +def list_webhooks(): + """ + List all webhooks with optional filtering. + + Query params: + page: Page number (default: 1) + per_page: Items per page (default: 20) + enabled: Filter by enabled status (true/false) + + Returns: + JSON response with webhooks list + """ + # Get query parameters + page = request.args.get('page', 1, type=int) + per_page = min(request.args.get('per_page', 20, type=int), 100) # Max 100 items + enabled = request.args.get('enabled') + + # Build query + query = current_app.db_session.query(Webhook) + + # Apply enabled filter + if enabled is not None: + enabled_bool = enabled.lower() == 'true' + query = query.filter(Webhook.enabled == enabled_bool) + + # Order by name + query = query.order_by(Webhook.name) + + # Paginate + total = query.count() + webhooks = query.offset((page - 1) * per_page).limit(per_page).all() + + # Format response + webhooks_data = [] + + for webhook in webhooks: + # Parse JSON fields + alert_types = json.loads(webhook.alert_types) if webhook.alert_types else None + severity_filter = json.loads(webhook.severity_filter) if webhook.severity_filter else None + custom_headers = json.loads(webhook.custom_headers) if webhook.custom_headers else None + + webhooks_data.append({ + 'id': webhook.id, + 'name': webhook.name, + 'url': webhook.url, + 'enabled': webhook.enabled, + 'auth_type': webhook.auth_type, + 'auth_token': '***ENCRYPTED***' if webhook.auth_token else None, # Mask sensitive data + 'custom_headers': custom_headers, + 'alert_types': alert_types, + 'severity_filter': severity_filter, + 'timeout': webhook.timeout, + 'retry_count': webhook.retry_count, + 'created_at': webhook.created_at.isoformat() if webhook.created_at else None, + 'updated_at': webhook.updated_at.isoformat() if webhook.updated_at else None + }) + + return jsonify({ + 'webhooks': webhooks_data, + 'total': total, + 'page': page, + 'per_page': per_page, + 'pages': (total + per_page - 1) // per_page + }) + + +@bp.route('/', methods=['GET']) +@api_auth_required +def get_webhook(webhook_id): + """ + Get a specific webhook by ID. + + Args: + webhook_id: Webhook ID + + Returns: + JSON response with webhook details + """ + webhook = current_app.db_session.query(Webhook).filter(Webhook.id == webhook_id).first() + + if not webhook: + return jsonify({ + 'status': 'error', + 'message': f'Webhook {webhook_id} not found' + }), 404 + + # Parse JSON fields + alert_types = json.loads(webhook.alert_types) if webhook.alert_types else None + severity_filter = json.loads(webhook.severity_filter) if webhook.severity_filter else None + custom_headers = json.loads(webhook.custom_headers) if webhook.custom_headers else None + + return jsonify({ + 'webhook': { + 'id': webhook.id, + 'name': webhook.name, + 'url': webhook.url, + 'enabled': webhook.enabled, + 'auth_type': webhook.auth_type, + 'auth_token': '***ENCRYPTED***' if webhook.auth_token else None, + 'custom_headers': custom_headers, + 'alert_types': alert_types, + 'severity_filter': severity_filter, + 'timeout': webhook.timeout, + 'retry_count': webhook.retry_count, + 'created_at': webhook.created_at.isoformat() if webhook.created_at else None, + 'updated_at': webhook.updated_at.isoformat() if webhook.updated_at else None + } + }) + + +@bp.route('', methods=['POST']) +@api_auth_required +def create_webhook(): + """ + Create a new webhook. + + Request body: + name: Webhook name (required) + url: Webhook URL (required) + enabled: Whether webhook is enabled (default: true) + auth_type: Authentication type (none, bearer, basic, custom) + auth_token: Authentication token (encrypted on storage) + custom_headers: JSON object with custom headers + alert_types: Array of alert types to filter + severity_filter: Array of severities to filter + timeout: Request timeout in seconds (default: 10) + retry_count: Number of retry attempts (default: 3) + + Returns: + JSON response with created webhook + """ + data = request.get_json() or {} + + # Validate required fields + if not data.get('name'): + return jsonify({ + 'status': 'error', + 'message': 'name is required' + }), 400 + + if not data.get('url'): + return jsonify({ + 'status': 'error', + 'message': 'url is required' + }), 400 + + # Validate auth_type + valid_auth_types = ['none', 'bearer', 'basic', 'custom'] + auth_type = data.get('auth_type', 'none') + if auth_type not in valid_auth_types: + return jsonify({ + 'status': 'error', + 'message': f'Invalid auth_type. Must be one of: {", ".join(valid_auth_types)}' + }), 400 + + try: + webhook_service = WebhookService(current_app.db_session) + + # Encrypt auth_token if provided + auth_token = None + if data.get('auth_token'): + auth_token = webhook_service._encrypt_value(data['auth_token']) + + # Serialize JSON fields + alert_types = json.dumps(data['alert_types']) if data.get('alert_types') else None + severity_filter = json.dumps(data['severity_filter']) if data.get('severity_filter') else None + custom_headers = json.dumps(data['custom_headers']) if data.get('custom_headers') else None + + # Create webhook + webhook = Webhook( + name=data['name'], + url=data['url'], + enabled=data.get('enabled', True), + auth_type=auth_type, + auth_token=auth_token, + custom_headers=custom_headers, + alert_types=alert_types, + severity_filter=severity_filter, + timeout=data.get('timeout', 10), + retry_count=data.get('retry_count', 3), + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc) + ) + + current_app.db_session.add(webhook) + current_app.db_session.commit() + + # Parse for response + alert_types_parsed = json.loads(alert_types) if alert_types else None + severity_filter_parsed = json.loads(severity_filter) if severity_filter else None + custom_headers_parsed = json.loads(custom_headers) if custom_headers else None + + return jsonify({ + 'status': 'success', + 'message': 'Webhook created successfully', + 'webhook': { + 'id': webhook.id, + 'name': webhook.name, + 'url': webhook.url, + 'enabled': webhook.enabled, + 'auth_type': webhook.auth_type, + 'alert_types': alert_types_parsed, + 'severity_filter': severity_filter_parsed, + 'custom_headers': custom_headers_parsed, + 'timeout': webhook.timeout, + 'retry_count': webhook.retry_count, + 'created_at': webhook.created_at.isoformat() + } + }), 201 + + except Exception as e: + current_app.db_session.rollback() + return jsonify({ + 'status': 'error', + 'message': f'Failed to create webhook: {str(e)}' + }), 500 + + +@bp.route('/', methods=['PUT']) +@api_auth_required +def update_webhook(webhook_id): + """ + Update an existing webhook. + + Args: + webhook_id: Webhook ID + + Request body (all optional): + name: Webhook name + url: Webhook URL + enabled: Whether webhook is enabled + auth_type: Authentication type + auth_token: Authentication token + custom_headers: JSON object with custom headers + alert_types: Array of alert types + severity_filter: Array of severities + timeout: Request timeout + retry_count: Retry attempts + + Returns: + JSON response with update status + """ + webhook = current_app.db_session.query(Webhook).filter(Webhook.id == webhook_id).first() + + if not webhook: + return jsonify({ + 'status': 'error', + 'message': f'Webhook {webhook_id} not found' + }), 404 + + data = request.get_json() or {} + + # Validate auth_type if provided + if 'auth_type' in data: + valid_auth_types = ['none', 'bearer', 'basic', 'custom'] + if data['auth_type'] not in valid_auth_types: + return jsonify({ + 'status': 'error', + 'message': f'Invalid auth_type. Must be one of: {", ".join(valid_auth_types)}' + }), 400 + + try: + webhook_service = WebhookService(current_app.db_session) + + # Update fields if provided + if 'name' in data: + webhook.name = data['name'] + if 'url' in data: + webhook.url = data['url'] + if 'enabled' in data: + webhook.enabled = data['enabled'] + if 'auth_type' in data: + webhook.auth_type = data['auth_type'] + if 'auth_token' in data: + # Encrypt new token + webhook.auth_token = webhook_service._encrypt_value(data['auth_token']) + if 'custom_headers' in data: + webhook.custom_headers = json.dumps(data['custom_headers']) if data['custom_headers'] else None + if 'alert_types' in data: + webhook.alert_types = json.dumps(data['alert_types']) if data['alert_types'] else None + if 'severity_filter' in data: + webhook.severity_filter = json.dumps(data['severity_filter']) if data['severity_filter'] else None + if 'timeout' in data: + webhook.timeout = data['timeout'] + if 'retry_count' in data: + webhook.retry_count = data['retry_count'] + + webhook.updated_at = datetime.now(timezone.utc) + current_app.db_session.commit() + + # Parse for response + alert_types = json.loads(webhook.alert_types) if webhook.alert_types else None + severity_filter = json.loads(webhook.severity_filter) if webhook.severity_filter else None + custom_headers = json.loads(webhook.custom_headers) if webhook.custom_headers else None + + return jsonify({ + 'status': 'success', + 'message': 'Webhook updated successfully', + 'webhook': { + 'id': webhook.id, + 'name': webhook.name, + 'url': webhook.url, + 'enabled': webhook.enabled, + 'auth_type': webhook.auth_type, + 'alert_types': alert_types, + 'severity_filter': severity_filter, + 'custom_headers': custom_headers, + 'timeout': webhook.timeout, + 'retry_count': webhook.retry_count, + 'updated_at': webhook.updated_at.isoformat() + } + }) + + except Exception as e: + current_app.db_session.rollback() + return jsonify({ + 'status': 'error', + 'message': f'Failed to update webhook: {str(e)}' + }), 500 + + +@bp.route('/', methods=['DELETE']) +@api_auth_required +def delete_webhook(webhook_id): + """ + Delete a webhook. + + Args: + webhook_id: Webhook ID + + Returns: + JSON response with deletion status + """ + webhook = current_app.db_session.query(Webhook).filter(Webhook.id == webhook_id).first() + + if not webhook: + return jsonify({ + 'status': 'error', + 'message': f'Webhook {webhook_id} not found' + }), 404 + + try: + # Delete webhook (delivery logs will be cascade deleted) + current_app.db_session.delete(webhook) + current_app.db_session.commit() + + return jsonify({ + 'status': 'success', + 'message': f'Webhook {webhook_id} deleted successfully' + }) + + except Exception as e: + current_app.db_session.rollback() + return jsonify({ + 'status': 'error', + 'message': f'Failed to delete webhook: {str(e)}' + }), 500 + + +@bp.route('//test', methods=['POST']) +@api_auth_required +def test_webhook(webhook_id): + """ + Send a test payload to a webhook. + + Args: + webhook_id: Webhook ID + + Returns: + JSON response with test result + """ + webhook = current_app.db_session.query(Webhook).filter(Webhook.id == webhook_id).first() + + if not webhook: + return jsonify({ + 'status': 'error', + 'message': f'Webhook {webhook_id} not found' + }), 404 + + # Test webhook delivery + webhook_service = WebhookService(current_app.db_session) + result = webhook_service.test_webhook(webhook_id) + + return jsonify({ + 'status': 'success' if result['success'] else 'error', + 'message': result['message'], + 'status_code': result['status_code'], + 'response_body': result.get('response_body') + }) + + +@bp.route('//logs', methods=['GET']) +@api_auth_required +def get_webhook_logs(webhook_id): + """ + Get delivery logs for a specific webhook. + + Args: + webhook_id: Webhook ID + + Query params: + page: Page number (default: 1) + per_page: Items per page (default: 20) + status: Filter by status (success/failed) + + Returns: + JSON response with delivery logs + """ + webhook = current_app.db_session.query(Webhook).filter(Webhook.id == webhook_id).first() + + if not webhook: + return jsonify({ + 'status': 'error', + 'message': f'Webhook {webhook_id} not found' + }), 404 + + # Get query parameters + page = request.args.get('page', 1, type=int) + per_page = min(request.args.get('per_page', 20, type=int), 100) + status_filter = request.args.get('status') + + # Build query + query = current_app.db_session.query(WebhookDeliveryLog).filter( + WebhookDeliveryLog.webhook_id == webhook_id + ) + + # Apply status filter + if status_filter: + query = query.filter(WebhookDeliveryLog.status == status_filter) + + # Order by most recent first + query = query.order_by(WebhookDeliveryLog.delivered_at.desc()) + + # Paginate + total = query.count() + logs = query.offset((page - 1) * per_page).limit(per_page).all() + + # Format response + logs_data = [] + for log in logs: + # Get alert info + alert = current_app.db_session.query(Alert).filter(Alert.id == log.alert_id).first() + + logs_data.append({ + 'id': log.id, + 'alert_id': log.alert_id, + 'alert_type': alert.alert_type if alert else None, + 'alert_message': alert.message if alert else None, + 'status': log.status, + 'response_code': log.response_code, + 'response_body': log.response_body, + 'error_message': log.error_message, + 'attempt_number': log.attempt_number, + 'delivered_at': log.delivered_at.isoformat() if log.delivered_at else None + }) + + return jsonify({ + 'webhook_id': webhook_id, + 'webhook_name': webhook.name, + 'logs': logs_data, + 'total': total, + 'page': page, + 'per_page': per_page, + 'pages': (total + per_page - 1) // per_page + }) + + +# Health check endpoint +@bp.route('/health', methods=['GET']) +def health_check(): + """ + Health check endpoint for monitoring. + + Returns: + JSON response with API health status + """ + return jsonify({ + 'status': 'healthy', + 'api': 'webhooks', + 'version': '1.0.0-phase5' + }) diff --git a/app/web/config.py b/app/web/config.py new file mode 100644 index 0000000..11277bc --- /dev/null +++ b/app/web/config.py @@ -0,0 +1,13 @@ +""" +Application configuration and metadata. + +Contains version information and other application-level constants +that are managed by developers, not stored in the database. +""" + +# Application metadata +APP_NAME = 'SneakyScanner' +APP_VERSION = '1.0.0-phase5' + +# Repository URL +REPO_URL = 'https://git.sneakygeek.net/sneakygeek/SneakyScan' diff --git a/app/web/jobs/webhook_job.py b/app/web/jobs/webhook_job.py new file mode 100644 index 0000000..b415d4b --- /dev/null +++ b/app/web/jobs/webhook_job.py @@ -0,0 +1,59 @@ +""" +Background webhook delivery job execution. + +This module handles the execution of webhook deliveries in background threads, +updating delivery logs and handling errors. +""" + +import logging +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from web.services.webhook_service import WebhookService + +logger = logging.getLogger(__name__) + + +def execute_webhook_delivery(webhook_id: int, alert_id: int, db_url: str): + """ + Execute a webhook delivery in the background. + + This function is designed to run in a background thread via APScheduler. + It creates its own database session to avoid conflicts with the main + application thread. + + Args: + webhook_id: ID of the webhook to deliver + alert_id: ID of the alert to send + db_url: Database connection URL + + Workflow: + 1. Create new database session for this thread + 2. Call WebhookService to deliver webhook + 3. WebhookService handles retry logic and logging + 4. Close session + """ + logger.info(f"Starting background webhook delivery: webhook_id={webhook_id}, alert_id={alert_id}") + + # Create new database session for this thread + engine = create_engine(db_url, echo=False) + Session = sessionmaker(bind=engine) + session = Session() + + try: + # Create webhook service and deliver + webhook_service = WebhookService(session) + success = webhook_service.deliver_webhook(webhook_id, alert_id) + + if success: + logger.info(f"Webhook {webhook_id} delivered successfully for alert {alert_id}") + else: + logger.warning(f"Webhook {webhook_id} delivery failed for alert {alert_id}") + + except Exception as e: + logger.error(f"Error during webhook delivery: {e}", exc_info=True) + + finally: + session.close() + engine.dispose() + logger.info(f"Webhook delivery job completed: webhook_id={webhook_id}, alert_id={alert_id}") diff --git a/app/web/routes/webhooks.py b/app/web/routes/webhooks.py new file mode 100644 index 0000000..f129ca3 --- /dev/null +++ b/app/web/routes/webhooks.py @@ -0,0 +1,83 @@ +""" +Webhook web routes for SneakyScanner. + +Provides UI pages for managing webhooks and viewing delivery logs. +""" + +import logging + +from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app + +from web.auth.decorators import login_required +from web.models import Webhook +from web.services.webhook_service import WebhookService + +logger = logging.getLogger(__name__) + +bp = Blueprint('webhooks', __name__) + + +@bp.route('') +@login_required +def list_webhooks(): + """ + Webhooks list page - shows all configured webhooks. + + Returns: + Rendered webhooks list template + """ + return render_template('webhooks/list.html') + + +@bp.route('/new') +@login_required +def new_webhook(): + """ + New webhook form page. + + Returns: + Rendered webhook form template + """ + return render_template('webhooks/form.html', webhook=None, mode='create') + + +@bp.route('//edit') +@login_required +def edit_webhook(webhook_id): + """ + Edit webhook form page. + + Args: + webhook_id: Webhook ID to edit + + Returns: + Rendered webhook form template or 404 + """ + webhook = current_app.db_session.query(Webhook).filter(Webhook.id == webhook_id).first() + + if not webhook: + flash('Webhook not found', 'error') + return redirect(url_for('webhooks.list_webhooks')) + + return render_template('webhooks/form.html', webhook=webhook, mode='edit') + + +@bp.route('//logs') +@login_required +def webhook_logs(webhook_id): + """ + Webhook delivery logs page. + + Args: + webhook_id: Webhook ID + + Returns: + Rendered webhook logs template or 404 + """ + webhook = current_app.db_session.query(Webhook).filter(Webhook.id == webhook_id).first() + + if not webhook: + flash('Webhook not found', 'error') + return redirect(url_for('webhooks.list_webhooks')) + + return render_template('webhooks/logs.html', webhook=webhook) diff --git a/app/web/services/webhook_service.py b/app/web/services/webhook_service.py new file mode 100644 index 0000000..2130029 --- /dev/null +++ b/app/web/services/webhook_service.py @@ -0,0 +1,439 @@ +""" +Webhook Service Module + +Handles webhook delivery for alert notifications with retry logic, +authentication support, and comprehensive logging. +""" + +import json +import logging +import time +from datetime import datetime, timezone +from typing import List, Dict, Optional, Any +from sqlalchemy.orm import Session +import requests +from requests.auth import HTTPBasicAuth + +from cryptography.fernet import Fernet +import os + +from ..models import Webhook, WebhookDeliveryLog, Alert, AlertRule, Scan + +logger = logging.getLogger(__name__) + + +class WebhookService: + """ + Service for webhook delivery and management. + + Handles queuing webhook deliveries, executing HTTP requests with + authentication, retry logic, and logging delivery attempts. + """ + + def __init__(self, db_session: Session, encryption_key: Optional[bytes] = None): + """ + Initialize webhook service. + + Args: + db_session: SQLAlchemy database session + encryption_key: Fernet encryption key for auth_token encryption + """ + self.db = db_session + self._encryption_key = encryption_key or self._get_encryption_key() + self._cipher = Fernet(self._encryption_key) if self._encryption_key else None + + def _get_encryption_key(self) -> Optional[bytes]: + """ + Get encryption key from environment or database. + + Returns: + Fernet encryption key or None if not available + """ + # Try environment variable first + key_str = os.environ.get('SNEAKYSCANNER_ENCRYPTION_KEY') + if key_str: + return key_str.encode() + + # Try to get from settings (would need to query Setting table) + # For now, generate a temporary key if none exists + try: + return Fernet.generate_key() + except Exception as e: + logger.warning(f"Could not generate encryption key: {e}") + return None + + def _encrypt_value(self, value: str) -> str: + """Encrypt a string value.""" + if not self._cipher: + return value # Return plain text if encryption not available + return self._cipher.encrypt(value.encode()).decode() + + def _decrypt_value(self, encrypted_value: str) -> str: + """Decrypt an encrypted string value.""" + if not self._cipher: + return encrypted_value # Return as-is if encryption not available + try: + return self._cipher.decrypt(encrypted_value.encode()).decode() + except Exception as e: + logger.error(f"Failed to decrypt value: {e}") + return encrypted_value + + def get_matching_webhooks(self, alert: Alert) -> List[Webhook]: + """ + Get all enabled webhooks that match an alert's type and severity. + + Args: + alert: Alert object to match against + + Returns: + List of matching Webhook objects + """ + # Get all enabled webhooks + webhooks = self.db.query(Webhook).filter(Webhook.enabled == True).all() + + matching_webhooks = [] + for webhook in webhooks: + # Check if webhook matches alert type filter + if webhook.alert_types: + try: + alert_types = json.loads(webhook.alert_types) + if alert.alert_type not in alert_types: + continue # Skip if alert type doesn't match + except json.JSONDecodeError: + logger.warning(f"Invalid alert_types JSON for webhook {webhook.id}") + continue + + # Check if webhook matches severity filter + if webhook.severity_filter: + try: + severity_filter = json.loads(webhook.severity_filter) + if alert.severity not in severity_filter: + continue # Skip if severity doesn't match + except json.JSONDecodeError: + logger.warning(f"Invalid severity_filter JSON for webhook {webhook.id}") + continue + + matching_webhooks.append(webhook) + + logger.info(f"Found {len(matching_webhooks)} matching webhooks for alert {alert.id}") + return matching_webhooks + + def queue_webhook_delivery(self, webhook_id: int, alert_id: int, scheduler_service=None) -> bool: + """ + Queue a webhook delivery for async execution via APScheduler. + + Args: + webhook_id: ID of webhook to deliver + alert_id: ID of alert to send + scheduler_service: SchedulerService instance (if None, deliver synchronously) + + Returns: + True if queued successfully, False otherwise + """ + if scheduler_service and scheduler_service.scheduler: + try: + # Import here to avoid circular dependency + from web.jobs.webhook_job import execute_webhook_delivery + + # Schedule immediate execution + scheduler_service.scheduler.add_job( + execute_webhook_delivery, + args=[webhook_id, alert_id, scheduler_service.db_url], + id=f"webhook_{webhook_id}_{alert_id}_{int(time.time())}", + replace_existing=False + ) + logger.info(f"Queued webhook {webhook_id} for alert {alert_id}") + return True + except Exception as e: + logger.error(f"Failed to queue webhook delivery: {e}") + # Fall back to synchronous delivery + return self.deliver_webhook(webhook_id, alert_id) + else: + # No scheduler available, deliver synchronously + logger.info(f"No scheduler available, delivering webhook {webhook_id} synchronously") + return self.deliver_webhook(webhook_id, alert_id) + + def deliver_webhook(self, webhook_id: int, alert_id: int, attempt_number: int = 1) -> bool: + """ + Deliver a webhook with retry logic. + + Args: + webhook_id: ID of webhook to deliver + alert_id: ID of alert to send + attempt_number: Current attempt number (for retries) + + Returns: + True if delivered successfully, False otherwise + """ + # Get webhook and alert + webhook = self.db.query(Webhook).filter(Webhook.id == webhook_id).first() + if not webhook: + logger.error(f"Webhook {webhook_id} not found") + return False + + alert = self.db.query(Alert).filter(Alert.id == alert_id).first() + if not alert: + logger.error(f"Alert {alert_id} not found") + return False + + logger.info(f"Delivering webhook {webhook_id} for alert {alert_id} (attempt {attempt_number}/{webhook.retry_count})") + + # Build payload + payload = self._build_payload(alert) + + # Prepare headers + headers = {'Content-Type': 'application/json'} + + # Add custom headers if provided + if webhook.custom_headers: + try: + custom_headers = json.loads(webhook.custom_headers) + headers.update(custom_headers) + except json.JSONDecodeError: + logger.warning(f"Invalid custom_headers JSON for webhook {webhook_id}") + + # Prepare authentication + auth = None + if webhook.auth_type == 'bearer' and webhook.auth_token: + decrypted_token = self._decrypt_value(webhook.auth_token) + headers['Authorization'] = f'Bearer {decrypted_token}' + elif webhook.auth_type == 'basic' and webhook.auth_token: + # Expecting format: "username:password" + decrypted_token = self._decrypt_value(webhook.auth_token) + if ':' in decrypted_token: + username, password = decrypted_token.split(':', 1) + auth = HTTPBasicAuth(username, password) + + # Execute HTTP request + try: + timeout = webhook.timeout or 10 + response = requests.post( + webhook.url, + json=payload, + headers=headers, + auth=auth, + timeout=timeout + ) + + # Log delivery attempt + log_entry = WebhookDeliveryLog( + webhook_id=webhook_id, + alert_id=alert_id, + status='success' if response.status_code < 400 else 'failed', + response_code=response.status_code, + response_body=response.text[:1000], # Limit to 1000 chars + error_message=None if response.status_code < 400 else f"HTTP {response.status_code}", + attempt_number=attempt_number, + delivered_at=datetime.now(timezone.utc) + ) + self.db.add(log_entry) + + # Update alert webhook status if successful + if response.status_code < 400: + alert.webhook_sent = True + alert.webhook_sent_at = datetime.now(timezone.utc) + logger.info(f"Webhook {webhook_id} delivered successfully (HTTP {response.status_code})") + self.db.commit() + return True + else: + # Failed but got a response + logger.warning(f"Webhook {webhook_id} failed with HTTP {response.status_code}") + self.db.commit() + + # Retry if attempts remaining + if attempt_number < webhook.retry_count: + delay = self._calculate_retry_delay(attempt_number) + logger.info(f"Retrying webhook {webhook_id} in {delay} seconds") + time.sleep(delay) + return self.deliver_webhook(webhook_id, alert_id, attempt_number + 1) + return False + + except requests.exceptions.Timeout: + error_msg = f"Request timeout after {timeout} seconds" + logger.error(f"Webhook {webhook_id} timeout: {error_msg}") + self._log_delivery_failure(webhook_id, alert_id, error_msg, attempt_number) + + except requests.exceptions.ConnectionError as e: + error_msg = f"Connection error: {str(e)}" + logger.error(f"Webhook {webhook_id} connection error: {error_msg}") + self._log_delivery_failure(webhook_id, alert_id, error_msg, attempt_number) + + except requests.exceptions.RequestException as e: + error_msg = f"Request error: {str(e)}" + logger.error(f"Webhook {webhook_id} request error: {error_msg}") + self._log_delivery_failure(webhook_id, alert_id, error_msg, attempt_number) + + except Exception as e: + error_msg = f"Unexpected error: {str(e)}" + logger.error(f"Webhook {webhook_id} unexpected error: {error_msg}") + self._log_delivery_failure(webhook_id, alert_id, error_msg, attempt_number) + + # Retry if attempts remaining + if attempt_number < webhook.retry_count: + delay = self._calculate_retry_delay(attempt_number) + logger.info(f"Retrying webhook {webhook_id} in {delay} seconds") + time.sleep(delay) + return self.deliver_webhook(webhook_id, alert_id, attempt_number + 1) + + return False + + def _log_delivery_failure(self, webhook_id: int, alert_id: int, error_message: str, attempt_number: int): + """Log a failed delivery attempt.""" + log_entry = WebhookDeliveryLog( + webhook_id=webhook_id, + alert_id=alert_id, + status='failed', + response_code=None, + response_body=None, + error_message=error_message[:500], # Limit error message length + attempt_number=attempt_number, + delivered_at=datetime.now(timezone.utc) + ) + self.db.add(log_entry) + self.db.commit() + + def _calculate_retry_delay(self, attempt_number: int) -> int: + """ + Calculate exponential backoff delay for retries. + + Args: + attempt_number: Current attempt number + + Returns: + Delay in seconds + """ + # Exponential backoff: 2^attempt seconds (2, 4, 8, 16...) + return min(2 ** attempt_number, 60) # Cap at 60 seconds + + def _build_payload(self, alert: Alert) -> Dict[str, Any]: + """ + Build JSON payload for webhook delivery. + + Args: + alert: Alert object + + Returns: + Dict containing alert details in generic JSON format + """ + # Get related scan + scan = self.db.query(Scan).filter(Scan.id == alert.scan_id).first() + + # Get related rule + rule = self.db.query(AlertRule).filter(AlertRule.id == alert.rule_id).first() + + payload = { + "event": "alert.created", + "alert": { + "id": alert.id, + "type": alert.alert_type, + "severity": alert.severity, + "message": alert.message, + "ip_address": alert.ip_address, + "port": alert.port, + "acknowledged": alert.acknowledged, + "created_at": alert.created_at.isoformat() if alert.created_at else None + }, + "scan": { + "id": scan.id if scan else None, + "title": scan.title if scan else None, + "timestamp": scan.timestamp.isoformat() if scan and scan.timestamp else None, + "status": scan.status if scan else None + }, + "rule": { + "id": rule.id if rule else None, + "name": rule.name if rule else None, + "type": rule.rule_type if rule else None, + "threshold": rule.threshold if rule else None + } + } + + return payload + + def test_webhook(self, webhook_id: int) -> Dict[str, Any]: + """ + Send a test payload to a webhook. + + Args: + webhook_id: ID of webhook to test + + Returns: + Dict with test result details + """ + webhook = self.db.query(Webhook).filter(Webhook.id == webhook_id).first() + if not webhook: + return { + 'success': False, + 'message': 'Webhook not found', + 'status_code': None + } + + # Build test payload + payload = { + "event": "webhook.test", + "message": "This is a test webhook from SneakyScanner", + "timestamp": datetime.now(timezone.utc).isoformat(), + "webhook": { + "id": webhook.id, + "name": webhook.name + } + } + + # Prepare headers + headers = {'Content-Type': 'application/json'} + + if webhook.custom_headers: + try: + custom_headers = json.loads(webhook.custom_headers) + headers.update(custom_headers) + except json.JSONDecodeError: + pass + + # Prepare authentication + auth = None + if webhook.auth_type == 'bearer' and webhook.auth_token: + decrypted_token = self._decrypt_value(webhook.auth_token) + headers['Authorization'] = f'Bearer {decrypted_token}' + elif webhook.auth_type == 'basic' and webhook.auth_token: + decrypted_token = self._decrypt_value(webhook.auth_token) + if ':' in decrypted_token: + username, password = decrypted_token.split(':', 1) + auth = HTTPBasicAuth(username, password) + + # Execute test request + try: + timeout = webhook.timeout or 10 + response = requests.post( + webhook.url, + json=payload, + headers=headers, + auth=auth, + timeout=timeout + ) + + return { + 'success': response.status_code < 400, + 'message': f'HTTP {response.status_code}', + 'status_code': response.status_code, + 'response_body': response.text[:500] + } + + except requests.exceptions.Timeout: + return { + 'success': False, + 'message': f'Request timeout after {timeout} seconds', + 'status_code': None + } + + except requests.exceptions.ConnectionError as e: + return { + 'success': False, + 'message': f'Connection error: {str(e)}', + 'status_code': None + } + + except Exception as e: + return { + 'success': False, + 'message': f'Error: {str(e)}', + 'status_code': None + } diff --git a/app/web/templates/webhooks/form.html b/app/web/templates/webhooks/form.html new file mode 100644 index 0000000..5595055 --- /dev/null +++ b/app/web/templates/webhooks/form.html @@ -0,0 +1,369 @@ +{% extends "base.html" %} + +{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Webhook - SneakyScanner{% endblock %} + +{% block content %} +
+
+

{{ 'Edit' if mode == 'edit' else 'Create' }} Webhook

+ +
+
+ +
+
+
+
+
+ +
Basic Information
+ +
+ + +
A descriptive name for this webhook
+
+ +
+ + +
The endpoint where alerts will be sent
+
+ +
+
+ + +
+
Disabled webhooks will not receive notifications
+
+ +
+ + +
Authentication
+ +
+ + +
+ + + + + +
+ + +
Alert Filters
+ +
+ +
Select which alert types trigger this webhook (leave all unchecked for all types)
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
Select which severities trigger this webhook (leave all unchecked for all severities)
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
Advanced Settings
+ +
+
+ + +
Maximum time to wait for response
+
+ +
+ + +
Number of retry attempts on failure
+
+
+ +
+ + +
+ Cancel +
+ + +
+
+
+
+
+
+ + +
+
+
+
Help
+ +
Payload Format
+

Webhooks receive JSON payloads with alert details:

+
{
+  "event": "alert.created",
+  "alert": {
+    "id": 123,
+    "type": "cert_expiry",
+    "severity": "warning",
+    "message": "...",
+    "ip_address": "192.168.1.10",
+    "port": 443
+  },
+  "scan": {...},
+  "rule": {...}
+}
+ +
Authentication Types
+
    +
  • None: No authentication
  • +
  • Bearer: Add Authorization header with token
  • +
  • Basic: Use username:password format
  • +
  • Custom: Define custom HTTP headers
  • +
+ +
Retry Logic
+

Failed webhooks are retried with exponential backoff (2^attempt seconds, max 60s).

+
+
+
+
+ +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/web/templates/webhooks/list.html b/app/web/templates/webhooks/list.html new file mode 100644 index 0000000..87c6b0e --- /dev/null +++ b/app/web/templates/webhooks/list.html @@ -0,0 +1,250 @@ +{% extends "base.html" %} + +{% block title %}Webhooks - SneakyScanner{% endblock %} + +{% block content %} +
+
+

Webhook Management

+ + Add Webhook + +
+
+ + +
+
+ Loading... +
+
+ + + + + + + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/web/templates/webhooks/logs.html b/app/web/templates/webhooks/logs.html new file mode 100644 index 0000000..a112a4f --- /dev/null +++ b/app/web/templates/webhooks/logs.html @@ -0,0 +1,328 @@ +{% extends "base.html" %} + +{% block title %}Webhook Logs - {{ webhook.name }} - SneakyScanner{% endblock %} + +{% block content %} +
+
+

Webhook Delivery Logs

+ +
+
+ + +
+
+
+
+
+
+
{{ webhook.name }}
+

URL: {{ webhook.url }}

+

+ Status: + {% if webhook.enabled %} + Enabled + {% else %} + Disabled + {% endif %} +

+
+ +
+
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+ +
+
+ +
+
+
+
+
+
+ + +
+
+ Loading... +
+
+ + + + + + + + + + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/docs/alerts.png b/docs/alerts.png new file mode 100644 index 0000000..9d399f5 Binary files /dev/null and b/docs/alerts.png differ diff --git a/docs/config_editor.png b/docs/config_editor.png new file mode 100644 index 0000000..49a71c0 Binary files /dev/null and b/docs/config_editor.png differ diff --git a/docs/configs.png b/docs/configs.png new file mode 100644 index 0000000..c7063ee Binary files /dev/null and b/docs/configs.png differ diff --git a/docs/scans.png b/docs/scans.png new file mode 100644 index 0000000..3bcfd2b Binary files /dev/null and b/docs/scans.png differ