Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Root cause: Microsoft Teams Incoming Webhooks require MessageCard JSON
format. The service was sending generic Aegis JSON which Teams rejected
with a 400, incrementing failure_count on every dispatch.
Fix: _send_webhook() now auto-detects the target from the URL:
- webhook.office.com / teams.microsoft.com → Teams MessageCard
(colored card with event title + key/value facts table)
- hooks.slack.com → Slack attachments format
- everything else → current generic Aegis JSON
Also resets failure_count=0 in production so the webhook starts fresh.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
244 lines
8.6 KiB
Python
244 lines
8.6 KiB
Python
"""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()
|
|
|
|
|
|
_EVENT_COLORS: dict[str, str] = {
|
|
"test.validated": "28a745", # green
|
|
"test.rejected": "dc3545", # red
|
|
"campaign.completed": "17a2b8", # teal
|
|
"campaign.started": "007bff", # blue
|
|
"mitre.synced": "6f42c1", # purple
|
|
"technique.status_changed":"fd7e14", # orange
|
|
"alert.fired": "dc3545", # red
|
|
"webhook.test": "6c757d", # gray
|
|
}
|
|
|
|
|
|
def _build_teams_card(event_type: str, payload: dict, timestamp: str) -> dict:
|
|
"""Format payload as a Microsoft Teams Incoming Webhook MessageCard."""
|
|
color = _EVENT_COLORS.get(event_type, "0078D4")
|
|
|
|
# Build facts from payload key/value pairs (simple types only)
|
|
facts = [{"name": "Event", "value": event_type},
|
|
{"name": "Time", "value": timestamp}]
|
|
for k, v in (payload or {}).items():
|
|
if isinstance(v, (str, int, float, bool)) and v is not None:
|
|
facts.append({"name": str(k).replace("_", " ").title(), "value": str(v)})
|
|
|
|
return {
|
|
"@type": "MessageCard",
|
|
"@context": "http://schema.org/extensions",
|
|
"themeColor": color,
|
|
"summary": f"Aegis · {event_type}",
|
|
"sections": [{
|
|
"activityTitle": f"**{event_type}**",
|
|
"activitySubtitle": "Aegis Platform Notification",
|
|
"activityImage": "https://raw.githubusercontent.com/microsoft/fluentui-system-icons/main/assets/Shield/SVG/ic_fluent_shield_24_filled.svg",
|
|
"facts": facts,
|
|
"markdown": True,
|
|
}],
|
|
}
|
|
|
|
|
|
def _build_slack_body(event_type: str, payload: dict, timestamp: str) -> dict:
|
|
"""Format payload as a Slack Incoming Webhook message."""
|
|
color = "#" + _EVENT_COLORS.get(event_type, "0078D4")
|
|
fields = [{"title": "Event", "value": event_type, "short": True},
|
|
{"title": "Time", "value": timestamp, "short": True}]
|
|
for k, v in (payload or {}).items():
|
|
if isinstance(v, (str, int, float, bool)) and v is not None:
|
|
fields.append({"title": str(k).replace("_", " ").title(),
|
|
"value": str(v), "short": True})
|
|
return {
|
|
"attachments": [{
|
|
"color": color,
|
|
"title": f"Aegis · {event_type}",
|
|
"fields": fields,
|
|
"footer": "Aegis Platform",
|
|
"ts": int(datetime.utcnow().timestamp()),
|
|
}]
|
|
}
|
|
|
|
|
|
def _send_webhook(db, wh: WebhookConfig, event_type: str, payload: dict) -> None:
|
|
"""Send a single webhook POST and update its metadata.
|
|
|
|
Auto-detects the target platform from the URL and formats accordingly:
|
|
- webhook.office.com / teams.microsoft.com → Teams MessageCard
|
|
- hooks.slack.com → Slack attachments
|
|
- everything else → generic Aegis JSON
|
|
"""
|
|
import json
|
|
timestamp = datetime.utcnow().isoformat() + "Z"
|
|
url_lower = wh.url.lower()
|
|
|
|
if "webhook.office.com" in url_lower or "teams.microsoft.com" in url_lower:
|
|
body = _build_teams_card(event_type, payload, timestamp)
|
|
headers = {"Content-Type": "application/json"}
|
|
elif "hooks.slack.com" in url_lower:
|
|
body = _build_slack_body(event_type, payload, timestamp)
|
|
headers = {"Content-Type": "application/json"}
|
|
else:
|
|
body = {
|
|
"event": event_type,
|
|
"data": payload,
|
|
"timestamp": timestamp,
|
|
}
|
|
headers = {"Content-Type": "application/json"}
|
|
if wh.secret:
|
|
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()
|