feat(alerts): close Phase 13 gaps — hourly job + webhook + in-app notifications
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

- Add dispatch_webhook_targeted() to webhook_service for rule-specific delivery
- evaluate_all_rules() now dispatches in-app notifications (admins/leads) and
  webhooks after each alert fires (targeted + global alert.fired broadcast)
- APScheduler: _run_alert_evaluation() job registered hourly alongside existing jobs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kitos
2026-05-21 15:57:41 +02:00
parent cfbf6a6ede
commit 97349a1d13
3 changed files with 141 additions and 12 deletions

View File

@@ -23,6 +23,53 @@ from app.models.enums import TechniqueStatus
log = logging.getLogger(__name__)
# ── Notification & webhook dispatch helpers ───────────────────────────────────
def _dispatch_inapp_notifications(db: Session, rule: AlertRule, instance: AlertInstance) -> None:
"""Create in-app Notification rows for all admins and leads."""
from app.services.notification_service import create_notification
admin_roles = {"admin", "red_lead", "blue_lead"}
users = db.query(User).filter(
User.role.in_(admin_roles),
User.is_active == True, # noqa: E712
).all()
for user in users:
create_notification(
db,
user_id = user.id,
type = "alert_fired",
title = instance.title,
message = instance.message,
entity_type = "alert_instance",
entity_id = instance.id,
)
def _dispatch_webhooks(rule: AlertRule, instance: AlertInstance) -> None:
"""Fire webhook(s) for a triggered alert (all exceptions caught)."""
from app.services.webhook_service import dispatch_webhook, dispatch_webhook_targeted
payload = {
"alert_id": str(instance.id),
"rule_id": str(rule.id) if rule.id else None,
"rule_name": instance.rule_name,
"rule_type": instance.rule_type,
"severity": instance.severity,
"title": instance.title,
"message": instance.message,
"details": instance.details,
}
# 1. Targeted webhook configured on the rule
if rule.notify_webhook and rule.webhook_id:
dispatch_webhook_targeted(str(rule.webhook_id), "alert.fired", payload)
# 2. Broadcast to all global "alert.fired" subscribers
dispatch_webhook("alert.fired", payload)
# ── Pre-configured system rules (seeded at startup) ───────────────────────────
SYSTEM_RULES = [
@@ -338,11 +385,19 @@ def _in_cooldown(rule: AlertRule) -> bool:
def evaluate_all_rules(db: Session) -> dict:
"""Evaluate every enabled rule; create AlertInstances for those that fire."""
"""Evaluate every enabled rule; create AlertInstances for those that fire.
After persisting each alert, dispatches:
- In-app notifications to all admins/leads (if rule.notify_in_app)
- Webhooks to the rule's targeted webhook + global "alert.fired" subscribers
(if rule.notify_webhook)
"""
t0 = time.monotonic()
rules = db.query(AlertRule).filter(AlertRule.is_enabled == True).all()
fired: List[AlertInstance] = []
# (rule, instance) pairs so we can dispatch after commit
fired_pairs: List[tuple] = []
for rule in rules:
if _in_cooldown(rule):
continue
@@ -370,12 +425,35 @@ def evaluate_all_rules(db: Session) -> dict:
)
db.add(instance)
rule.last_fired_at = datetime.utcnow()
fired.append(instance)
fired_pairs.append((rule, instance))
# ── Persist alerts ────────────────────────────────────────────────────────
db.commit()
for inst in fired:
db.refresh(inst)
for _rule, inst in fired_pairs:
db.refresh(inst) # populate id + created_at from DB
# ── In-app notifications (need instance.id, so must be after refresh) ────
for rule, inst in fired_pairs:
if rule.notify_in_app:
try:
_dispatch_inapp_notifications(db, rule, inst)
except Exception:
log.exception("In-app notification failed for alert %s", inst.id)
if fired_pairs:
try:
db.commit() # commit notifications
except Exception:
log.exception("Failed to commit in-app notifications")
db.rollback()
# ── Webhooks (fire-and-forget, own sessions) ──────────────────────────────
for rule, inst in fired_pairs:
try:
_dispatch_webhooks(rule, inst)
except Exception:
log.exception("Webhook dispatch failed for alert %s", inst.id)
fired = [inst for _, inst in fired_pairs]
return {
"rules_evaluated": len(rules),
"alerts_fired": len(fired),

View File

@@ -1,13 +1,14 @@
"""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
- 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
- webhook.test — manual test ping from the admin UI
- alert.fired — fired when an operational alert is triggered
"""
import hashlib
@@ -29,6 +30,28 @@ logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
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*.