Merge pull request #120 from christianlouis/codex/notification-alert-rules
[codex] add notification alert rules
This commit is contained in:
@@ -21,6 +21,7 @@ from sqlalchemy.orm import Session
|
||||
from app.core.database import get_db
|
||||
from app.core.security import require_admin_auth
|
||||
from app.models.setting import Setting
|
||||
from app.services.alert_rules import evaluate_alert_rules, send_current_alerts
|
||||
from app.services.notifications import send_notification
|
||||
|
||||
router = APIRouter()
|
||||
@@ -127,6 +128,55 @@ SETTING_DEFAULTS: List[Dict[str, Any]] = [
|
||||
"value_type": "string",
|
||||
"category": "notifications",
|
||||
},
|
||||
{
|
||||
"key": "notifications.alert_new_sources_enabled",
|
||||
"value": "true",
|
||||
"description": "Alert when a new sending source appears in recent DMARC reports",
|
||||
"value_type": "boolean",
|
||||
"category": "notifications",
|
||||
},
|
||||
{
|
||||
"key": "notifications.alert_compliance_drop_enabled",
|
||||
"value": "true",
|
||||
"description": "Alert when DMARC compliance drops by the configured percentage points",
|
||||
"value_type": "boolean",
|
||||
"category": "notifications",
|
||||
},
|
||||
{
|
||||
"key": "notifications.alert_compliance_drop_points",
|
||||
"value": "10",
|
||||
"description": "Minimum compliance-rate drop, in percentage points, before alerting",
|
||||
"value_type": "integer",
|
||||
"category": "notifications",
|
||||
},
|
||||
{
|
||||
"key": "notifications.alert_failure_threshold_enabled",
|
||||
"value": "true",
|
||||
"description": "Alert when DMARC failures exceed the configured daily threshold",
|
||||
"value_type": "boolean",
|
||||
"category": "notifications",
|
||||
},
|
||||
{
|
||||
"key": "notifications.alert_failure_threshold_count",
|
||||
"value": "100",
|
||||
"description": "Minimum failed message count in the last day before alerting",
|
||||
"value_type": "integer",
|
||||
"category": "notifications",
|
||||
},
|
||||
{
|
||||
"key": "notifications.alert_missing_reports_enabled",
|
||||
"value": "true",
|
||||
"description": "Alert when a monitored domain has not received recent DMARC reports",
|
||||
"value_type": "boolean",
|
||||
"category": "notifications",
|
||||
},
|
||||
{
|
||||
"key": "notifications.alert_missing_reports_days",
|
||||
"value": "2",
|
||||
"description": "Number of days without DMARC reports before alerting",
|
||||
"value_type": "integer",
|
||||
"category": "notifications",
|
||||
},
|
||||
]
|
||||
|
||||
# Keys whose values should be redacted in GET responses (treated as secrets)
|
||||
@@ -216,6 +266,19 @@ class NotificationTestResponse(BaseModel):
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class AlertRulesResponse(BaseModel):
|
||||
"""Current alert-rule evaluation response."""
|
||||
|
||||
alerts: List[Dict[str, Any]]
|
||||
|
||||
|
||||
class AlertNotificationResponse(BaseModel):
|
||||
"""Alert-rule evaluation plus notification delivery status."""
|
||||
|
||||
alerts: List[Dict[str, Any]]
|
||||
notification: Dict[str, Any]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -261,6 +324,33 @@ async def test_notification_settings(
|
||||
return result.to_dict()
|
||||
|
||||
|
||||
@router.get("/notifications/alerts", response_model=AlertRulesResponse)
|
||||
async def evaluate_notification_alerts(
|
||||
db: Session = Depends(get_db),
|
||||
_auth: dict = Depends(require_admin_auth),
|
||||
) -> AlertRulesResponse:
|
||||
"""Evaluate enabled notification alert rules against current DMARC data."""
|
||||
_seed_defaults(db)
|
||||
return {"alerts": evaluate_alert_rules(db)}
|
||||
|
||||
|
||||
@router.post("/notifications/alerts/send", response_model=AlertNotificationResponse)
|
||||
async def send_notification_alerts(
|
||||
db: Session = Depends(get_db),
|
||||
_auth: dict = Depends(require_admin_auth),
|
||||
) -> AlertNotificationResponse:
|
||||
"""Evaluate current alert rules and send a notification summary when needed."""
|
||||
_seed_defaults(db)
|
||||
result = send_current_alerts(db)
|
||||
notification = result["notification"]
|
||||
if result["alerts"] and not notification.get("success"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=result,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{key:path}", response_model=SettingResponse)
|
||||
async def get_setting(
|
||||
key: str,
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"""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.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)
|
||||
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()}
|
||||
@@ -235,7 +235,100 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="border-t border-border pt-4 space-y-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">Alert Rules</h3>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox"
|
||||
:checked="s['notifications.alert_new_sources_enabled'] === 'true'"
|
||||
@change="s['notifications.alert_new_sources_enabled'] = $event.target.checked ? 'true' : 'false'"
|
||||
class="checkbox checkbox-primary" />
|
||||
<span class="label-text font-medium">New sending sources</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox"
|
||||
:checked="s['notifications.alert_missing_reports_enabled'] === 'true'"
|
||||
@change="s['notifications.alert_missing_reports_enabled'] = $event.target.checked ? 'true' : 'false'"
|
||||
class="checkbox checkbox-primary" />
|
||||
<span class="label-text font-medium">Missing reports</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox"
|
||||
:checked="s['notifications.alert_failure_threshold_enabled'] === 'true'"
|
||||
@change="s['notifications.alert_failure_threshold_enabled'] = $event.target.checked ? 'true' : 'false'"
|
||||
class="checkbox checkbox-primary" />
|
||||
<span class="label-text font-medium">High DMARC failures</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox"
|
||||
:checked="s['notifications.alert_compliance_drop_enabled'] === 'true'"
|
||||
@change="s['notifications.alert_compliance_drop_enabled'] = $event.target.checked ? 'true' : 'false'"
|
||||
class="checkbox checkbox-primary" />
|
||||
<span class="label-text font-medium">Compliance drops</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="form-control w-full">
|
||||
<label class="label"><span class="label-text font-medium">Failure Threshold</span></label>
|
||||
<input type="number" x-model.number="s['notifications.alert_failure_threshold_count']"
|
||||
class="input input-bordered w-full" min="1" />
|
||||
</div>
|
||||
<div class="form-control w-full">
|
||||
<label class="label"><span class="label-text font-medium">Missing After Days</span></label>
|
||||
<input type="number" x-model.number="s['notifications.alert_missing_reports_days']"
|
||||
class="input input-bordered w-full" min="1" />
|
||||
</div>
|
||||
<div class="form-control w-full">
|
||||
<label class="label"><span class="label-text font-medium">Drop Points</span></label>
|
||||
<input type="number" x-model.number="s['notifications.alert_compliance_drop_points']"
|
||||
class="input input-bordered w-full" min="1" max="100" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template x-if="alertPreview !== null">
|
||||
<div class="alert" :class="alertPreview > 0 ? 'alert-warning' : 'alert-success'">
|
||||
<span x-text="alertPreview > 0 ? `${alertPreview} active alert${alertPreview === 1 ? '' : 's'} found.` : 'No active alerts found.'"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row justify-end gap-2">
|
||||
<button type="button" class="btn btn-outline btn-md" :disabled="saving || checkingAlerts" @click="checkAlerts()">
|
||||
<template x-if="!checkingAlerts">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2">
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2"></path>
|
||||
</svg>
|
||||
</template>
|
||||
<template x-if="checkingAlerts"><span class="loading loading-spinner loading-xs mr-2"></span></template>
|
||||
Check Alerts
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline btn-md" :disabled="saving || sendingAlerts" @click="sendAlertSummary()">
|
||||
<template x-if="!sendingAlerts">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2">
|
||||
<path d="M10.268 21a2 2 0 0 0 3.464 0"></path>
|
||||
<path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"></path>
|
||||
</svg>
|
||||
</template>
|
||||
<template x-if="sendingAlerts"><span class="loading loading-spinner loading-xs mr-2"></span></template>
|
||||
Send Alerts Now
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline btn-md" :disabled="saving || testingNotification" @click="sendTestNotification()">
|
||||
<template x-if="!testingNotification">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
@@ -292,6 +385,9 @@ function settingsApp() {
|
||||
flashMsg: '',
|
||||
flashOk: true,
|
||||
testingNotification: false,
|
||||
checkingAlerts: false,
|
||||
sendingAlerts: false,
|
||||
alertPreview: null,
|
||||
showCfToken: false,
|
||||
|
||||
// Session cookie is sent automatically by the browser (httpOnly, same-origin).
|
||||
@@ -369,6 +465,51 @@ function settingsApp() {
|
||||
}
|
||||
},
|
||||
|
||||
async checkAlerts() {
|
||||
this.checkingAlerts = true;
|
||||
try {
|
||||
await this.saveCategory('notifications');
|
||||
const res = await fetch('/api/v1/settings/notifications/alerts', {
|
||||
headers: this.apiHeaders(),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
this.showFlash('Alert check failed: ' + (data.detail || res.statusText), false);
|
||||
} else {
|
||||
this.alertPreview = (data.alerts || []).length;
|
||||
this.showFlash(`${this.alertPreview} active alert${this.alertPreview === 1 ? '' : 's'} found.`, true);
|
||||
}
|
||||
} catch (err) {
|
||||
this.showFlash('Error checking alerts: ' + err.message, false);
|
||||
} finally {
|
||||
this.checkingAlerts = false;
|
||||
}
|
||||
},
|
||||
|
||||
async sendAlertSummary() {
|
||||
this.sendingAlerts = true;
|
||||
try {
|
||||
await this.saveCategory('notifications');
|
||||
const res = await fetch('/api/v1/settings/notifications/alerts/send', {
|
||||
method: 'POST',
|
||||
headers: this.apiHeaders(),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const detail = data.detail || {};
|
||||
const notification = detail.notification || {};
|
||||
this.showFlash('Alert send failed: ' + (notification.message || data.detail || res.statusText), false);
|
||||
} else {
|
||||
this.alertPreview = (data.alerts || []).length;
|
||||
this.showFlash(data.notification.skipped ? 'No active alerts to send.' : 'Alert summary sent.', true);
|
||||
}
|
||||
} catch (err) {
|
||||
this.showFlash('Error sending alerts: ' + err.message, false);
|
||||
} finally {
|
||||
this.sendingAlerts = false;
|
||||
}
|
||||
},
|
||||
|
||||
showFlash(msg, ok) {
|
||||
this.flashMsg = msg;
|
||||
this.flashOk = ok;
|
||||
|
||||
@@ -2,10 +2,61 @@
|
||||
Tests for the Settings model and /api/v1/settings endpoints.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
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.notifications import NotificationResult
|
||||
|
||||
|
||||
def _timestamp_days_ago(days: int) -> int:
|
||||
return int((datetime.now(timezone.utc) - timedelta(days=days)).timestamp())
|
||||
|
||||
|
||||
def _add_domain(db_session: Session, name: str) -> Domain:
|
||||
domain = Domain(name=name)
|
||||
db_session.add(domain)
|
||||
db_session.flush()
|
||||
return domain
|
||||
|
||||
|
||||
def _add_report_record(
|
||||
db_session: Session,
|
||||
domain: Domain,
|
||||
*,
|
||||
report_id: str,
|
||||
days_ago: int,
|
||||
source_ip: str,
|
||||
count: int,
|
||||
dkim: str = "pass",
|
||||
spf: str = "pass",
|
||||
) -> None:
|
||||
begin_date = _timestamp_days_ago(days_ago)
|
||||
report = DMARCReport(
|
||||
domain_id=domain.id,
|
||||
report_id=report_id,
|
||||
org_name="receiver.example",
|
||||
begin_date=begin_date,
|
||||
end_date=begin_date + 3600,
|
||||
policy="none",
|
||||
)
|
||||
db_session.add(report)
|
||||
db_session.flush()
|
||||
db_session.add(
|
||||
ReportRecord(
|
||||
report_id=report.id,
|
||||
source_ip=source_ip,
|
||||
count=count,
|
||||
disposition="none",
|
||||
dkim=dkim,
|
||||
spf=spf,
|
||||
header_from=domain.name,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestSettingModel:
|
||||
@@ -51,6 +102,10 @@ class TestSettingsAPI:
|
||||
assert "cloudflare.api_token" in keys
|
||||
assert "notifications.apprise_enabled" in keys
|
||||
assert "notifications.apprise_urls" in keys
|
||||
assert "notifications.alert_new_sources_enabled" in keys
|
||||
assert "notifications.alert_compliance_drop_points" in keys
|
||||
assert "notifications.alert_failure_threshold_count" in keys
|
||||
assert "notifications.alert_missing_reports_days" in keys
|
||||
|
||||
def test_list_settings_filter_by_category(self, authed_client: TestClient):
|
||||
"""GET /api/v1/settings?category=dmarc returns only dmarc settings."""
|
||||
@@ -213,3 +268,145 @@ class TestSettingsAPI:
|
||||
detail = res.json()["detail"]
|
||||
assert detail["success"] is False
|
||||
assert detail["message"] == "No notification targets are configured."
|
||||
|
||||
def test_notification_alert_rules_detect_new_source_and_failures(
|
||||
self,
|
||||
authed_client: TestClient,
|
||||
db_session: Session,
|
||||
):
|
||||
"""GET /settings/notifications/alerts evaluates source and failure alerts."""
|
||||
domain = _add_domain(db_session, "alerts.example")
|
||||
_add_report_record(
|
||||
db_session,
|
||||
domain,
|
||||
report_id="alerts-old-source",
|
||||
days_ago=10,
|
||||
source_ip="203.0.113.10",
|
||||
count=12,
|
||||
)
|
||||
_add_report_record(
|
||||
db_session,
|
||||
domain,
|
||||
report_id="alerts-new-source",
|
||||
days_ago=0,
|
||||
source_ip="203.0.113.20",
|
||||
count=150,
|
||||
dkim="fail",
|
||||
spf="fail",
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
res = authed_client.get("/api/v1/settings/notifications/alerts")
|
||||
|
||||
assert res.status_code == 200
|
||||
alerts = res.json()["alerts"]
|
||||
rules = {alert["rule"] for alert in alerts}
|
||||
assert "new_sender_source" in rules
|
||||
assert "dmarc_failures_above_threshold" in rules
|
||||
new_source = next(alert for alert in alerts if alert["rule"] == "new_sender_source")
|
||||
assert new_source["source_ip"] == "203.0.113.20"
|
||||
|
||||
def test_notification_alert_rules_detect_missing_reports(
|
||||
self,
|
||||
authed_client: TestClient,
|
||||
db_session: Session,
|
||||
):
|
||||
"""Alert rules flag active monitored domains without recent reports."""
|
||||
_add_domain(db_session, "missing.example")
|
||||
db_session.commit()
|
||||
|
||||
res = authed_client.get("/api/v1/settings/notifications/alerts")
|
||||
|
||||
assert res.status_code == 200
|
||||
alerts = res.json()["alerts"]
|
||||
assert any(
|
||||
alert["rule"] == "missing_reports" and alert["domain"] == "missing.example"
|
||||
for alert in alerts
|
||||
)
|
||||
|
||||
def test_notification_alert_rules_detect_compliance_drop(
|
||||
self,
|
||||
authed_client: TestClient,
|
||||
db_session: Session,
|
||||
):
|
||||
"""Alert rules compare recent compliance rates and flag large drops."""
|
||||
domain = _add_domain(db_session, "drop.example")
|
||||
_add_report_record(
|
||||
db_session,
|
||||
domain,
|
||||
report_id="drop-known-source",
|
||||
days_ago=10,
|
||||
source_ip="203.0.113.30",
|
||||
count=3,
|
||||
)
|
||||
_add_report_record(
|
||||
db_session,
|
||||
domain,
|
||||
report_id="drop-passing-day",
|
||||
days_ago=1,
|
||||
source_ip="203.0.113.30",
|
||||
count=10,
|
||||
)
|
||||
_add_report_record(
|
||||
db_session,
|
||||
domain,
|
||||
report_id="drop-failing-day",
|
||||
days_ago=0,
|
||||
source_ip="203.0.113.30",
|
||||
count=10,
|
||||
dkim="fail",
|
||||
spf="fail",
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
res = authed_client.get("/api/v1/settings/notifications/alerts")
|
||||
|
||||
assert res.status_code == 200
|
||||
alerts = res.json()["alerts"]
|
||||
compliance_alert = next(alert for alert in alerts if alert["rule"] == "compliance_drop")
|
||||
assert compliance_alert["domain"] == "drop.example"
|
||||
assert compliance_alert["previous_rate"] == 100.0
|
||||
assert compliance_alert["current_rate"] == 0.0
|
||||
|
||||
def test_notification_alert_send_uses_alert_summary(
|
||||
self,
|
||||
authed_client: TestClient,
|
||||
db_session: Session,
|
||||
monkeypatch,
|
||||
):
|
||||
"""POST /settings/notifications/alerts/send sends one alert summary."""
|
||||
sent_messages = []
|
||||
|
||||
def fake_send_notification(
|
||||
db, *, title, body, force=False
|
||||
): # pylint: disable=unused-argument
|
||||
sent_messages.append({"title": title, "body": body})
|
||||
return NotificationResult(
|
||||
success=True,
|
||||
message="Notification sent.",
|
||||
configured_targets=1,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.services.alert_rules.send_notification", fake_send_notification)
|
||||
|
||||
domain = _add_domain(db_session, "send.example")
|
||||
_add_report_record(
|
||||
db_session,
|
||||
domain,
|
||||
report_id="send-failing-day",
|
||||
days_ago=0,
|
||||
source_ip="203.0.113.40",
|
||||
count=200,
|
||||
dkim="fail",
|
||||
spf="fail",
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
res = authed_client.post("/api/v1/settings/notifications/alerts/send")
|
||||
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["notification"]["success"] is True
|
||||
assert data["alerts"]
|
||||
assert sent_messages[0]["title"].startswith("DMARQ alert summary")
|
||||
assert "DMARC failures above threshold" in sent_messages[0]["body"]
|
||||
|
||||
@@ -85,6 +85,13 @@ from API responses.
|
||||
|---------|-------------|---------|---------|
|
||||
| `notifications.apprise_enabled` | Enable Apprise notification delivery | `false` | `true` |
|
||||
| `notifications.apprise_urls` | Newline-separated Apprise target URLs | - | `mailto://user:pass@example.com` |
|
||||
| `notifications.alert_new_sources_enabled` | Alert on newly observed sending sources | `true` | `true` |
|
||||
| `notifications.alert_compliance_drop_enabled` | Alert on recent compliance-rate drops | `true` | `true` |
|
||||
| `notifications.alert_compliance_drop_points` | Minimum compliance-rate drop in percentage points | `10` | `15` |
|
||||
| `notifications.alert_failure_threshold_enabled` | Alert on high recent DMARC failure volume | `true` | `true` |
|
||||
| `notifications.alert_failure_threshold_count` | Failed messages in the last day before alerting | `100` | `250` |
|
||||
| `notifications.alert_missing_reports_enabled` | Alert when a monitored domain stops receiving reports | `true` | `true` |
|
||||
| `notifications.alert_missing_reports_days` | Days without reports before alerting | `2` | `3` |
|
||||
|
||||
### Cloudflare Integration
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ Follow-up:
|
||||
|
||||
## Later Milestones
|
||||
|
||||
- Notifications and alert rules. Apprise delivery and test notifications are in place; alert rules, summaries, and alert history remain.
|
||||
- Notifications and alert rules. Apprise delivery, test notifications, and current alert-rule evaluation are in place; scheduled summaries and alert history remain.
|
||||
- DNS health and Cloudflare read-only inspection.
|
||||
- Guided setup and operator health screens.
|
||||
- Forensic/RUF report support.
|
||||
|
||||
+2
-1
@@ -114,9 +114,10 @@ Goal: notify administrators when action is needed.
|
||||
Delivered:
|
||||
- Apprise notification integration for newline-separated notification target URLs.
|
||||
- Notification settings UI can save Apprise targets, keeps target URLs redacted after save, and can send a test notification.
|
||||
- Alert rules for new sender source, compliance drop, DMARC failures above threshold, and missing reports.
|
||||
- Notification settings UI can evaluate active alerts and send the current alert summary on demand.
|
||||
|
||||
Planned:
|
||||
- Alert rules for new sender source, compliance drop, DMARC failures above threshold, and missing reports.
|
||||
- Daily/weekly summary notifications.
|
||||
- Alert history.
|
||||
|
||||
|
||||
+1
-1
@@ -165,7 +165,7 @@ Status: Complete for the delivered reporting milestone. Alert-specific dashboard
|
||||
- [x] Add backup/restore guidance for database deployments
|
||||
- [x] Add release checklist covering migrations, tests, and smoke checks
|
||||
- [x] Add Apprise notification delivery and test notification support
|
||||
- [ ] Add alert rules for new sender source, compliance drop, DMARC failures above threshold, and missing reports
|
||||
- [x] Add alert rules for new sender source, compliance drop, DMARC failures above threshold, and missing reports
|
||||
- [ ] Add daily and weekly summary notifications
|
||||
- [ ] Add alert history
|
||||
- [ ] DNS health guidance and Cloudflare read-only inspection
|
||||
|
||||
@@ -44,14 +44,15 @@ settings API or page reloads.
|
||||
|
||||
### Alert Thresholds
|
||||
|
||||
Set thresholds for when alerts are triggered:
|
||||
Set alert rules and thresholds in **Settings** > **Notifications**:
|
||||
|
||||
1. Navigate to **Settings** > **Notifications** > **Thresholds**
|
||||
2. Configure thresholds for:
|
||||
- **Compliance Rate Drop**: Alert when compliance falls below a threshold
|
||||
- **New Sending Sources**: Alert when new IPs/servers send email as your domain
|
||||
- **Authentication Failures**: Alert when failures exceed a certain number
|
||||
- **Report Processing Issues**: Alert on report processing errors
|
||||
- **New Sending Sources**: newly observed IPs or servers sending as a monitored domain
|
||||
- **Compliance Drops**: recent compliance-rate drops beyond the configured point threshold
|
||||
- **High DMARC Failures**: failed messages over the daily threshold
|
||||
- **Missing Reports**: monitored domains without reports for the configured number of days
|
||||
|
||||
Use **Check Alerts** to preview the current alert count, or **Send Alerts Now** to
|
||||
send the current alert summary through the configured Apprise targets.
|
||||
|
||||
### Integration Notifications
|
||||
|
||||
|
||||
Reference in New Issue
Block a user