webhook templates
This commit is contained in:
294
app/web/services/template_service.py
Normal file
294
app/web/services/template_service.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user