webhook templates

This commit is contained in:
2025-11-18 15:29:23 -06:00
parent 28b32a2049
commit 230094d7b2
13 changed files with 965 additions and 31 deletions

View File

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

View File

@@ -11,6 +11,7 @@ from flask import Blueprint, jsonify, request, current_app
from web.auth.decorators import api_auth_required from web.auth.decorators import api_auth_required
from web.models import Webhook, WebhookDeliveryLog, Alert from web.models import Webhook, WebhookDeliveryLog, Alert
from web.services.webhook_service import WebhookService from web.services.webhook_service import WebhookService
from web.services.template_service import get_template_service
bp = Blueprint('webhooks_api', __name__) bp = Blueprint('webhooks_api', __name__)
@@ -144,6 +145,9 @@ def create_webhook():
severity_filter: Array of severities to filter severity_filter: Array of severities to filter
timeout: Request timeout in seconds (default: 10) timeout: Request timeout in seconds (default: 10)
retry_count: Number of retry attempts (default: 3) 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: Returns:
JSON response with created webhook 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)}' 'message': f'Invalid auth_type. Must be one of: {", ".join(valid_auth_types)}'
}), 400 }), 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: try:
webhook_service = WebhookService(current_app.db_session) webhook_service = WebhookService(current_app.db_session)
@@ -197,6 +221,9 @@ def create_webhook():
severity_filter=severity_filter, severity_filter=severity_filter,
timeout=data.get('timeout', 10), timeout=data.get('timeout', 10),
retry_count=data.get('retry_count', 3), 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), created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc) updated_at=datetime.now(timezone.utc)
) )
@@ -223,6 +250,9 @@ def create_webhook():
'custom_headers': custom_headers_parsed, 'custom_headers': custom_headers_parsed,
'timeout': webhook.timeout, 'timeout': webhook.timeout,
'retry_count': webhook.retry_count, '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() 'created_at': webhook.created_at.isoformat()
} }
}), 201 }), 201
@@ -255,6 +285,9 @@ def update_webhook(webhook_id):
severity_filter: Array of severities severity_filter: Array of severities
timeout: Request timeout timeout: Request timeout
retry_count: Retry attempts 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: Returns:
JSON response with update status 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)}' 'message': f'Invalid auth_type. Must be one of: {", ".join(valid_auth_types)}'
}), 400 }), 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: try:
webhook_service = WebhookService(current_app.db_session) webhook_service = WebhookService(current_app.db_session)
@@ -303,6 +356,12 @@ def update_webhook(webhook_id):
webhook.timeout = data['timeout'] webhook.timeout = data['timeout']
if 'retry_count' in data: if 'retry_count' in data:
webhook.retry_count = data['retry_count'] 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) webhook.updated_at = datetime.now(timezone.utc)
current_app.db_session.commit() current_app.db_session.commit()
@@ -326,6 +385,9 @@ def update_webhook(webhook_id):
'custom_headers': custom_headers, 'custom_headers': custom_headers,
'timeout': webhook.timeout, 'timeout': webhook.timeout,
'retry_count': webhook.retry_count, '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() '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 # Health check endpoint
@bp.route('/health', methods=['GET']) @bp.route('/health', methods=['GET'])
def health_check(): def health_check():

View File

@@ -361,6 +361,9 @@ class Webhook(Base):
severity_filter = Column(Text, nullable=True, comment="JSON array of severities to trigger on") 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") 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") 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") 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") updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Last update time")

View File

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

View File

@@ -9,7 +9,7 @@ import json
import logging import logging
import time import time
from datetime import datetime, timezone 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 from sqlalchemy.orm import Session
import requests import requests
from requests.auth import HTTPBasicAuth from requests.auth import HTTPBasicAuth
@@ -18,6 +18,8 @@ from cryptography.fernet import Fernet
import os import os
from ..models import Webhook, WebhookDeliveryLog, Alert, AlertRule, Scan 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__) 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})") logger.info(f"Delivering webhook {webhook_id} for alert {alert_id} (attempt {attempt_number}/{webhook.retry_count})")
# Build payload # Build payload with template support
payload = self._build_payload(alert) payload, content_type = self._build_payload(webhook, alert)
# Prepare headers # Prepare headers
headers = {'Content-Type': 'application/json'} headers = {'Content-Type': content_type}
# Add custom headers if provided # Add custom headers if provided
if webhook.custom_headers: if webhook.custom_headers:
@@ -207,13 +209,26 @@ class WebhookService:
# Execute HTTP request # Execute HTTP request
try: try:
timeout = webhook.timeout or 10 timeout = webhook.timeout or 10
response = requests.post(
webhook.url, # Use appropriate parameter based on payload type
json=payload, if isinstance(payload, dict):
headers=headers, # JSON payload
auth=auth, response = requests.post(
timeout=timeout 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 delivery attempt
log_entry = WebhookDeliveryLog( log_entry = WebhookDeliveryLog(
@@ -305,15 +320,18 @@ class WebhookService:
# Exponential backoff: 2^attempt seconds (2, 4, 8, 16...) # Exponential backoff: 2^attempt seconds (2, 4, 8, 16...)
return min(2 ** attempt_number, 60) # Cap at 60 seconds 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: Args:
webhook: Webhook object with optional template configuration
alert: Alert object alert: Alert object
Returns: 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 # Get related scan
scan = self.db.query(Scan).filter(Scan.id == alert.scan_id).first() scan = self.db.query(Scan).filter(Scan.id == alert.scan_id).first()
@@ -321,6 +339,65 @@ class WebhookService:
# Get related rule # Get related rule
rule = self.db.query(AlertRule).filter(AlertRule.id == alert.rule_id).first() 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 = { payload = {
"event": "alert.created", "event": "alert.created",
"alert": { "alert": {
@@ -367,19 +444,56 @@ class WebhookService:
'status_code': None 'status_code': None
} }
# Build test payload # Build test payload - use template if configured
payload = { if webhook.template:
"event": "webhook.test", template_service = get_template_service()
"message": "This is a test webhook from SneakyScanner", rendered, error = template_service.render_test_payload(
"timestamp": datetime.now(timezone.utc).isoformat(), webhook.template,
"webhook": { webhook.template_format or 'json'
"id": webhook.id, )
"name": webhook.name
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 # Prepare headers
headers = {'Content-Type': 'application/json'} headers = {'Content-Type': content_type}
if webhook.custom_headers: if webhook.custom_headers:
try: try:
@@ -402,13 +516,26 @@ class WebhookService:
# Execute test request # Execute test request
try: try:
timeout = webhook.timeout or 10 timeout = webhook.timeout or 10
response = requests.post(
webhook.url, # Use appropriate parameter based on payload type
json=payload, if isinstance(payload, dict):
headers=headers, # JSON payload
auth=auth, response = requests.post(
timeout=timeout 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 { return {
'success': response.status_code < 400, 'success': response.status_code < 400,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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