259 lines
9.0 KiB
Python
259 lines
9.0 KiB
Python
"""Alert rule evaluation for DMARC report data."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from sqlalchemy import case, func
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.models.domain import Domain
|
|
from app.models.report import DMARCReport, ReportRecord
|
|
from app.models.setting import Setting
|
|
from app.services.alert_history import record_alert_evaluation
|
|
from app.services.notifications import NotificationResult, send_notification
|
|
|
|
|
|
def _truthy(value: Optional[str], default: bool = True) -> bool:
|
|
if value in (None, ""):
|
|
return default
|
|
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
def _int_setting(value: Optional[str], default: int) -> int:
|
|
try:
|
|
return int(value or default)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
def _settings(db: Session) -> Dict[str, Optional[str]]:
|
|
rows = db.query(Setting).filter(Setting.category == "notifications").all()
|
|
return {row.key: row.value for row in rows}
|
|
|
|
|
|
def _days_ago_ts(days: int) -> int:
|
|
return int((datetime.now(timezone.utc) - timedelta(days=days)).timestamp())
|
|
|
|
|
|
def _new_source_alerts(db: Session, window_days: int = 7) -> List[Dict[str, Any]]:
|
|
cutoff_ts = _days_ago_ts(window_days)
|
|
previous_sources = {
|
|
(row.domain, row.source_ip)
|
|
for row in (
|
|
db.query(Domain.name.label("domain"), ReportRecord.source_ip.label("source_ip"))
|
|
.join(DMARCReport, DMARCReport.domain_id == Domain.id)
|
|
.join(ReportRecord, ReportRecord.report_id == DMARCReport.id)
|
|
.filter(DMARCReport.begin_date < cutoff_ts)
|
|
.distinct()
|
|
.all()
|
|
)
|
|
}
|
|
|
|
current_sources = (
|
|
db.query(
|
|
Domain.name.label("domain"),
|
|
ReportRecord.source_ip.label("source_ip"),
|
|
func.sum(ReportRecord.count).label("message_count"),
|
|
)
|
|
.join(DMARCReport, DMARCReport.domain_id == Domain.id)
|
|
.join(ReportRecord, ReportRecord.report_id == DMARCReport.id)
|
|
.filter(DMARCReport.begin_date >= cutoff_ts)
|
|
.group_by(Domain.name, ReportRecord.source_ip)
|
|
.order_by(func.sum(ReportRecord.count).desc())
|
|
.all()
|
|
)
|
|
|
|
alerts = []
|
|
for row in current_sources:
|
|
if (row.domain, row.source_ip) in previous_sources:
|
|
continue
|
|
count = int(row.message_count or 0)
|
|
alerts.append(
|
|
{
|
|
"rule": "new_sender_source",
|
|
"severity": "warning",
|
|
"domain": row.domain,
|
|
"source_ip": row.source_ip,
|
|
"message_count": count,
|
|
"title": "New sending source",
|
|
"detail": f"{row.source_ip} first appeared for {row.domain} with {count} messages.",
|
|
}
|
|
)
|
|
return alerts
|
|
|
|
|
|
def _failure_threshold_alerts(
|
|
db: Session, threshold: int, window_days: int = 1
|
|
) -> List[Dict[str, Any]]:
|
|
cutoff_ts = _days_ago_ts(window_days)
|
|
rows = (
|
|
db.query(
|
|
Domain.name.label("domain"),
|
|
func.sum(ReportRecord.count).label("failed_messages"),
|
|
)
|
|
.join(DMARCReport, DMARCReport.domain_id == Domain.id)
|
|
.join(ReportRecord, ReportRecord.report_id == DMARCReport.id)
|
|
.filter(DMARCReport.begin_date >= cutoff_ts)
|
|
.filter(func.coalesce(ReportRecord.dkim, "fail") != "pass")
|
|
.filter(func.coalesce(ReportRecord.spf, "fail") != "pass")
|
|
.group_by(Domain.name)
|
|
.having(func.sum(ReportRecord.count) >= threshold)
|
|
.all()
|
|
)
|
|
return [
|
|
{
|
|
"rule": "dmarc_failures_above_threshold",
|
|
"severity": "error",
|
|
"domain": row.domain,
|
|
"failed_messages": int(row.failed_messages or 0),
|
|
"threshold": threshold,
|
|
"title": "DMARC failures above threshold",
|
|
"detail": (
|
|
f"{row.domain} had {int(row.failed_messages or 0)} DMARC failures "
|
|
f"in the last {window_days} day(s)."
|
|
),
|
|
}
|
|
for row in rows
|
|
]
|
|
|
|
|
|
def _missing_report_alerts(db: Session, missing_days: int) -> List[Dict[str, Any]]:
|
|
cutoff_ts = _days_ago_ts(missing_days)
|
|
latest_rows = (
|
|
db.query(
|
|
Domain.name.label("domain"),
|
|
func.max(DMARCReport.end_date).label("last_report_ts"),
|
|
)
|
|
.outerjoin(DMARCReport, DMARCReport.domain_id == Domain.id)
|
|
.group_by(Domain.id, Domain.name)
|
|
.all()
|
|
)
|
|
alerts = []
|
|
for row in latest_rows:
|
|
last_report_ts = int(row.last_report_ts or 0)
|
|
if last_report_ts >= cutoff_ts:
|
|
continue
|
|
alerts.append(
|
|
{
|
|
"rule": "missing_reports",
|
|
"severity": "warning",
|
|
"domain": row.domain,
|
|
"missing_days": missing_days,
|
|
"last_report_at": (
|
|
datetime.fromtimestamp(last_report_ts, tz=timezone.utc).isoformat()
|
|
if last_report_ts
|
|
else None
|
|
),
|
|
"title": "Missing DMARC reports",
|
|
"detail": f"{row.domain} has no DMARC report in the last {missing_days} day(s).",
|
|
}
|
|
)
|
|
return alerts
|
|
|
|
|
|
def _compliance_drop_alerts(
|
|
db: Session, drop_points: int, window_days: int = 2
|
|
) -> List[Dict[str, Any]]:
|
|
cutoff_ts = _days_ago_ts(window_days)
|
|
rows = (
|
|
db.query(
|
|
Domain.name.label("domain"),
|
|
DMARCReport.begin_date.label("begin_date"),
|
|
func.sum(ReportRecord.count).label("total"),
|
|
func.sum(
|
|
case(
|
|
(
|
|
(ReportRecord.dkim == "pass") | (ReportRecord.spf == "pass"),
|
|
ReportRecord.count,
|
|
),
|
|
else_=0,
|
|
)
|
|
).label("passed"),
|
|
)
|
|
.join(DMARCReport, DMARCReport.domain_id == Domain.id)
|
|
.join(ReportRecord, ReportRecord.report_id == DMARCReport.id)
|
|
.filter(DMARCReport.begin_date >= cutoff_ts)
|
|
.group_by(Domain.name, DMARCReport.begin_date)
|
|
.order_by(Domain.name, DMARCReport.begin_date)
|
|
.all()
|
|
)
|
|
|
|
by_domain: Dict[str, List[Dict[str, float]]] = {}
|
|
for row in rows:
|
|
total = int(row.total or 0)
|
|
passed = int(row.passed or 0)
|
|
rate = round((passed / total) * 100, 1) if total else 0.0
|
|
by_domain.setdefault(row.domain, []).append({"date": row.begin_date, "rate": rate})
|
|
|
|
alerts = []
|
|
for domain, points in by_domain.items():
|
|
if len(points) < 2:
|
|
continue
|
|
previous = points[-2]
|
|
current = points[-1]
|
|
drop = round(previous["rate"] - current["rate"], 1)
|
|
if drop < drop_points:
|
|
continue
|
|
alerts.append(
|
|
{
|
|
"rule": "compliance_drop",
|
|
"severity": "error" if drop >= 25 else "warning",
|
|
"domain": domain,
|
|
"previous_rate": previous["rate"],
|
|
"current_rate": current["rate"],
|
|
"drop": drop,
|
|
"threshold": drop_points,
|
|
"title": "Compliance dropped",
|
|
"detail": f"{domain} compliance fell by {drop} percentage points.",
|
|
}
|
|
)
|
|
return alerts
|
|
|
|
|
|
def evaluate_alert_rules(db: Session) -> List[Dict[str, Any]]:
|
|
"""Evaluate all enabled alert rules against persisted DMARC data."""
|
|
settings = _settings(db)
|
|
alerts: List[Dict[str, Any]] = []
|
|
|
|
if _truthy(settings.get("notifications.alert_new_sources_enabled")):
|
|
alerts.extend(_new_source_alerts(db))
|
|
|
|
if _truthy(settings.get("notifications.alert_failure_threshold_enabled")):
|
|
threshold = _int_setting(settings.get("notifications.alert_failure_threshold_count"), 100)
|
|
alerts.extend(_failure_threshold_alerts(db, threshold))
|
|
|
|
if _truthy(settings.get("notifications.alert_missing_reports_enabled")):
|
|
missing_days = _int_setting(settings.get("notifications.alert_missing_reports_days"), 2)
|
|
alerts.extend(_missing_report_alerts(db, missing_days))
|
|
|
|
if _truthy(settings.get("notifications.alert_compliance_drop_enabled")):
|
|
drop_points = _int_setting(settings.get("notifications.alert_compliance_drop_points"), 10)
|
|
alerts.extend(_compliance_drop_alerts(db, drop_points))
|
|
|
|
return alerts
|
|
|
|
|
|
def send_current_alerts(db: Session) -> Dict[str, Any]:
|
|
"""Evaluate current alert rules and send one summary notification when needed."""
|
|
alerts = evaluate_alert_rules(db)
|
|
record_alert_evaluation(db, alerts)
|
|
if not alerts:
|
|
return {
|
|
"alerts": [],
|
|
"notification": NotificationResult(
|
|
success=True, skipped=True, message="No alerts."
|
|
).to_dict(),
|
|
}
|
|
|
|
lines = [f"{alert['title']}: {alert['detail']}" for alert in alerts[:10]]
|
|
if len(alerts) > 10:
|
|
lines.append(f"...and {len(alerts) - 10} more alert(s).")
|
|
result = send_notification(
|
|
db,
|
|
title=f"DMARQ alert summary: {len(alerts)} alert(s)",
|
|
body="\n".join(lines),
|
|
)
|
|
return {"alerts": alerts, "notification": result.to_dict()}
|