From 230094d7b27f02b4feace413c46995b1a41c0456 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Tue, 18 Nov 2025 15:29:23 -0600 Subject: [PATCH] webhook templates --- .../versions/005_add_webhook_templates.py | 83 +++++ app/web/api/webhooks.py | 177 +++++++++++ app/web/models.py | 3 + app/web/services/template_service.py | 294 ++++++++++++++++++ app/web/services/webhook_service.py | 189 +++++++++-- .../templates/webhook_presets/custom_json.j2 | 9 + .../templates/webhook_presets/default_json.j2 | 25 ++ app/web/templates/webhook_presets/discord.j2 | 41 +++ app/web/templates/webhook_presets/gotify.j2 | 13 + app/web/templates/webhook_presets/ntfy.j2 | 10 + .../templates/webhook_presets/plain_text.j2 | 27 ++ .../templates/webhook_presets/presets.json | 65 ++++ app/web/templates/webhook_presets/slack.j2 | 60 ++++ 13 files changed, 965 insertions(+), 31 deletions(-) create mode 100644 app/migrations/versions/005_add_webhook_templates.py create mode 100644 app/web/services/template_service.py create mode 100644 app/web/templates/webhook_presets/custom_json.j2 create mode 100644 app/web/templates/webhook_presets/default_json.j2 create mode 100644 app/web/templates/webhook_presets/discord.j2 create mode 100644 app/web/templates/webhook_presets/gotify.j2 create mode 100644 app/web/templates/webhook_presets/ntfy.j2 create mode 100644 app/web/templates/webhook_presets/plain_text.j2 create mode 100644 app/web/templates/webhook_presets/presets.json create mode 100644 app/web/templates/webhook_presets/slack.j2 diff --git a/app/migrations/versions/005_add_webhook_templates.py b/app/migrations/versions/005_add_webhook_templates.py new file mode 100644 index 0000000..6a6aff6 --- /dev/null +++ b/app/migrations/versions/005_add_webhook_templates.py @@ -0,0 +1,83 @@ +"""Add webhook template support + +Revision ID: 005 +Revises: 004 +Create Date: 2025-11-18 + +""" +from alembic import op +import sqlalchemy as sa +import json + + +# revision identifiers, used by Alembic +revision = '005' +down_revision = '004' +branch_labels = None +depends_on = None + + +# Default template that matches the current JSON payload structure +DEFAULT_TEMPLATE = """{ + "event": "alert.created", + "alert": { + "id": {{ alert.id }}, + "type": "{{ alert.type }}", + "severity": "{{ alert.severity }}", + "message": "{{ alert.message }}", + {% if alert.ip_address %}"ip_address": "{{ alert.ip_address }}",{% endif %} + {% if alert.port %}"port": {{ alert.port }},{% endif %} + "acknowledged": {{ alert.acknowledged|lower }}, + "created_at": "{{ alert.created_at.isoformat() }}" + }, + "scan": { + "id": {{ scan.id }}, + "title": "{{ scan.title }}", + "timestamp": "{{ scan.timestamp.isoformat() }}", + "status": "{{ scan.status }}" + }, + "rule": { + "id": {{ rule.id }}, + "name": "{{ rule.name }}", + "type": "{{ rule.type }}", + "threshold": {{ rule.threshold if rule.threshold else 'null' }} + } +}""" + + +def upgrade(): + """ + Add webhook template fields: + - template: Jinja2 template for payload + - template_format: Output format (json, text) + - content_type_override: Optional custom Content-Type + """ + + # Add new columns to webhooks table + with op.batch_alter_table('webhooks') as batch_op: + batch_op.add_column(sa.Column('template', sa.Text(), nullable=True, comment='Jinja2 template for webhook payload')) + batch_op.add_column(sa.Column('template_format', sa.String(20), nullable=True, server_default='json', comment='Template output format: json, text')) + batch_op.add_column(sa.Column('content_type_override', sa.String(100), nullable=True, comment='Optional custom Content-Type header')) + + # Populate existing webhooks with default template + # This ensures backward compatibility by converting existing webhooks to use the + # same JSON structure they're currently sending + connection = op.get_bind() + connection.execute( + sa.text(""" + UPDATE webhooks + SET template = :template, + template_format = 'json' + WHERE template IS NULL + """), + {"template": DEFAULT_TEMPLATE} + ) + + +def downgrade(): + """Remove webhook template fields.""" + + with op.batch_alter_table('webhooks') as batch_op: + batch_op.drop_column('content_type_override') + batch_op.drop_column('template_format') + batch_op.drop_column('template') diff --git a/app/web/api/webhooks.py b/app/web/api/webhooks.py index 23984eb..496a78b 100644 --- a/app/web/api/webhooks.py +++ b/app/web/api/webhooks.py @@ -11,6 +11,7 @@ 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 +from web.services.template_service import get_template_service bp = Blueprint('webhooks_api', __name__) @@ -144,6 +145,9 @@ def create_webhook(): severity_filter: Array of severities to filter timeout: Request timeout in seconds (default: 10) retry_count: Number of retry attempts (default: 3) + template: Jinja2 template for custom payload (optional) + template_format: Template format - 'json' or 'text' (default: json) + content_type_override: Custom Content-Type header (optional) Returns: JSON response with created webhook @@ -172,6 +176,26 @@ def create_webhook(): 'message': f'Invalid auth_type. Must be one of: {", ".join(valid_auth_types)}' }), 400 + # Validate template_format + valid_template_formats = ['json', 'text'] + template_format = data.get('template_format', 'json') + if template_format not in valid_template_formats: + return jsonify({ + 'status': 'error', + 'message': f'Invalid template_format. Must be one of: {", ".join(valid_template_formats)}' + }), 400 + + # Validate template if provided + template = data.get('template') + if template: + template_service = get_template_service() + is_valid, error_msg = template_service.validate_template(template, template_format) + if not is_valid: + return jsonify({ + 'status': 'error', + 'message': f'Invalid template: {error_msg}' + }), 400 + try: webhook_service = WebhookService(current_app.db_session) @@ -197,6 +221,9 @@ def create_webhook(): severity_filter=severity_filter, timeout=data.get('timeout', 10), retry_count=data.get('retry_count', 3), + template=template, + template_format=template_format, + content_type_override=data.get('content_type_override'), created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc) ) @@ -223,6 +250,9 @@ def create_webhook(): 'custom_headers': custom_headers_parsed, 'timeout': webhook.timeout, 'retry_count': webhook.retry_count, + 'template': webhook.template, + 'template_format': webhook.template_format, + 'content_type_override': webhook.content_type_override, 'created_at': webhook.created_at.isoformat() } }), 201 @@ -255,6 +285,9 @@ def update_webhook(webhook_id): severity_filter: Array of severities timeout: Request timeout retry_count: Retry attempts + template: Jinja2 template for custom payload + template_format: Template format - 'json' or 'text' + content_type_override: Custom Content-Type header Returns: JSON response with update status @@ -278,6 +311,26 @@ def update_webhook(webhook_id): 'message': f'Invalid auth_type. Must be one of: {", ".join(valid_auth_types)}' }), 400 + # Validate template_format if provided + if 'template_format' in data: + valid_template_formats = ['json', 'text'] + if data['template_format'] not in valid_template_formats: + return jsonify({ + 'status': 'error', + 'message': f'Invalid template_format. Must be one of: {", ".join(valid_template_formats)}' + }), 400 + + # Validate template if provided + if 'template' in data and data['template']: + template_format = data.get('template_format', webhook.template_format or 'json') + template_service = get_template_service() + is_valid, error_msg = template_service.validate_template(data['template'], template_format) + if not is_valid: + return jsonify({ + 'status': 'error', + 'message': f'Invalid template: {error_msg}' + }), 400 + try: webhook_service = WebhookService(current_app.db_session) @@ -303,6 +356,12 @@ def update_webhook(webhook_id): webhook.timeout = data['timeout'] if 'retry_count' in data: webhook.retry_count = data['retry_count'] + if 'template' in data: + webhook.template = data['template'] + if 'template_format' in data: + webhook.template_format = data['template_format'] + if 'content_type_override' in data: + webhook.content_type_override = data['content_type_override'] webhook.updated_at = datetime.now(timezone.utc) current_app.db_session.commit() @@ -326,6 +385,9 @@ def update_webhook(webhook_id): 'custom_headers': custom_headers, 'timeout': webhook.timeout, 'retry_count': webhook.retry_count, + 'template': webhook.template, + 'template_format': webhook.template_format, + 'content_type_override': webhook.content_type_override, 'updated_at': webhook.updated_at.isoformat() } }) @@ -484,6 +546,121 @@ def get_webhook_logs(webhook_id): }) +@bp.route('/preview-template', methods=['POST']) +@api_auth_required +def preview_template(): + """ + Preview a webhook template with sample data. + + Request body: + template: Jinja2 template string (required) + template_format: Template format - 'json' or 'text' (default: json) + + Returns: + JSON response with rendered template preview + """ + data = request.get_json() or {} + + if not data.get('template'): + return jsonify({ + 'status': 'error', + 'message': 'template is required' + }), 400 + + template = data['template'] + template_format = data.get('template_format', 'json') + + # Validate template format + if template_format not in ['json', 'text']: + return jsonify({ + 'status': 'error', + 'message': 'Invalid template_format. Must be json or text' + }), 400 + + try: + template_service = get_template_service() + + # Validate template + is_valid, error_msg = template_service.validate_template(template, template_format) + if not is_valid: + return jsonify({ + 'status': 'error', + 'message': f'Template validation error: {error_msg}' + }), 400 + + # Render with sample data + rendered, error = template_service.render_test_payload(template, template_format) + + if error: + return jsonify({ + 'status': 'error', + 'message': f'Template rendering error: {error}' + }), 400 + + return jsonify({ + 'status': 'success', + 'rendered': rendered, + 'format': template_format + }) + + except Exception as e: + return jsonify({ + 'status': 'error', + 'message': f'Failed to preview template: {str(e)}' + }), 500 + + +@bp.route('/template-presets', methods=['GET']) +@api_auth_required +def get_template_presets(): + """ + Get list of available webhook template presets. + + Returns: + JSON response with template presets + """ + import os + + try: + # Load presets manifest + presets_file = os.path.join( + os.path.dirname(__file__), + '../templates/webhook_presets/presets.json' + ) + + with open(presets_file, 'r') as f: + presets_manifest = json.load(f) + + # Load template contents for each preset + presets_dir = os.path.join( + os.path.dirname(__file__), + '../templates/webhook_presets' + ) + + for preset in presets_manifest: + template_file = os.path.join(presets_dir, preset['file']) + with open(template_file, 'r') as f: + preset['template'] = f.read() + # Remove file reference from response + del preset['file'] + + return jsonify({ + 'status': 'success', + 'presets': presets_manifest + }) + + except FileNotFoundError as e: + return jsonify({ + 'status': 'error', + 'message': f'Template presets not found: {str(e)}' + }), 500 + except Exception as e: + return jsonify({ + 'status': 'error', + 'message': f'Failed to load template presets: {str(e)}' + }), 500 + + # Health check endpoint @bp.route('/health', methods=['GET']) def health_check(): diff --git a/app/web/models.py b/app/web/models.py index ae4a90c..7136aff 100644 --- a/app/web/models.py +++ b/app/web/models.py @@ -361,6 +361,9 @@ class Webhook(Base): severity_filter = Column(Text, nullable=True, comment="JSON array of severities to trigger on") timeout = Column(Integer, nullable=True, default=10, comment="Request timeout in seconds") retry_count = Column(Integer, nullable=True, default=3, comment="Number of retry attempts") + template = Column(Text, nullable=True, comment="Jinja2 template for webhook payload") + template_format = Column(String(20), nullable=True, default='json', comment="Template output format: json, text") + content_type_override = Column(String(100), nullable=True, comment="Optional custom Content-Type header") created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Creation time") updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Last update time") diff --git a/app/web/services/template_service.py b/app/web/services/template_service.py new file mode 100644 index 0000000..c200a82 --- /dev/null +++ b/app/web/services/template_service.py @@ -0,0 +1,294 @@ +""" +Webhook Template Service + +Provides Jinja2 template rendering for webhook payloads with a sandboxed +environment and comprehensive context building from scan/alert/rule data. +""" + +from jinja2 import Environment, BaseLoader, TemplateError, meta +from jinja2.sandbox import SandboxedEnvironment +import json +from typing import Dict, Any, Optional, Tuple +from datetime import datetime + + +class TemplateService: + """ + Service for rendering webhook templates safely using Jinja2. + + Features: + - Sandboxed Jinja2 environment to prevent code execution + - Rich context with alert, scan, rule, service, cert data + - Support for both JSON and text output formats + - Template validation and error handling + """ + + def __init__(self): + """Initialize the sandboxed Jinja2 environment.""" + self.env = SandboxedEnvironment( + loader=BaseLoader(), + autoescape=False, # We control the output format + trim_blocks=True, + lstrip_blocks=True + ) + + # Add custom filters + self.env.filters['isoformat'] = self._isoformat_filter + + def _isoformat_filter(self, value): + """Custom filter to convert datetime to ISO format.""" + if isinstance(value, datetime): + return value.isoformat() + return str(value) + + def build_context( + self, + alert, + scan, + rule, + app_name: str = "SneakyScanner", + app_version: str = "1.0.0", + app_url: str = "https://github.com/sneakygeek/SneakyScan" + ) -> Dict[str, Any]: + """ + Build the template context from alert, scan, and rule objects. + + Args: + alert: Alert model instance + scan: Scan model instance + rule: AlertRule model instance + app_name: Application name + app_version: Application version + app_url: Application repository URL + + Returns: + Dictionary with all available template variables + """ + context = { + "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, + "acknowledged_at": alert.acknowledged_at, + "acknowledged_by": alert.acknowledged_by, + "created_at": alert.created_at, + "email_sent": alert.email_sent, + "email_sent_at": alert.email_sent_at, + "webhook_sent": alert.webhook_sent, + "webhook_sent_at": alert.webhook_sent_at, + }, + "scan": { + "id": scan.id, + "title": scan.title, + "timestamp": scan.timestamp, + "duration": scan.duration, + "status": scan.status, + "config_file": scan.config_file, + "triggered_by": scan.triggered_by, + "started_at": scan.started_at, + "completed_at": scan.completed_at, + "error_message": scan.error_message, + }, + "rule": { + "id": rule.id, + "name": rule.name, + "type": rule.rule_type, + "threshold": rule.threshold, + "severity": rule.severity, + "enabled": rule.enabled, + }, + "app": { + "name": app_name, + "version": app_version, + "url": app_url, + }, + "timestamp": datetime.utcnow(), + } + + # Add service information if available (for service-related alerts) + # This would require additional context from the caller + # For now, we'll add placeholder support + context["service"] = None + context["cert"] = None + context["tls"] = None + + return context + + def render( + self, + template_string: str, + context: Dict[str, Any], + template_format: str = 'json' + ) -> Tuple[str, Optional[str]]: + """ + Render a template with the given context. + + Args: + template_string: The Jinja2 template string + context: Template context dictionary + template_format: Output format ('json' or 'text') + + Returns: + Tuple of (rendered_output, error_message) + - If successful: (rendered_string, None) + - If failed: (None, error_message) + """ + try: + template = self.env.from_string(template_string) + rendered = template.render(context) + + # For JSON format, validate that the output is valid JSON + if template_format == 'json': + try: + # Parse to validate JSON structure + json.loads(rendered) + except json.JSONDecodeError as e: + return None, f"Template rendered invalid JSON: {str(e)}" + + return rendered, None + + except TemplateError as e: + return None, f"Template rendering error: {str(e)}" + except Exception as e: + return None, f"Unexpected error rendering template: {str(e)}" + + def validate_template( + self, + template_string: str, + template_format: str = 'json' + ) -> Tuple[bool, Optional[str]]: + """ + Validate a template without rendering it. + + Args: + template_string: The Jinja2 template string to validate + template_format: Expected output format ('json' or 'text') + + Returns: + Tuple of (is_valid, error_message) + - If valid: (True, None) + - If invalid: (False, error_message) + """ + try: + # Parse the template to check syntax + self.env.parse(template_string) + + # For JSON templates, check if it looks like valid JSON structure + # (this is a basic check - full validation happens during render) + if template_format == 'json': + # Just check for basic JSON structure markers + stripped = template_string.strip() + if not (stripped.startswith('{') or stripped.startswith('[')): + return False, "JSON template must start with { or [" + + return True, None + + except TemplateError as e: + return False, f"Template syntax error: {str(e)}" + except Exception as e: + return False, f"Template validation error: {str(e)}" + + def get_template_variables(self, template_string: str) -> set: + """ + Extract all variables used in a template. + + Args: + template_string: The Jinja2 template string + + Returns: + Set of variable names used in the template + """ + try: + ast = self.env.parse(template_string) + return meta.find_undeclared_variables(ast) + except Exception: + return set() + + def render_test_payload( + self, + template_string: str, + template_format: str = 'json' + ) -> Tuple[str, Optional[str]]: + """ + Render a template with sample/test data for preview purposes. + + Args: + template_string: The Jinja2 template string + template_format: Output format ('json' or 'text') + + Returns: + Tuple of (rendered_output, error_message) + """ + # Create sample context data + sample_context = { + "alert": { + "id": 123, + "type": "unexpected_port", + "severity": "warning", + "message": "Unexpected port 8080 found open on 192.168.1.100", + "ip_address": "192.168.1.100", + "port": 8080, + "acknowledged": False, + "acknowledged_at": None, + "acknowledged_by": None, + "created_at": datetime.utcnow(), + "email_sent": False, + "email_sent_at": None, + "webhook_sent": False, + "webhook_sent_at": None, + }, + "scan": { + "id": 456, + "title": "Production Infrastructure Scan", + "timestamp": datetime.utcnow(), + "duration": 125.5, + "status": "completed", + "config_file": "production-scan.yaml", + "triggered_by": "schedule", + "started_at": datetime.utcnow(), + "completed_at": datetime.utcnow(), + "error_message": None, + }, + "rule": { + "id": 789, + "name": "Unexpected Port Detection", + "type": "unexpected_port", + "threshold": None, + "severity": "warning", + "enabled": True, + }, + "service": { + "name": "http", + "product": "nginx", + "version": "1.20.0", + }, + "cert": { + "subject": "CN=example.com", + "issuer": "CN=Let's Encrypt Authority X3", + "days_until_expiry": 15, + }, + "app": { + "name": "SneakyScanner", + "version": "1.0.0-phase5", + "url": "https://github.com/sneakygeek/SneakyScan", + }, + "timestamp": datetime.utcnow(), + } + + return self.render(template_string, sample_context, template_format) + + +# Singleton instance +_template_service = None + + +def get_template_service() -> TemplateService: + """Get the singleton TemplateService instance.""" + global _template_service + if _template_service is None: + _template_service = TemplateService() + return _template_service diff --git a/app/web/services/webhook_service.py b/app/web/services/webhook_service.py index 2130029..98b0ae3 100644 --- a/app/web/services/webhook_service.py +++ b/app/web/services/webhook_service.py @@ -9,7 +9,7 @@ import json import logging import time from datetime import datetime, timezone -from typing import List, Dict, Optional, Any +from typing import List, Dict, Optional, Any, Tuple from sqlalchemy.orm import Session import requests from requests.auth import HTTPBasicAuth @@ -18,6 +18,8 @@ from cryptography.fernet import Fernet import os from ..models import Webhook, WebhookDeliveryLog, Alert, AlertRule, Scan +from .template_service import get_template_service +from ..config import APP_NAME, APP_VERSION, REPO_URL logger = logging.getLogger(__name__) @@ -178,11 +180,11 @@ class WebhookService: logger.info(f"Delivering webhook {webhook_id} for alert {alert_id} (attempt {attempt_number}/{webhook.retry_count})") - # Build payload - payload = self._build_payload(alert) + # Build payload with template support + payload, content_type = self._build_payload(webhook, alert) # Prepare headers - headers = {'Content-Type': 'application/json'} + headers = {'Content-Type': content_type} # Add custom headers if provided if webhook.custom_headers: @@ -207,13 +209,26 @@ class WebhookService: # Execute HTTP request try: timeout = webhook.timeout or 10 - response = requests.post( - webhook.url, - json=payload, - headers=headers, - auth=auth, - timeout=timeout - ) + + # Use appropriate parameter based on payload type + if isinstance(payload, dict): + # JSON payload + response = requests.post( + webhook.url, + json=payload, + headers=headers, + auth=auth, + timeout=timeout + ) + else: + # Text payload + response = requests.post( + webhook.url, + data=payload, + headers=headers, + auth=auth, + timeout=timeout + ) # Log delivery attempt log_entry = WebhookDeliveryLog( @@ -305,15 +320,18 @@ class WebhookService: # 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]: + def _build_payload(self, webhook: Webhook, alert: Alert) -> Tuple[Any, str]: """ - Build JSON payload for webhook delivery. + Build payload for webhook delivery using template if configured. Args: + webhook: Webhook object with optional template configuration alert: Alert object Returns: - Dict containing alert details in generic JSON format + Tuple of (payload, content_type): + - payload: Rendered payload (dict for JSON, string for text) + - content_type: Content-Type header value """ # Get related scan scan = self.db.query(Scan).filter(Scan.id == alert.scan_id).first() @@ -321,6 +339,65 @@ class WebhookService: # Get related rule rule = self.db.query(AlertRule).filter(AlertRule.id == alert.rule_id).first() + # If webhook has a custom template, use it + if webhook.template: + template_service = get_template_service() + context = template_service.build_context( + alert=alert, + scan=scan, + rule=rule, + app_name=APP_NAME, + app_version=APP_VERSION, + app_url=REPO_URL + ) + + rendered, error = template_service.render( + webhook.template, + context, + webhook.template_format or 'json' + ) + + if error: + logger.error(f"Template rendering error for webhook {webhook.id}: {error}") + # Fall back to default payload + return self._build_default_payload(alert, scan, rule), 'application/json' + + # Determine content type + if webhook.content_type_override: + content_type = webhook.content_type_override + elif webhook.template_format == 'text': + content_type = 'text/plain' + else: + content_type = 'application/json' + + # For JSON format, parse the rendered string back to a dict + # For text format, return as string + if webhook.template_format == 'json': + try: + payload = json.loads(rendered) + except json.JSONDecodeError: + logger.error(f"Failed to parse rendered JSON template for webhook {webhook.id}") + return self._build_default_payload(alert, scan, rule), 'application/json' + else: + payload = rendered + + return payload, content_type + + # No template - use default payload + return self._build_default_payload(alert, scan, rule), 'application/json' + + def _build_default_payload(self, alert: Alert, scan: Optional[Scan], rule: Optional[AlertRule]) -> Dict[str, Any]: + """ + Build default JSON payload for webhook delivery. + + Args: + alert: Alert object + scan: Scan object (optional) + rule: AlertRule object (optional) + + Returns: + Dict containing alert details in generic JSON format + """ payload = { "event": "alert.created", "alert": { @@ -367,19 +444,56 @@ class WebhookService: '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 + # Build test payload - use template if configured + if webhook.template: + template_service = get_template_service() + rendered, error = template_service.render_test_payload( + webhook.template, + webhook.template_format or 'json' + ) + + if error: + return { + 'success': False, + 'message': f'Template error: {error}', + 'status_code': None + } + + # Determine content type + if webhook.content_type_override: + content_type = webhook.content_type_override + elif webhook.template_format == 'text': + content_type = 'text/plain' + else: + content_type = 'application/json' + + # Parse JSON template + if webhook.template_format == 'json': + try: + payload = json.loads(rendered) + except json.JSONDecodeError: + return { + 'success': False, + 'message': 'Template rendered invalid JSON', + 'status_code': None + } + else: + payload = rendered + else: + # Default 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 + } } - } + content_type = 'application/json' # Prepare headers - headers = {'Content-Type': 'application/json'} + headers = {'Content-Type': content_type} if webhook.custom_headers: try: @@ -402,13 +516,26 @@ class WebhookService: # Execute test request try: timeout = webhook.timeout or 10 - response = requests.post( - webhook.url, - json=payload, - headers=headers, - auth=auth, - timeout=timeout - ) + + # Use appropriate parameter based on payload type + if isinstance(payload, dict): + # JSON payload + response = requests.post( + webhook.url, + json=payload, + headers=headers, + auth=auth, + timeout=timeout + ) + else: + # Text payload + response = requests.post( + webhook.url, + data=payload, + headers=headers, + auth=auth, + timeout=timeout + ) return { 'success': response.status_code < 400, diff --git a/app/web/templates/webhook_presets/custom_json.j2 b/app/web/templates/webhook_presets/custom_json.j2 new file mode 100644 index 0000000..8ddd354 --- /dev/null +++ b/app/web/templates/webhook_presets/custom_json.j2 @@ -0,0 +1,9 @@ +{ + "title": "{{ scan.title }} - {{ alert.type|title|replace('_', ' ') }}", + "message": "{{ alert.message }}{% if alert.ip_address %} on {{ alert.ip_address }}{% endif %}{% if alert.port %}:{{ alert.port }}{% endif %}", + "priority": {% if alert.severity == 'critical' %}5{% elif alert.severity == 'warning' %}3{% else %}1{% endif %}, + "severity": "{{ alert.severity }}", + "scan_id": {{ scan.id }}, + "alert_id": {{ alert.id }}, + "timestamp": "{{ timestamp.isoformat() }}" +} \ No newline at end of file diff --git a/app/web/templates/webhook_presets/default_json.j2 b/app/web/templates/webhook_presets/default_json.j2 new file mode 100644 index 0000000..20cad9f --- /dev/null +++ b/app/web/templates/webhook_presets/default_json.j2 @@ -0,0 +1,25 @@ +{ + "event": "alert.created", + "alert": { + "id": {{ alert.id }}, + "type": "{{ alert.type }}", + "severity": "{{ alert.severity }}", + "message": "{{ alert.message }}", + {% if alert.ip_address %}"ip_address": "{{ alert.ip_address }}",{% endif %} + {% if alert.port %}"port": {{ alert.port }},{% endif %} + "acknowledged": {{ alert.acknowledged|lower }}, + "created_at": "{{ alert.created_at.isoformat() }}" + }, + "scan": { + "id": {{ scan.id }}, + "title": "{{ scan.title }}", + "timestamp": "{{ scan.timestamp.isoformat() }}", + "status": "{{ scan.status }}" + }, + "rule": { + "id": {{ rule.id }}, + "name": "{{ rule.name }}", + "type": "{{ rule.type }}", + "threshold": {{ rule.threshold if rule.threshold else 'null' }} + } +} \ No newline at end of file diff --git a/app/web/templates/webhook_presets/discord.j2 b/app/web/templates/webhook_presets/discord.j2 new file mode 100644 index 0000000..72a54de --- /dev/null +++ b/app/web/templates/webhook_presets/discord.j2 @@ -0,0 +1,41 @@ +{ + "username": "SneakyScanner", + "embeds": [ + { + "title": "{{ alert.type|title|replace('_', ' ') }} Alert", + "description": "{{ alert.message }}", + "color": {% if alert.severity == 'critical' %}15158332{% elif alert.severity == 'warning' %}16776960{% else %}3447003{% endif %}, + "fields": [ + { + "name": "Severity", + "value": "{{ alert.severity|upper }}", + "inline": true + }, + { + "name": "Scan", + "value": "{{ scan.title }}", + "inline": true + }, + { + "name": "Rule", + "value": "{{ rule.name }}", + "inline": false + }{% if alert.ip_address %}, + { + "name": "IP Address", + "value": "{{ alert.ip_address }}", + "inline": true + }{% endif %}{% if alert.port %}, + { + "name": "Port", + "value": "{{ alert.port }}", + "inline": true + }{% endif %} + ], + "footer": { + "text": "Alert ID: {{ alert.id }} | Scan ID: {{ scan.id }}" + }, + "timestamp": "{{ timestamp.isoformat() }}" + } + ] +} \ No newline at end of file diff --git a/app/web/templates/webhook_presets/gotify.j2 b/app/web/templates/webhook_presets/gotify.j2 new file mode 100644 index 0000000..ae3c1bf --- /dev/null +++ b/app/web/templates/webhook_presets/gotify.j2 @@ -0,0 +1,13 @@ +{ + "title": "{{ scan.title }}", + "message": "**{{ alert.severity|upper }}**: {{ alert.message }}\n\n**Scan:** {{ scan.title }}\n**Status:** {{ scan.status }}\n**Rule:** {{ rule.name }}{% if alert.ip_address %}\n**IP:** {{ alert.ip_address }}{% endif %}{% if alert.port %}\n**Port:** {{ alert.port }}{% endif %}", + "priority": {% if alert.severity == 'critical' %}8{% elif alert.severity == 'warning' %}5{% else %}2{% endif %}, + "extras": { + "client::display": { + "contentType": "text/markdown" + }, + "alert_id": {{ alert.id }}, + "scan_id": {{ scan.id }}, + "alert_type": "{{ alert.type }}" + } +} \ No newline at end of file diff --git a/app/web/templates/webhook_presets/ntfy.j2 b/app/web/templates/webhook_presets/ntfy.j2 new file mode 100644 index 0000000..5530681 --- /dev/null +++ b/app/web/templates/webhook_presets/ntfy.j2 @@ -0,0 +1,10 @@ +{{ alert.message }} + +Scan: {{ scan.title }} +Rule: {{ rule.name }} +Severity: {{ alert.severity|upper }}{% if alert.ip_address %} +IP: {{ alert.ip_address }}{% endif %}{% if alert.port %} +Port: {{ alert.port }}{% endif %} + +Scan Status: {{ scan.status }} +Alert ID: {{ alert.id }} \ No newline at end of file diff --git a/app/web/templates/webhook_presets/plain_text.j2 b/app/web/templates/webhook_presets/plain_text.j2 new file mode 100644 index 0000000..1e0f049 --- /dev/null +++ b/app/web/templates/webhook_presets/plain_text.j2 @@ -0,0 +1,27 @@ +SNEAKYSCANNER ALERT - {{ alert.severity|upper }} + +Alert: {{ alert.message }} +Type: {{ alert.type|title|replace('_', ' ') }} +Severity: {{ alert.severity|upper }} + +Scan Information: + Title: {{ scan.title }} + Status: {{ scan.status }} + Duration: {{ scan.duration }}s + Triggered By: {{ scan.triggered_by }} + +Rule Information: + Name: {{ rule.name }} + Type: {{ rule.type }} +{% if rule.threshold %} Threshold: {{ rule.threshold }} +{% endif %} +{% if alert.ip_address %}IP Address: {{ alert.ip_address }} +{% endif %}{% if alert.port %}Port: {{ alert.port }} +{% endif %} +Alert ID: {{ alert.id }} +Scan ID: {{ scan.id }} +Timestamp: {{ timestamp.strftime('%Y-%m-%d %H:%M:%S UTC') }} + +--- +Generated by {{ app.name }} v{{ app.version }} +{{ app.url }} \ No newline at end of file diff --git a/app/web/templates/webhook_presets/presets.json b/app/web/templates/webhook_presets/presets.json new file mode 100644 index 0000000..80ee732 --- /dev/null +++ b/app/web/templates/webhook_presets/presets.json @@ -0,0 +1,65 @@ +[ + { + "id": "default_json", + "name": "Default JSON (Current Format)", + "description": "Standard webhook payload format matching the current implementation", + "format": "json", + "content_type": "application/json", + "file": "default_json.j2", + "category": "general" + }, + { + "id": "custom_json", + "name": "Custom JSON", + "description": "Flexible custom JSON format with configurable title, message, and priority fields", + "format": "json", + "content_type": "application/json", + "file": "custom_json.j2", + "category": "general" + }, + { + "id": "gotify", + "name": "Gotify", + "description": "Optimized for Gotify push notification server with markdown support", + "format": "json", + "content_type": "application/json", + "file": "gotify.j2", + "category": "service" + }, + { + "id": "ntfy", + "name": "Ntfy", + "description": "Simple text format for Ntfy pub-sub notification service", + "format": "text", + "content_type": "text/plain", + "file": "ntfy.j2", + "category": "service" + }, + { + "id": "slack", + "name": "Slack", + "description": "Rich Block Kit format for Slack webhooks with visual formatting", + "format": "json", + "content_type": "application/json", + "file": "slack.j2", + "category": "service" + }, + { + "id": "discord", + "name": "Discord", + "description": "Embedded message format for Discord webhooks with color-coded severity", + "format": "json", + "content_type": "application/json", + "file": "discord.j2", + "category": "service" + }, + { + "id": "plain_text", + "name": "Plain Text", + "description": "Simple plain text format for logging or basic notification services", + "format": "text", + "content_type": "text/plain", + "file": "plain_text.j2", + "category": "general" + } +] diff --git a/app/web/templates/webhook_presets/slack.j2 b/app/web/templates/webhook_presets/slack.j2 new file mode 100644 index 0000000..a02eeaf --- /dev/null +++ b/app/web/templates/webhook_presets/slack.j2 @@ -0,0 +1,60 @@ +{ + "text": "{{ alert.severity|upper }}: {{ alert.message }}", + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "🚨 {{ alert.severity|upper }} Alert: {{ alert.type|title|replace('_', ' ') }}" + } + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Alert:*\n{{ alert.message }}" + }, + { + "type": "mrkdwn", + "text": "*Severity:*\n{{ alert.severity|upper }}" + } + ] + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Scan:*\n{{ scan.title }}" + }, + { + "type": "mrkdwn", + "text": "*Rule:*\n{{ rule.name }}" + } + ] + }{% if alert.ip_address or alert.port %}, + { + "type": "section", + "fields": [{% if alert.ip_address %} + { + "type": "mrkdwn", + "text": "*IP Address:*\n{{ alert.ip_address }}" + }{% if alert.port %},{% endif %}{% endif %}{% if alert.port %} + { + "type": "mrkdwn", + "text": "*Port:*\n{{ alert.port }}" + }{% endif %} + ] + }{% endif %}, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "Scan ID: {{ scan.id }} | Alert ID: {{ alert.id }} | {{ timestamp.strftime('%Y-%m-%d %H:%M:%S UTC') }}" + } + ] + } + ] +} \ No newline at end of file