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