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

This commit is contained in:
2025-11-18 15:05:57 -06:00
parent 1d076a467a
commit 28b32a2049
12 changed files with 2041 additions and 0 deletions

500
app/web/api/webhooks.py Normal file
View File

@@ -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('/<int:webhook_id>', 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('/<int:webhook_id>', 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('/<int:webhook_id>', 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('/<int:webhook_id>/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('/<int:webhook_id>/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'
})

13
app/web/config.py Normal file
View File

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

View File

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

View File

@@ -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('/<int:webhook_id>/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('/<int:webhook_id>/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)

View File

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

View File

@@ -0,0 +1,369 @@
{% extends "base.html" %}
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Webhook - SneakyScanner{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-12 mb-4">
<h1 style="color: #60a5fa;">{{ 'Edit' if mode == 'edit' else 'Create' }} Webhook</h1>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('webhooks.list_webhooks') }}">Webhooks</a></li>
<li class="breadcrumb-item active">{{ 'Edit' if mode == 'edit' else 'New' }}</li>
</ol>
</nav>
</div>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-body">
<form id="webhook-form">
<!-- Basic Information -->
<h5 class="card-title mb-3">Basic Information</h5>
<div class="mb-3">
<label for="name" class="form-label">Webhook Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="name" name="name" required
placeholder="e.g., Slack Notifications">
<div class="form-text">A descriptive name for this webhook</div>
</div>
<div class="mb-3">
<label for="url" class="form-label">Webhook URL <span class="text-danger">*</span></label>
<input type="url" class="form-control" id="url" name="url" required
placeholder="https://hooks.example.com/webhook">
<div class="form-text">The endpoint where alerts will be sent</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="enabled" name="enabled" checked>
<label class="form-check-label" for="enabled">Enabled</label>
</div>
<div class="form-text">Disabled webhooks will not receive notifications</div>
</div>
<hr class="my-4">
<!-- Authentication -->
<h5 class="card-title mb-3">Authentication</h5>
<div class="mb-3">
<label for="auth_type" class="form-label">Authentication Type</label>
<select class="form-select" id="auth_type" name="auth_type">
<option value="none">None</option>
<option value="bearer">Bearer Token</option>
<option value="basic">Basic Auth (username:password)</option>
<option value="custom">Custom Headers</option>
</select>
</div>
<div class="mb-3" id="auth_token_field" style="display: none;">
<label for="auth_token" class="form-label">Authentication Token</label>
<input type="password" class="form-control" id="auth_token" name="auth_token"
placeholder="Enter token or username:password">
<div class="form-text" id="auth_token_help">Will be encrypted when stored</div>
</div>
<div class="mb-3" id="custom_headers_field" style="display: none;">
<label for="custom_headers" class="form-label">Custom Headers (JSON)</label>
<textarea class="form-control font-monospace" id="custom_headers" name="custom_headers" rows="4"
placeholder='{"X-API-Key": "your-key", "X-Custom-Header": "value"}'></textarea>
<div class="form-text">JSON object with custom HTTP headers</div>
</div>
<hr class="my-4">
<!-- Filters -->
<h5 class="card-title mb-3">Alert Filters</h5>
<div class="mb-3">
<label class="form-label">Alert Types</label>
<div class="form-text mb-2">Select which alert types trigger this webhook (leave all unchecked for all types)</div>
<div class="form-check">
<input class="form-check-input alert-type-check" type="checkbox" value="unexpected_port" id="type_unexpected">
<label class="form-check-label" for="type_unexpected">Unexpected Port</label>
</div>
<div class="form-check">
<input class="form-check-input alert-type-check" type="checkbox" value="drift_detection" id="type_drift">
<label class="form-check-label" for="type_drift">Drift Detection</label>
</div>
<div class="form-check">
<input class="form-check-input alert-type-check" type="checkbox" value="cert_expiry" id="type_cert">
<label class="form-check-label" for="type_cert">Certificate Expiry</label>
</div>
<div class="form-check">
<input class="form-check-input alert-type-check" type="checkbox" value="weak_tls" id="type_tls">
<label class="form-check-label" for="type_tls">Weak TLS</label>
</div>
<div class="form-check">
<input class="form-check-input alert-type-check" type="checkbox" value="ping_failed" id="type_ping">
<label class="form-check-label" for="type_ping">Ping Failed</label>
</div>
</div>
<div class="mb-3">
<label class="form-label">Severity Filter</label>
<div class="form-text mb-2">Select which severities trigger this webhook (leave all unchecked for all severities)</div>
<div class="form-check">
<input class="form-check-input severity-check" type="checkbox" value="critical" id="severity_critical">
<label class="form-check-label" for="severity_critical">Critical</label>
</div>
<div class="form-check">
<input class="form-check-input severity-check" type="checkbox" value="warning" id="severity_warning">
<label class="form-check-label" for="severity_warning">Warning</label>
</div>
<div class="form-check">
<input class="form-check-input severity-check" type="checkbox" value="info" id="severity_info">
<label class="form-check-label" for="severity_info">Info</label>
</div>
</div>
<hr class="my-4">
<!-- Advanced Settings -->
<h5 class="card-title mb-3">Advanced Settings</h5>
<div class="row">
<div class="col-md-6 mb-3">
<label for="timeout" class="form-label">Timeout (seconds)</label>
<input type="number" class="form-control" id="timeout" name="timeout" min="1" max="60" value="10">
<div class="form-text">Maximum time to wait for response</div>
</div>
<div class="col-md-6 mb-3">
<label for="retry_count" class="form-label">Retry Count</label>
<input type="number" class="form-control" id="retry_count" name="retry_count" min="0" max="5" value="3">
<div class="form-text">Number of retry attempts on failure</div>
</div>
</div>
<hr class="my-4">
<!-- Submit Buttons -->
<div class="d-flex justify-content-between">
<a href="{{ url_for('webhooks.list_webhooks') }}" class="btn btn-secondary">Cancel</a>
<div>
<button type="button" class="btn btn-outline-primary me-2" id="test-btn">
<i class="bi bi-send"></i> Test Webhook
</button>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i> Save Webhook
</button>
</div>
</div>
</form>
</div>
</div>
</div>
<!-- Help Sidebar -->
<div class="col-lg-4">
<div class="card">
<div class="card-body">
<h5 class="card-title"><i class="bi bi-info-circle"></i> Help</h5>
<h6 class="mt-3">Payload Format</h6>
<p class="small text-muted">Webhooks receive JSON payloads with alert details:</p>
<pre class="small bg-dark text-light p-2 rounded"><code>{
"event": "alert.created",
"alert": {
"id": 123,
"type": "cert_expiry",
"severity": "warning",
"message": "...",
"ip_address": "192.168.1.10",
"port": 443
},
"scan": {...},
"rule": {...}
}</code></pre>
<h6 class="mt-3">Authentication Types</h6>
<ul class="small">
<li><strong>None:</strong> No authentication</li>
<li><strong>Bearer:</strong> Add Authorization header with token</li>
<li><strong>Basic:</strong> Use username:password format</li>
<li><strong>Custom:</strong> Define custom HTTP headers</li>
</ul>
<h6 class="mt-3">Retry Logic</h6>
<p class="small text-muted">Failed webhooks are retried with exponential backoff (2^attempt seconds, max 60s).</p>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const webhookId = {{ webhook.id if webhook else 'null' }};
const mode = '{{ mode }}';
// Show/hide auth fields based on type
document.getElementById('auth_type').addEventListener('change', function() {
const authType = this.value;
const tokenField = document.getElementById('auth_token_field');
const headersField = document.getElementById('custom_headers_field');
const tokenHelp = document.getElementById('auth_token_help');
tokenField.style.display = 'none';
headersField.style.display = 'none';
if (authType === 'bearer') {
tokenField.style.display = 'block';
document.getElementById('auth_token').placeholder = 'Enter bearer token';
tokenHelp.textContent = 'Bearer token for Authorization header (encrypted when stored)';
} else if (authType === 'basic') {
tokenField.style.display = 'block';
document.getElementById('auth_token').placeholder = 'username:password';
tokenHelp.textContent = 'Format: username:password (encrypted when stored)';
} else if (authType === 'custom') {
headersField.style.display = 'block';
}
});
// Load existing webhook data if editing
if (mode === 'edit' && webhookId) {
loadWebhookData(webhookId);
}
async function loadWebhookData(id) {
try {
const response = await fetch(`/api/webhooks/${id}`);
const data = await response.json();
const webhook = data.webhook;
// Populate form fields
document.getElementById('name').value = webhook.name || '';
document.getElementById('url').value = webhook.url || '';
document.getElementById('enabled').checked = webhook.enabled;
document.getElementById('auth_type').value = webhook.auth_type || 'none';
document.getElementById('timeout').value = webhook.timeout || 10;
document.getElementById('retry_count').value = webhook.retry_count || 3;
// Trigger auth type change to show relevant fields
document.getElementById('auth_type').dispatchEvent(new Event('change'));
// Don't populate auth_token (it's encrypted)
if (webhook.custom_headers) {
document.getElementById('custom_headers').value = JSON.stringify(webhook.custom_headers, null, 2);
}
// Check alert types
if (webhook.alert_types && webhook.alert_types.length > 0) {
webhook.alert_types.forEach(type => {
const checkbox = document.querySelector(`.alert-type-check[value="${type}"]`);
if (checkbox) checkbox.checked = true;
});
}
// Check severities
if (webhook.severity_filter && webhook.severity_filter.length > 0) {
webhook.severity_filter.forEach(sev => {
const checkbox = document.querySelector(`.severity-check[value="${sev}"]`);
if (checkbox) checkbox.checked = true;
});
}
} catch (error) {
console.error('Error loading webhook:', error);
alert('Failed to load webhook data');
}
}
// Form submission
document.getElementById('webhook-form').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = {
name: document.getElementById('name').value,
url: document.getElementById('url').value,
enabled: document.getElementById('enabled').checked,
auth_type: document.getElementById('auth_type').value,
timeout: parseInt(document.getElementById('timeout').value),
retry_count: parseInt(document.getElementById('retry_count').value)
};
// Add auth token if provided
const authToken = document.getElementById('auth_token').value;
if (authToken) {
formData.auth_token = authToken;
}
// Add custom headers if provided
const customHeaders = document.getElementById('custom_headers').value;
if (customHeaders.trim()) {
try {
formData.custom_headers = JSON.parse(customHeaders);
} catch (e) {
alert('Invalid JSON in custom headers');
return;
}
}
// Collect selected alert types
const alertTypes = Array.from(document.querySelectorAll('.alert-type-check:checked'))
.map(cb => cb.value);
if (alertTypes.length > 0) {
formData.alert_types = alertTypes;
}
// Collect selected severities
const severities = Array.from(document.querySelectorAll('.severity-check:checked'))
.map(cb => cb.value);
if (severities.length > 0) {
formData.severity_filter = severities;
}
try {
const url = mode === 'edit' ? `/api/webhooks/${webhookId}` : '/api/webhooks';
const method = mode === 'edit' ? 'PUT' : 'POST';
const response = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
const result = await response.json();
if (result.status === 'success') {
alert('Webhook saved successfully!');
window.location.href = '{{ url_for("webhooks.list_webhooks") }}';
} else {
alert(`Failed to save webhook: ${result.message}`);
}
} catch (error) {
console.error('Error saving webhook:', error);
alert('Failed to save webhook');
}
});
// Test webhook button
document.getElementById('test-btn').addEventListener('click', async function() {
if (mode !== 'edit' || !webhookId) {
alert('Please save the webhook first before testing');
return;
}
if (!confirm('Send a test payload to this webhook?')) return;
try {
const response = await fetch(`/api/webhooks/${webhookId}/test`, { method: 'POST' });
const result = await response.json();
if (result.status === 'success') {
alert(`Test successful!\nHTTP ${result.status_code}\n${result.message}`);
} else {
alert(`Test failed:\n${result.message}`);
}
} catch (error) {
console.error('Error testing webhook:', error);
alert('Failed to test webhook');
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,250 @@
{% extends "base.html" %}
{% block title %}Webhooks - SneakyScanner{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-12 d-flex justify-content-between align-items-center mb-4">
<h1 style="color: #60a5fa;">Webhook Management</h1>
<a href="{{ url_for('webhooks.new_webhook') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add Webhook
</a>
</div>
</div>
<!-- Loading indicator -->
<div id="loading" class="text-center my-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<!-- Webhooks table -->
<div id="webhooks-container" style="display: none;">
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Name</th>
<th>URL</th>
<th>Alert Types</th>
<th>Severity Filter</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="webhooks-tbody">
<!-- Populated via JavaScript -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Pagination -->
<nav aria-label="Webhooks pagination" id="pagination-container">
<ul class="pagination justify-content-center" id="pagination">
<!-- Populated via JavaScript -->
</ul>
</nav>
</div>
<!-- Empty state -->
<div id="empty-state" class="text-center my-5" style="display: none;">
<i class="bi bi-webhook" style="font-size: 4rem; color: #94a3b8;"></i>
<p class="text-muted mt-3">No webhooks configured yet.</p>
<a href="{{ url_for('webhooks.new_webhook') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Create Your First Webhook
</a>
</div>
{% endblock %}
{% block scripts %}
<script>
let currentPage = 1;
const perPage = 20;
async function loadWebhooks(page = 1) {
try {
const response = await fetch(`/api/webhooks?page=${page}&per_page=${perPage}`);
const data = await response.json();
if (data.webhooks && data.webhooks.length > 0) {
renderWebhooks(data.webhooks);
renderPagination(data.page, data.pages, data.total);
document.getElementById('webhooks-container').style.display = 'block';
document.getElementById('empty-state').style.display = 'none';
} else {
document.getElementById('webhooks-container').style.display = 'none';
document.getElementById('empty-state').style.display = 'block';
}
} catch (error) {
console.error('Error loading webhooks:', error);
alert('Failed to load webhooks');
} finally {
document.getElementById('loading').style.display = 'none';
}
}
function renderWebhooks(webhooks) {
const tbody = document.getElementById('webhooks-tbody');
tbody.innerHTML = '';
webhooks.forEach(webhook => {
const row = document.createElement('tr');
// Truncate URL for display
const truncatedUrl = webhook.url.length > 50 ?
webhook.url.substring(0, 47) + '...' : webhook.url;
// Format alert types
const alertTypes = webhook.alert_types && webhook.alert_types.length > 0 ?
webhook.alert_types.map(t => `<span class="badge bg-secondary me-1">${t}</span>`).join('') :
'<span class="text-muted">All</span>';
// Format severity filter
const severityFilter = webhook.severity_filter && webhook.severity_filter.length > 0 ?
webhook.severity_filter.map(s => `<span class="badge bg-${getSeverityColor(s)} me-1">${s}</span>`).join('') :
'<span class="text-muted">All</span>';
// Status badge
const statusBadge = webhook.enabled ?
'<span class="badge bg-success">Enabled</span>' :
'<span class="badge bg-secondary">Disabled</span>';
row.innerHTML = `
<td><strong>${escapeHtml(webhook.name)}</strong></td>
<td><code class="small">${escapeHtml(truncatedUrl)}</code></td>
<td>${alertTypes}</td>
<td>${severityFilter}</td>
<td>${statusBadge}</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-primary" onclick="testWebhook(${webhook.id})" title="Test">
<i class="bi bi-send"></i>
</button>
<a href="/webhooks/${webhook.id}/edit" class="btn btn-outline-primary" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<a href="/webhooks/${webhook.id}/logs" class="btn btn-outline-info" title="Logs">
<i class="bi bi-list-ul"></i>
</a>
<button class="btn btn-outline-danger" onclick="deleteWebhook(${webhook.id}, '${escapeHtml(webhook.name)}')" title="Delete">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
`;
tbody.appendChild(row);
});
}
function renderPagination(currentPage, totalPages, totalItems) {
const pagination = document.getElementById('pagination');
pagination.innerHTML = '';
if (totalPages <= 1) {
document.getElementById('pagination-container').style.display = 'none';
return;
}
document.getElementById('pagination-container').style.display = 'block';
// Previous button
const prevLi = document.createElement('li');
prevLi.className = `page-item ${currentPage === 1 ? 'disabled' : ''}`;
prevLi.innerHTML = `<a class="page-link" href="#" onclick="changePage(${currentPage - 1}); return false;">Previous</a>`;
pagination.appendChild(prevLi);
// Page numbers
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages || (i >= currentPage - 2 && i <= currentPage + 2)) {
const li = document.createElement('li');
li.className = `page-item ${i === currentPage ? 'active' : ''}`;
li.innerHTML = `<a class="page-link" href="#" onclick="changePage(${i}); return false;">${i}</a>`;
pagination.appendChild(li);
} else if (i === currentPage - 3 || i === currentPage + 3) {
const li = document.createElement('li');
li.className = 'page-item disabled';
li.innerHTML = '<a class="page-link" href="#">...</a>';
pagination.appendChild(li);
}
}
// Next button
const nextLi = document.createElement('li');
nextLi.className = `page-item ${currentPage === totalPages ? 'disabled' : ''}`;
nextLi.innerHTML = `<a class="page-link" href="#" onclick="changePage(${currentPage + 1}); return false;">Next</a>`;
pagination.appendChild(nextLi);
}
function changePage(page) {
currentPage = page;
loadWebhooks(page);
}
async function testWebhook(id) {
if (!confirm('Send a test payload to this webhook?')) return;
try {
const response = await fetch(`/api/webhooks/${id}/test`, { method: 'POST' });
const result = await response.json();
if (result.status === 'success') {
alert(`Test successful!\nHTTP ${result.status_code}\n${result.message}`);
} else {
alert(`Test failed:\n${result.message}`);
}
} catch (error) {
console.error('Error testing webhook:', error);
alert('Failed to test webhook');
}
}
async function deleteWebhook(id, name) {
if (!confirm(`Are you sure you want to delete webhook "${name}"?`)) return;
try {
const response = await fetch(`/api/webhooks/${id}`, { method: 'DELETE' });
const result = await response.json();
if (result.status === 'success') {
alert('Webhook deleted successfully');
loadWebhooks(currentPage);
} else {
alert(`Failed to delete webhook: ${result.message}`);
}
} catch (error) {
console.error('Error deleting webhook:', error);
alert('Failed to delete webhook');
}
}
function getSeverityColor(severity) {
const colors = {
'critical': 'danger',
'warning': 'warning',
'info': 'info'
};
return colors[severity] || 'secondary';
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Load webhooks on page load
document.addEventListener('DOMContentLoaded', () => {
loadWebhooks(1);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,328 @@
{% extends "base.html" %}
{% block title %}Webhook Logs - {{ webhook.name }} - SneakyScanner{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-12 mb-4">
<h1 style="color: #60a5fa;">Webhook Delivery Logs</h1>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('webhooks.list_webhooks') }}">Webhooks</a></li>
<li class="breadcrumb-item active">{{ webhook.name }}</li>
</ol>
</nav>
</div>
</div>
<!-- Webhook Info -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h5 class="card-title">{{ webhook.name }}</h5>
<p class="text-muted mb-1"><strong>URL:</strong> <code>{{ webhook.url }}</code></p>
<p class="text-muted mb-0">
<strong>Status:</strong>
{% if webhook.enabled %}
<span class="badge bg-success">Enabled</span>
{% else %}
<span class="badge bg-secondary">Disabled</span>
{% endif %}
</p>
</div>
<div class="col-md-6 text-md-end">
<a href="{{ url_for('webhooks.edit_webhook', webhook_id=webhook.id) }}" class="btn btn-primary">
<i class="bi bi-pencil"></i> Edit Webhook
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label for="status-filter" class="form-label">Status</label>
<select class="form-select" id="status-filter">
<option value="">All</option>
<option value="success">Success</option>
<option value="failed">Failed</option>
</select>
</div>
<div class="col-md-4 d-flex align-items-end">
<button type="button" class="btn btn-primary w-100" onclick="applyFilter()">
<i class="bi bi-funnel"></i> Apply Filter
</button>
</div>
<div class="col-md-4 d-flex align-items-end">
<button type="button" class="btn btn-outline-secondary w-100" onclick="refreshLogs()">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Loading indicator -->
<div id="loading" class="text-center my-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<!-- Logs table -->
<div id="logs-container" style="display: none;">
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Timestamp</th>
<th>Alert</th>
<th>Status</th>
<th>HTTP Code</th>
<th>Attempt</th>
<th>Details</th>
</tr>
</thead>
<tbody id="logs-tbody">
<!-- Populated via JavaScript -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Pagination -->
<nav aria-label="Logs pagination" id="pagination-container">
<ul class="pagination justify-content-center" id="pagination">
<!-- Populated via JavaScript -->
</ul>
</nav>
</div>
<!-- Empty state -->
<div id="empty-state" class="text-center my-5" style="display: none;">
<i class="bi bi-list-ul" style="font-size: 4rem; color: #94a3b8;"></i>
<p class="text-muted mt-3">No delivery logs yet.</p>
<p class="small text-muted">Logs will appear here after alerts trigger this webhook.</p>
</div>
<!-- Modal for log details -->
<div class="modal fade" id="logDetailModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Delivery Log Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="modal-content">
<!-- Populated via JavaScript -->
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const webhookId = {{ webhook.id }};
let currentPage = 1;
let currentStatus = '';
const perPage = 20;
async function loadLogs(page = 1, status = '') {
try {
let url = `/api/webhooks/${webhookId}/logs?page=${page}&per_page=${perPage}`;
if (status) {
url += `&status=${status}`;
}
const response = await fetch(url);
const data = await response.json();
if (data.logs && data.logs.length > 0) {
renderLogs(data.logs);
renderPagination(data.page, data.pages, data.total);
document.getElementById('logs-container').style.display = 'block';
document.getElementById('empty-state').style.display = 'none';
} else {
document.getElementById('logs-container').style.display = 'none';
document.getElementById('empty-state').style.display = 'block';
}
} catch (error) {
console.error('Error loading logs:', error);
alert('Failed to load delivery logs');
} finally {
document.getElementById('loading').style.display = 'none';
}
}
function renderLogs(logs) {
const tbody = document.getElementById('logs-tbody');
tbody.innerHTML = '';
logs.forEach(log => {
const row = document.createElement('tr');
// Format timestamp
const timestamp = new Date(log.delivered_at).toLocaleString();
// Status badge
const statusBadge = log.status === 'success' ?
'<span class="badge bg-success">Success</span>' :
'<span class="badge bg-danger">Failed</span>';
// HTTP code badge
const httpBadge = log.response_code ?
`<span class="badge ${log.response_code < 400 ? 'bg-success' : 'bg-danger'}">${log.response_code}</span>` :
'<span class="text-muted">N/A</span>';
// Alert info
const alertInfo = log.alert_type ?
`<span class="badge bg-secondary">${log.alert_type}</span><br><small class="text-muted">${escapeHtml(log.alert_message || '')}</small>` :
`<small class="text-muted">Alert #${log.alert_id}</small>`;
row.innerHTML = `
<td><small>${timestamp}</small></td>
<td>${alertInfo}</td>
<td>${statusBadge}</td>
<td>${httpBadge}</td>
<td>${log.attempt_number || 1}</td>
<td>
<button class="btn btn-sm btn-outline-primary" onclick="showLogDetails(${JSON.stringify(log).replace(/"/g, '&quot;')})">
<i class="bi bi-eye"></i> View
</button>
</td>
`;
tbody.appendChild(row);
});
}
function renderPagination(currentPage, totalPages, totalItems) {
const pagination = document.getElementById('pagination');
pagination.innerHTML = '';
if (totalPages <= 1) {
document.getElementById('pagination-container').style.display = 'none';
return;
}
document.getElementById('pagination-container').style.display = 'block';
// Previous button
const prevLi = document.createElement('li');
prevLi.className = `page-item ${currentPage === 1 ? 'disabled' : ''}`;
prevLi.innerHTML = `<a class="page-link" href="#" onclick="changePage(${currentPage - 1}); return false;">Previous</a>`;
pagination.appendChild(prevLi);
// Page numbers
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages || (i >= currentPage - 2 && i <= currentPage + 2)) {
const li = document.createElement('li');
li.className = `page-item ${i === currentPage ? 'active' : ''}`;
li.innerHTML = `<a class="page-link" href="#" onclick="changePage(${i}); return false;">${i}</a>`;
pagination.appendChild(li);
} else if (i === currentPage - 3 || i === currentPage + 3) {
const li = document.createElement('li');
li.className = 'page-item disabled';
li.innerHTML = '<a class="page-link" href="#">...</a>';
pagination.appendChild(li);
}
}
// Next button
const nextLi = document.createElement('li');
nextLi.className = `page-item ${currentPage === totalPages ? 'disabled' : ''}`;
nextLi.innerHTML = `<a class="page-link" href="#" onclick="changePage(${currentPage + 1}); return false;">Next</a>`;
pagination.appendChild(nextLi);
}
function changePage(page) {
currentPage = page;
loadLogs(page, currentStatus);
}
function applyFilter() {
currentStatus = document.getElementById('status-filter').value;
currentPage = 1;
loadLogs(1, currentStatus);
}
function refreshLogs() {
loadLogs(currentPage, currentStatus);
}
function showLogDetails(log) {
const modalContent = document.getElementById('modal-content');
let detailsHTML = `
<div class="mb-3">
<strong>Log ID:</strong> ${log.id}<br>
<strong>Alert ID:</strong> ${log.alert_id}<br>
<strong>Status:</strong> <span class="badge ${log.status === 'success' ? 'bg-success' : 'bg-danger'}">${log.status}</span><br>
<strong>HTTP Code:</strong> ${log.response_code || 'N/A'}<br>
<strong>Attempt:</strong> ${log.attempt_number || 1}<br>
<strong>Delivered At:</strong> ${new Date(log.delivered_at).toLocaleString()}
</div>
`;
if (log.response_body) {
detailsHTML += `
<div class="mb-3">
<strong>Response Body:</strong>
<pre class="bg-dark text-light p-2 rounded mt-2"><code>${escapeHtml(log.response_body)}</code></pre>
</div>
`;
}
if (log.error_message) {
detailsHTML += `
<div class="mb-3">
<strong>Error Message:</strong>
<div class="alert alert-danger mt-2">${escapeHtml(log.error_message)}</div>
</div>
`;
}
modalContent.innerHTML = detailsHTML;
const modal = new bootstrap.Modal(document.getElementById('logDetailModal'));
modal.show();
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Load logs on page load
document.addEventListener('DOMContentLoaded', () => {
loadLogs(1);
});
</script>
{% endblock %}