webhook templates
This commit is contained in:
83
app/migrations/versions/005_add_webhook_templates.py
Normal file
83
app/migrations/versions/005_add_webhook_templates.py
Normal 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')
|
||||||
@@ -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():
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
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 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,
|
||||||
|
|||||||
9
app/web/templates/webhook_presets/custom_json.j2
Normal file
9
app/web/templates/webhook_presets/custom_json.j2
Normal 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() }}"
|
||||||
|
}
|
||||||
25
app/web/templates/webhook_presets/default_json.j2
Normal file
25
app/web/templates/webhook_presets/default_json.j2
Normal 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' }}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/web/templates/webhook_presets/discord.j2
Normal file
41
app/web/templates/webhook_presets/discord.j2
Normal 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() }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
13
app/web/templates/webhook_presets/gotify.j2
Normal file
13
app/web/templates/webhook_presets/gotify.j2
Normal 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 }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
10
app/web/templates/webhook_presets/ntfy.j2
Normal file
10
app/web/templates/webhook_presets/ntfy.j2
Normal 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 }}
|
||||||
27
app/web/templates/webhook_presets/plain_text.j2
Normal file
27
app/web/templates/webhook_presets/plain_text.j2
Normal 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 }}
|
||||||
65
app/web/templates/webhook_presets/presets.json
Normal file
65
app/web/templates/webhook_presets/presets.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
60
app/web/templates/webhook_presets/slack.j2
Normal file
60
app/web/templates/webhook_presets/slack.j2
Normal 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') }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user