"""Webhook dispatch service — outbound HTTP notifications. Supported event types: - test.validated — fired when a test reaches "validated" state - test.rejected — fired when a test reaches "rejected" state - campaign.completed — fired when a campaign is completed - campaign.started — fired when a campaign is activated - mitre.synced — fired after MITRE ATT&CK sync completes - technique.status_changed — fired when a technique's status changes - webhook.test — manual test ping from the admin UI - alert.fired — fired when an operational alert is triggered """ import hashlib import hmac import logging import uuid from datetime import datetime import requests from app.database import SessionLocal from app.models.webhook_config import WebhookConfig logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Dispatch # --------------------------------------------------------------------------- def dispatch_webhook_targeted(webhook_id: str, event_type: str, payload: dict) -> None: """Send to a single specific WebhookConfig by ID (used by alert rules). Opens its own DB session. All exceptions are caught. """ db = SessionLocal() try: wh = db.query(WebhookConfig).filter( WebhookConfig.id == webhook_id, WebhookConfig.is_active == True, # noqa: E712 ).first() if wh: _send_webhook(db, wh, event_type, payload) except Exception: logger.exception( "dispatch_webhook_targeted: error for webhook_id=%s event=%s", webhook_id, event_type, ) finally: db.close() def dispatch_webhook(event_type: str, payload: dict) -> None: """Send an outbound webhook to all active subscribers for *event_type*. Opens its own DB session so this can be called outside request context (e.g. from background jobs). All exceptions are caught; webhook failures never crash the caller. """ db = SessionLocal() try: webhooks = ( db.query(WebhookConfig) .filter(WebhookConfig.is_active == True) # noqa: E712 .all() ) for wh in webhooks: # Filter by subscribed events — empty list means "all events" subscribed: list = wh.events or [] if subscribed and event_type not in subscribed: continue _send_webhook(db, wh, event_type, payload) except Exception: logger.exception("dispatch_webhook: unexpected error for event_type=%s", event_type) finally: db.close() def _send_webhook(db, wh: WebhookConfig, event_type: str, payload: dict) -> None: """Send a single webhook POST and update its metadata.""" body = { "event": event_type, "data": payload, "timestamp": datetime.utcnow().isoformat() + "Z", } headers = {"Content-Type": "application/json"} if wh.secret: import json raw = json.dumps(body, separators=(",", ":"), sort_keys=True).encode() sig = hmac.new(wh.secret.encode(), raw, hashlib.sha256).hexdigest() headers["X-Aegis-Signature"] = f"sha256={sig}" try: resp = requests.post(wh.url, json=body, headers=headers, timeout=10) resp.raise_for_status() wh.last_triggered_at = datetime.utcnow() wh.failure_count = 0 db.commit() logger.info("Webhook '%s' (%s) dispatched OK for event=%s", wh.name, wh.url, event_type) except Exception as exc: wh.failure_count = (wh.failure_count or 0) + 1 wh.last_triggered_at = datetime.utcnow() try: db.commit() except Exception: db.rollback() logger.warning( "Webhook '%s' (%s) failed for event=%s: %s (failure_count=%d)", wh.name, wh.url, event_type, exc, wh.failure_count, ) # --------------------------------------------------------------------------- # CRUD # --------------------------------------------------------------------------- def list_webhooks(db, *, offset: int = 0, limit: int = 50) -> list[WebhookConfig]: """Return paginated webhook configs.""" return ( db.query(WebhookConfig) .order_by(WebhookConfig.created_at.desc()) .offset(offset) .limit(limit) .all() ) def get_webhook_or_raise(db, webhook_id: uuid.UUID) -> WebhookConfig: """Fetch a webhook by ID or raise 404.""" from app.domain.errors import EntityNotFoundError wh = db.query(WebhookConfig).filter(WebhookConfig.id == webhook_id).first() if wh is None: raise EntityNotFoundError("WebhookConfig", str(webhook_id)) return wh def create_webhook(db, created_by: uuid.UUID, payload) -> WebhookConfig: """Create and persist a new WebhookConfig.""" wh = WebhookConfig( name=payload.name, url=payload.url, secret=payload.secret, events=payload.events, is_active=payload.is_active, created_by=created_by, ) db.add(wh) db.flush() return wh def update_webhook(db, webhook_id: uuid.UUID, payload) -> WebhookConfig: """Apply a partial update to an existing WebhookConfig.""" wh = get_webhook_or_raise(db, webhook_id) update_data = payload.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(wh, field, value) db.flush() return wh def delete_webhook(db, webhook_id: uuid.UUID) -> None: """Hard-delete a WebhookConfig.""" wh = get_webhook_or_raise(db, webhook_id) db.delete(wh) db.flush()