Add db migration and DNS dmarc entries

This commit is contained in:
2026-05-20 13:20:58 -03:00
parent 636d3b73cb
commit e57df39562
19 changed files with 850 additions and 12 deletions
+3 -1
View File
@@ -12,7 +12,9 @@ RUN apt-get update \
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY alembic.ini .
COPY app ./app COPY app ./app
COPY migrations ./migrations
RUN mkdir -p /app/config /app/data /app/logs RUN mkdir -p /app/config /app/data /app/logs
RUN groupadd --gid 1000 app \ RUN groupadd --gid 1000 app \
@@ -21,4 +23,4 @@ RUN groupadd --gid 1000 app \
USER app USER app
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"]
+26
View File
@@ -26,6 +26,8 @@ nano config/config.yml
docker compose up -d --build docker compose up -d --build
``` ```
Container startup runs `alembic upgrade head` before starting the dashboard, so mounted SQLite databases are migrated forward automatically when new schema migrations are added.
Then process the existing mailbox backlog: Then process the existing mailbox backlog:
```bash ```bash
@@ -115,6 +117,30 @@ Options:
Backlog mode scans all matching messages, skips already imported XML hashes unless `--reprocess` is passed, and does not modify messages unless configured or explicitly flagged. Backlog mode scans all matching messages, skips already imported XML hashes unless `--reprocess` is passed, and does not modify messages unless configured or explicitly flagged.
## Database Migrations
Schema changes are managed with Alembic. In Docker, migrations run automatically on container startup:
```bash
docker compose up -d --build
```
For a manual migration run:
```bash
docker compose exec dmarc-sentinel alembic upgrade head
```
For local development:
```bash
DMARC_SENTINEL_ALLOW_NO_LLM_FOR_TESTS=true alembic upgrade head
```
The app still creates missing tables on startup for lightweight local and test runs, but migrations are the intended path for preserving an existing production SQLite database.
When adopting Alembic on an existing database that was already started once with the new code, the migration is idempotent for the DNS snapshot table and will still stamp the Alembic revision.
## gethomepage Widget ## gethomepage Widget
Endpoint: Endpoint:
+38
View File
@@ -0,0 +1,38 @@
[alembic]
script_location = migrations
prepend_sys_path = .
timezone = UTC
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+66 -4
View File
@@ -62,6 +62,60 @@ def _merge_details(existing: str, incoming: dict[str, Any]) -> str:
return json.dumps(data, sort_keys=True) return json.dumps(data, sort_keys=True)
def _domain_equal(a: str | None, b: str | None) -> bool:
return (a or "").lower().rstrip(".") == (b or "").lower().rstrip(".")
def _is_subdomain(child: str | None, parent: str | None) -> bool:
child_norm = (child or "").lower().rstrip(".")
parent_norm = (parent or "").lower().rstrip(".")
return bool(child_norm and parent_norm and child_norm.endswith(f".{parent_norm}"))
def _published_policy(record: Record, report: Report) -> dict[str, Any]:
effective_source = "p"
effective = report.policy_p
if _is_subdomain(record.header_from, report.domain) and report.policy_sp:
effective_source = "sp"
effective = report.policy_sp
elif not _domain_equal(record.header_from, report.domain) and record.header_from:
effective_source = "p"
effective = report.policy_p
return {
"domain": report.domain,
"header_from": record.header_from,
"p": report.policy_p,
"sp": report.policy_sp,
"pct": report.policy_pct,
"effective": effective,
"effective_source": effective_source,
"adkim": report.adkim,
"aspf": report.aspf,
"fo": report.fo,
}
def _receiver_action(record: Record) -> dict[str, Any]:
return {
"disposition": record.disposition,
"override_type": record.reason_type,
"override_comment": record.reason_comment,
}
def _policy_context_sentence(record: Record, report: Report) -> str:
published = _published_policy(record, report)
receiver = _receiver_action(record)
effective = published.get("effective") or "unspecified"
source = published.get("effective_source") or "p"
disposition = receiver.get("disposition") or "none"
pct = published.get("pct")
pct_text = f"; pct={pct}" if pct is not None else ""
override = receiver.get("override_type")
override_text = f" with override {override}" if override else ""
return f"Published DMARC policy was {source}={effective}{pct_text}; receiver disposition was {disposition}{override_text}."
def create_or_update_alert( def create_or_update_alert(
session: Session, session: Session,
*, *,
@@ -112,6 +166,11 @@ def _record_details(record: Record, report: Report) -> dict[str, Any]:
"dkim_aligned": record.dkim_aligned, "dkim_aligned": record.dkim_aligned,
"dmarc_pass": record.dmarc_pass, "dmarc_pass": record.dmarc_pass,
"disposition": record.disposition, "disposition": record.disposition,
"policy_p": report.policy_p,
"policy_sp": report.policy_sp,
"policy_pct": report.policy_pct,
"published_policy": _published_policy(record, report),
"receiver_action": _receiver_action(record),
"known_sender": record.is_known_sender, "known_sender": record.is_known_sender,
"known_sender_id": record.known_sender_id, "known_sender_id": record.known_sender_id,
"reporting_orgs": [report.org_name] if report.org_name else [], "reporting_orgs": [report.org_name] if report.org_name else [],
@@ -204,6 +263,7 @@ def analyze_report(session: Session, settings: Settings, report: Report, llm: LL
for record in report.records: for record in report.records:
details = _record_details(record, report) details = _record_details(record, report)
if not record.is_known_sender and not record.spf_aligned and not record.dkim_aligned and record.count >= thresholds.unknown_source_fail_count: if not record.is_known_sender and not record.spf_aligned and not record.dkim_aligned and record.count >= thresholds.unknown_source_fail_count:
policy_context = _policy_context_sentence(record, report)
created.append( created.append(
create_or_update_alert( create_or_update_alert(
session, session,
@@ -213,11 +273,12 @@ def analyze_report(session: Session, settings: Settings, report: Report, llm: LL
alert_type="unknown_source_failed_both", alert_type="unknown_source_failed_both",
key=record.source_ip, key=record.source_ip,
title=f"Unknown source failed SPF and DKIM for {report.domain}", title=f"Unknown source failed SPF and DKIM for {report.domain}",
summary=f"{record.source_ip} sent {record.count} messages that failed SPF and DKIM alignment.", summary=f"{record.source_ip} sent {record.count} messages that failed SPF and DKIM alignment. {policy_context}",
details=details, details=details,
) )
) )
if record.is_known_sender and not record.dmarc_pass and record.count >= thresholds.min_messages_for_rate_alert: if record.is_known_sender and not record.dmarc_pass and record.count >= thresholds.min_messages_for_rate_alert:
policy_context = _policy_context_sentence(record, report)
created.append( created.append(
create_or_update_alert( create_or_update_alert(
session, session,
@@ -227,11 +288,12 @@ def analyze_report(session: Session, settings: Settings, report: Report, llm: LL
alert_type="known_sender_dmarc_failure", alert_type="known_sender_dmarc_failure",
key=record.known_sender_id or record.source_ip, key=record.known_sender_id or record.source_ip,
title=f"Known sender failed DMARC for {report.domain}", title=f"Known sender failed DMARC for {report.domain}",
summary=f"{record.known_sender_name or record.source_ip} failed DMARC for {record.count} messages.", summary=f"{record.known_sender_name or record.source_ip} failed DMARC for {record.count} messages. {policy_context}",
details=details, details=details,
) )
) )
if record.disposition in {"quarantine", "reject"} and record.count > 0: if record.disposition in {"quarantine", "reject"} and record.count > 0:
policy_context = _policy_context_sentence(record, report)
created.append( created.append(
create_or_update_alert( create_or_update_alert(
session, session,
@@ -241,7 +303,7 @@ def analyze_report(session: Session, settings: Settings, report: Report, llm: LL
alert_type="quarantine_or_reject_seen", alert_type="quarantine_or_reject_seen",
key=f"{record.disposition}:{record.source_ip}", key=f"{record.disposition}:{record.source_ip}",
title=f"{record.disposition.title()} disposition seen for {report.domain}", title=f"{record.disposition.title()} disposition seen for {report.domain}",
summary=f"Receiver applied {record.disposition} to {record.count} messages.", summary=f"Receiver applied {record.disposition} to {record.count} messages. {policy_context}",
details=details, details=details,
) )
) )
@@ -260,7 +322,7 @@ def analyze_report(session: Session, settings: Settings, report: Report, llm: LL
alert_type="new_unknown_source", alert_type="new_unknown_source",
key=record.source_ip, key=record.source_ip,
title=f"New unknown failing source for {report.domain}", title=f"New unknown failing source for {report.domain}",
summary=f"{record.source_ip} is newly observed and failed DMARC.", summary=f"{record.source_ip} is newly observed and failed DMARC. {_policy_context_sentence(record, report)}",
details=details, details=details,
) )
) )
+179
View File
@@ -0,0 +1,179 @@
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Callable
TxtLookup = Callable[[str], list[str]]
MxLookup = Callable[[str], list[str]]
@dataclass
class ParsedDmarcRecord:
raw: str | None = None
p: str | None = None
sp: str | None = None
pct: int | None = None
adkim: str | None = None
aspf: str | None = None
fo: str | None = None
rua: str | None = None
ruf: str | None = None
@dataclass
class ParsedSpfRecord:
raw: str | None = None
includes: list[str] = field(default_factory=list)
all_mechanism: str | None = None
@dataclass
class DkimRecord:
selector: str
query_name: str
record: str | None = None
error: str | None = None
@dataclass
class DomainDnsPolicy:
domain: str
dmarc: ParsedDmarcRecord = field(default_factory=ParsedDmarcRecord)
spf: ParsedSpfRecord = field(default_factory=ParsedSpfRecord)
dkim: list[DkimRecord] = field(default_factory=list)
mx_records: list[str] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
def _default_txt_lookup(name: str) -> list[str]:
try:
import dns.resolver
except ImportError as exc:
raise RuntimeError("dnspython is not installed") from exc
answers = dns.resolver.resolve(name, "TXT", lifetime=10)
values = []
for answer in answers:
parts = getattr(answer, "strings", None)
if parts is None:
values.append(str(answer).strip('"'))
else:
values.append("".join(part.decode("utf-8", errors="replace") for part in parts))
return values
def _default_mx_lookup(name: str) -> list[str]:
try:
import dns.resolver
except ImportError as exc:
raise RuntimeError("dnspython is not installed") from exc
answers = dns.resolver.resolve(name, "MX", lifetime=10)
return [f"{answer.preference} {str(answer.exchange).rstrip('.')}" for answer in answers]
def _tag_map(record: str) -> dict[str, str]:
tags: dict[str, str] = {}
for part in record.split(";"):
if "=" not in part:
continue
key, value = part.split("=", 1)
key = key.strip().lower()
value = value.strip()
if key:
tags[key] = value
return tags
def _int(value: str | None) -> int | None:
if not value:
return None
try:
return int(value)
except ValueError:
return None
def parse_dmarc_records(records: list[str]) -> tuple[ParsedDmarcRecord, list[str]]:
dmarc_records = [record.strip() for record in records if record.strip().lower().startswith("v=dmarc1")]
errors = []
if not dmarc_records:
return ParsedDmarcRecord(), ["DMARC TXT record not found"]
if len(dmarc_records) > 1:
errors.append("Multiple DMARC TXT records found")
raw = dmarc_records[0]
tags = _tag_map(raw)
return (
ParsedDmarcRecord(
raw=raw,
p=tags.get("p"),
sp=tags.get("sp"),
pct=_int(tags.get("pct")),
adkim=tags.get("adkim"),
aspf=tags.get("aspf"),
fo=tags.get("fo"),
rua=tags.get("rua"),
ruf=tags.get("ruf"),
),
errors,
)
def parse_spf_records(records: list[str]) -> tuple[ParsedSpfRecord, list[str]]:
spf_records = [record.strip() for record in records if record.strip().lower().startswith("v=spf1")]
errors = []
if not spf_records:
return ParsedSpfRecord(), ["SPF TXT record not found"]
if len(spf_records) > 1:
errors.append("Multiple SPF TXT records found")
raw = spf_records[0]
tokens = raw.split()
includes = [token.split(":", 1)[1] for token in tokens if token.lower().startswith("include:") and ":" in token]
all_mechanism = next((token for token in tokens if re.fullmatch(r"[+?~-]?all", token, flags=re.IGNORECASE)), None)
return ParsedSpfRecord(raw=raw, includes=includes, all_mechanism=all_mechanism), errors
def collect_domain_dns_policy(
domain: str,
*,
selectors: list[str] | None = None,
txt_lookup: TxtLookup | None = None,
mx_lookup: MxLookup | None = None,
) -> DomainDnsPolicy:
txt_lookup = txt_lookup or _default_txt_lookup
mx_lookup = mx_lookup or _default_mx_lookup
domain = domain.lower().rstrip(".")
policy = DomainDnsPolicy(domain=domain)
try:
policy.dmarc, errors = parse_dmarc_records(txt_lookup(f"_dmarc.{domain}"))
policy.errors.extend(errors)
except Exception as exc:
policy.errors.append(f"DMARC lookup failed: {exc}")
try:
policy.spf, errors = parse_spf_records(txt_lookup(domain))
policy.errors.extend(errors)
except Exception as exc:
policy.errors.append(f"SPF lookup failed: {exc}")
try:
policy.mx_records = mx_lookup(domain)
except Exception as exc:
policy.errors.append(f"MX lookup failed: {exc}")
for selector in sorted({item.strip().lower() for item in selectors or [] if item and item.strip()}):
query_name = f"{selector}._domainkey.{domain}"
try:
records = txt_lookup(query_name)
dkim_records = [record for record in records if record.strip().lower().startswith("v=dkim1")]
policy.dkim.append(DkimRecord(selector=selector, query_name=query_name, record=dkim_records[0] if dkim_records else None))
if not dkim_records:
policy.errors.append(f"DKIM record not found for selector {selector}")
except Exception as exc:
policy.dkim.append(DkimRecord(selector=selector, query_name=query_name, error=str(exc)))
policy.errors.append(f"DKIM lookup failed for selector {selector}: {exc}")
return policy
+100 -3
View File
@@ -8,7 +8,7 @@ from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from fastapi import Depends, FastAPI, HTTPException, Request from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy import desc, func, select from sqlalchemy import desc, func, select
@@ -18,10 +18,11 @@ from app import __version__
from app.auth import require_admin_csrf, require_dashboard_auth, require_homepage_token from app.auth import require_admin_csrf, require_dashboard_auth, require_homepage_token
from app.config import Settings, configure_logging, get_settings from app.config import Settings, configure_logging, get_settings
from app.db import database_ok, get_db, init_db, session_scope from app.db import database_ok, get_db, init_db, session_scope
from app.dns_policy import DomainDnsPolicy, collect_domain_dns_policy
from app.homepage import domain_homepage_summary, domain_metrics, homepage_summary, latest_summary, resolve_date_range, traffic_distribution from app.homepage import domain_homepage_summary, domain_metrics, homepage_summary, latest_summary, resolve_date_range, traffic_distribution
from app.inbox_locks import InboxRunLease, inbox_run_locks from app.inbox_locks import InboxRunLease, inbox_run_locks
from app.jobs import import_jobs from app.jobs import import_jobs
from app.models import Alert, DailyStat, InboxStatus, LLMReport, Record, Report, SkippedReportPayload, utcnow from app.models import Alert, AuthResult, DailyStat, DomainDnsSnapshot, InboxStatus, LLMReport, Record, Report, SkippedReportPayload, utcnow
from app.scheduler import generate_open_posture_summaries, scheduler_ok, start_scheduler from app.scheduler import generate_open_posture_summaries, scheduler_ok, start_scheduler
from app.schemas import BacklogRequest, ProcessNowRequest from app.schemas import BacklogRequest, ProcessNowRequest
from app.message_processor import process_inbox from app.message_processor import process_inbox
@@ -200,6 +201,12 @@ def _alert_view(alert: Alert, session: Session | None = None) -> SimpleNamespace
details = _alert_details(alert) details = _alert_details(alert)
details = _infer_alert_report_details(session, alert, details) details = _infer_alert_report_details(session, alert, details)
date_range = details.get("date_range") if isinstance(details.get("date_range"), dict) else {} date_range = details.get("date_range") if isinstance(details.get("date_range"), dict) else {}
published_policy = details.get("published_policy") if isinstance(details.get("published_policy"), dict) else {}
receiver_action = details.get("receiver_action") if isinstance(details.get("receiver_action"), dict) else {}
effective_policy = published_policy.get("effective")
effective_source = published_policy.get("effective_source") or "p"
receiver_disposition = receiver_action.get("disposition") or details.get("disposition")
override_type = receiver_action.get("override_type")
report_db_id = details.get("report_db_id") report_db_id = details.get("report_db_id")
report_db_ids = details.get("report_db_ids") if isinstance(details.get("report_db_ids"), list) else [] report_db_ids = details.get("report_db_ids") if isinstance(details.get("report_db_ids"), list) else []
if not report_db_id and isinstance(details.get("report_db_ids"), list) and details["report_db_ids"]: if not report_db_id and isinstance(details.get("report_db_ids"), list) and details["report_db_ids"]:
@@ -236,6 +243,11 @@ def _alert_view(alert: Alert, session: Session | None = None) -> SimpleNamespace
report_db_id=report_db_id, report_db_id=report_db_id,
report_db_ids=report_db_ids, report_db_ids=report_db_ids,
source_ip=details.get("source_ip"), source_ip=details.get("source_ip"),
published_policy=published_policy,
receiver_action=receiver_action,
published_policy_label=f"{effective_source}={effective_policy}" if effective_policy else None,
receiver_action_label=f"receiver {receiver_disposition}" if receiver_disposition else None,
policy_override_label=f"override {override_type}" if override_type else None,
source_history=_source_history(session, alert.domain, details.get("source_ip"), alert.type, report_db_id) if session else None, source_history=_source_history(session, alert.domain, details.get("source_ip"), alert.type, report_db_id) if session else None,
) )
@@ -380,6 +392,83 @@ def _record_auth_tooltip(record: Record, auth_type: str) -> str:
return "; ".join(items) if items else f"No {auth_type.upper()} auth result domain reported." return "; ".join(items) if items else f"No {auth_type.upper()} auth result domain reported."
def _json_list(value: str | None) -> list:
try:
data = json.loads(value or "[]")
except json.JSONDecodeError:
return []
return data if isinstance(data, list) else []
def _observed_dkim_selectors(session: Session, domain: str) -> list[str]:
rows = session.execute(
select(AuthResult.selector)
.select_from(AuthResult)
.join(Record)
.join(Report)
.where(
Report.domain == domain,
AuthResult.auth_type == "dkim",
AuthResult.selector.is_not(None),
)
.distinct()
.order_by(AuthResult.selector)
).scalars().all()
return [row for row in rows if row]
def _snapshot_model(domain: str, policy: DomainDnsPolicy) -> DomainDnsSnapshot:
return DomainDnsSnapshot(
domain=domain,
dmarc_record=policy.dmarc.raw,
dmarc_policy_p=policy.dmarc.p,
dmarc_policy_sp=policy.dmarc.sp,
dmarc_policy_pct=policy.dmarc.pct,
dmarc_adkim=policy.dmarc.adkim,
dmarc_aspf=policy.dmarc.aspf,
dmarc_fo=policy.dmarc.fo,
dmarc_rua=policy.dmarc.rua,
dmarc_ruf=policy.dmarc.ruf,
spf_record=policy.spf.raw,
spf_all=policy.spf.all_mechanism,
spf_includes_json=json.dumps(policy.spf.includes, sort_keys=True),
dkim_records_json=json.dumps([item.__dict__ for item in policy.dkim], sort_keys=True),
mx_records_json=json.dumps(policy.mx_records, sort_keys=True),
errors_json=json.dumps(policy.errors, sort_keys=True),
)
def _latest_dns_snapshot(session: Session, domain: str) -> SimpleNamespace | None:
snapshot = session.scalar(
select(DomainDnsSnapshot)
.where(DomainDnsSnapshot.domain == domain)
.order_by(desc(DomainDnsSnapshot.checked_at), desc(DomainDnsSnapshot.id))
.limit(1)
)
if not snapshot:
return None
return SimpleNamespace(
id=snapshot.id,
domain=snapshot.domain,
checked_at=snapshot.checked_at,
dmarc_record=snapshot.dmarc_record,
dmarc_policy_p=snapshot.dmarc_policy_p,
dmarc_policy_sp=snapshot.dmarc_policy_sp,
dmarc_policy_pct=snapshot.dmarc_policy_pct,
dmarc_adkim=snapshot.dmarc_adkim,
dmarc_aspf=snapshot.dmarc_aspf,
dmarc_fo=snapshot.dmarc_fo,
dmarc_rua=snapshot.dmarc_rua,
dmarc_ruf=snapshot.dmarc_ruf,
spf_record=snapshot.spf_record,
spf_all=snapshot.spf_all,
spf_includes=_json_list(snapshot.spf_includes_json),
dkim_records=_json_list(snapshot.dkim_records_json),
mx_records=_json_list(snapshot.mx_records_json),
errors=_json_list(snapshot.errors_json),
)
@app.get("/domains/{domain}", response_class=HTMLResponse, dependencies=[Depends(require_dashboard_auth)]) @app.get("/domains/{domain}", response_class=HTMLResponse, dependencies=[Depends(require_dashboard_auth)])
def domain_page(domain: str, request: Request, source_page: int = 1, alert_page: int = 1, report_page: int = 1, trend_page: int = 1, session: Session = Depends(get_db)): def domain_page(domain: str, request: Request, source_page: int = 1, alert_page: int = 1, report_page: int = 1, trend_page: int = 1, session: Session = Depends(get_db)):
metrics = domain_metrics(session, domain) metrics = domain_metrics(session, domain)
@@ -443,10 +532,19 @@ def domain_page(domain: str, request: Request, source_page: int = 1, alert_page:
"dispositions": dispositions, "dispositions": dispositions,
"known_unknown": known_unknown, "known_unknown": known_unknown,
"summary": latest_summary(session, domain), "summary": latest_summary(session, domain),
"dns_snapshot": _latest_dns_snapshot(session, domain),
}, },
) )
@app.post("/domains/{domain}/dns/refresh", dependencies=dashboard_post_auth)
def refresh_domain_dns(domain: str, session: Session = Depends(get_db)):
policy = collect_domain_dns_policy(domain, selectors=_observed_dkim_selectors(session, domain))
session.add(_snapshot_model(domain, policy))
session.commit()
return RedirectResponse(url=f"/domains/{domain}", status_code=303)
@app.get("/reports/{report_id}", response_class=HTMLResponse, dependencies=[Depends(require_dashboard_auth)]) @app.get("/reports/{report_id}", response_class=HTMLResponse, dependencies=[Depends(require_dashboard_auth)])
def report_page(report_id: int, request: Request, session: Session = Depends(get_db)): def report_page(report_id: int, request: Request, session: Session = Depends(get_db)):
report = session.scalar(select(Report).options(selectinload(Report.records).selectinload(Record.auth_results)).where(Report.id == report_id)) report = session.scalar(select(Report).options(selectinload(Report.records).selectinload(Record.auth_results)).where(Report.id == report_id))
@@ -915,4 +1013,3 @@ def api_import_job(job_id: str):
@app.get("/api/admin/inboxes/{inbox_id}/status", dependencies=[Depends(require_dashboard_auth)]) @app.get("/api/admin/inboxes/{inbox_id}/status", dependencies=[Depends(require_dashboard_auth)])
def api_inbox_status(inbox_id: str, session: Session = Depends(get_db)): def api_inbox_status(inbox_id: str, session: Session = Depends(get_db)):
return _inbox_status_payload(inbox_id, session) return _inbox_status_payload(inbox_id, session)
+23
View File
@@ -167,6 +167,29 @@ class Alert(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
class DomainDnsSnapshot(Base):
__tablename__ = "domain_dns_snapshots"
id: Mapped[int] = mapped_column(primary_key=True)
domain: Mapped[str] = mapped_column(String(255), index=True)
checked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
dmarc_record: Mapped[str | None] = mapped_column(Text)
dmarc_policy_p: Mapped[str | None] = mapped_column(String(40))
dmarc_policy_sp: Mapped[str | None] = mapped_column(String(40))
dmarc_policy_pct: Mapped[int | None] = mapped_column(Integer)
dmarc_adkim: Mapped[str | None] = mapped_column(String(20))
dmarc_aspf: Mapped[str | None] = mapped_column(String(20))
dmarc_fo: Mapped[str | None] = mapped_column(String(80))
dmarc_rua: Mapped[str | None] = mapped_column(Text)
dmarc_ruf: Mapped[str | None] = mapped_column(Text)
spf_record: Mapped[str | None] = mapped_column(Text)
spf_all: Mapped[str | None] = mapped_column(String(20))
spf_includes_json: Mapped[str] = mapped_column(Text, default="[]")
dkim_records_json: Mapped[str] = mapped_column(Text, default="[]")
mx_records_json: Mapped[str] = mapped_column(Text, default="[]")
errors_json: Mapped[str] = mapped_column(Text, default="[]")
class DailyStat(Base): class DailyStat(Base):
__tablename__ = "daily_stats" __tablename__ = "daily_stats"
__table_args__ = (UniqueConstraint("domain", "date", name="uq_daily_stat_domain_date"),) __table_args__ = (UniqueConstraint("domain", "date", name="uq_daily_stat_domain_date"),)
+74
View File
@@ -785,6 +785,7 @@ button {
} }
.dw-domain-summary-section, .dw-domain-summary-section,
.dw-domain-dns-section,
.dw-domain-main-grid, .dw-domain-main-grid,
.dw-domain-lower-grid, .dw-domain-lower-grid,
.dw-reports-section { .dw-reports-section {
@@ -1139,6 +1140,78 @@ button {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
.dw-section-heading {
align-items: center;
display: flex;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.dw-section-heading .dw-panel-title {
margin-bottom: 0;
}
.dw-dns-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.dw-dns-panel {
background: var(--dw-surface);
border: 1px solid var(--dw-border);
min-width: 0;
padding: 16px;
}
.dw-policy-chip-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 10px 0;
}
.dw-dns-record,
.dw-dns-record-list code {
background: var(--dw-surface-low);
border: 1px solid var(--dw-border-soft);
color: var(--dw-text);
display: block;
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
line-height: 18px;
overflow-wrap: anywhere;
padding: 10px;
}
.dw-dns-record-list {
display: grid;
gap: 8px;
}
.dw-dns-record-list strong {
display: block;
font-size: 12px;
line-height: 18px;
margin-bottom: 4px;
}
.dw-dns-errors {
display: grid;
gap: 6px;
margin-top: 12px;
}
.dw-dns-errors span {
background: var(--dw-warning-soft);
border: 1px solid #fde68a;
color: var(--dw-warning);
font-size: 12px;
line-height: 18px;
padding: 8px 10px;
}
.dw-panel-title { .dw-panel-title {
color: var(--dw-text); color: var(--dw-text);
font-size: 20px; font-size: 20px;
@@ -1289,6 +1362,7 @@ button {
.dw-domain-main-grid, .dw-domain-main-grid,
.dw-domain-lower-grid, .dw-domain-lower-grid,
.dw-dns-grid,
.dw-settings-layout, .dw-settings-layout,
.dw-settings-board, .dw-settings-board,
.dw-inbox-row { .dw-inbox-row {
+13
View File
@@ -98,6 +98,19 @@
<a class="dw-inline-link" href="/reports/{{ alert.report_db_id }}">Source report</a> <a class="dw-inline-link" href="/reports/{{ alert.report_db_id }}">Source report</a>
{% endif %} {% endif %}
</div> </div>
{% if alert.published_policy_label or alert.receiver_action_label %}
<div class="mt-stack-sm flex flex-wrap items-center gap-stack-sm">
{% if alert.published_policy_label %}
<span class="status-chip chip-info">published {{ alert.published_policy_label }}</span>
{% endif %}
{% if alert.receiver_action_label %}
<span class="status-chip {{ 'chip-fail' if alert.receiver_action_label == 'receiver reject' else ('chip-warning' if alert.receiver_action_label == 'receiver quarantine' else 'chip-info') }}">{{ alert.receiver_action_label }}</span>
{% endif %}
{% if alert.policy_override_label %}
<span class="status-chip chip-warning">{{ alert.policy_override_label }}</span>
{% endif %}
</div>
{% endif %}
<p class="mt-stack-md text-body-base text-on-surface-variant">{{ alert.llm_summary or alert.summary }}</p> <p class="mt-stack-md text-body-base text-on-surface-variant">{{ alert.llm_summary or alert.summary }}</p>
{% if alert.llm_recommended_action %} {% if alert.llm_recommended_action %}
<p class="mt-stack-sm text-body-sm italic text-secondary">{{ alert.llm_recommended_action }}</p> <p class="mt-stack-sm text-body-sm italic text-secondary">{{ alert.llm_recommended_action }}</p>
+1 -1
View File
@@ -12,7 +12,7 @@
<script> <script>
window.adminPostHeaders = { "X-Requested-With": "XMLHttpRequest" }; window.adminPostHeaders = { "X-Requested-With": "XMLHttpRequest" };
</script> </script>
<link rel="stylesheet" href="/static/app.css?v=9"> <link rel="stylesheet" href="/static/app.css?v=10">
</head> </head>
<body> <body>
<header class="dw-topbar"> <header class="dw-topbar">
+90
View File
@@ -25,6 +25,89 @@
</article> </article>
</section> </section>
<section class="dw-domain-dns-section">
<div class="dw-section-heading">
<h2 class="dw-panel-title">DNS Policy</h2>
<form method="post" action="/domains/{{ domain }}/dns/refresh">
<button class="button-secondary" type="submit">
<span class="material-symbols-outlined text-[18px]">refresh</span>
Refresh
</button>
</form>
</div>
{% if dns_snapshot %}
<div class="dw-dns-grid">
<article class="dw-dns-panel">
<div class="dw-list-row">
<span>DMARC</span>
<span class="dw-chip {{ 'dw-chip-fail' if dns_snapshot.dmarc_policy_p == 'reject' else ('dw-chip-warning' if dns_snapshot.dmarc_policy_p == 'quarantine' else 'dw-chip-info') }}">p={{ dns_snapshot.dmarc_policy_p or "missing" }}</span>
</div>
<div class="dw-policy-chip-row">
<span class="status-chip chip-info">sp={{ dns_snapshot.dmarc_policy_sp or "inherit" }}</span>
<span class="status-chip chip-info">pct={{ dns_snapshot.dmarc_policy_pct if dns_snapshot.dmarc_policy_pct is not none else "unset" }}</span>
<span class="status-chip chip-info">adkim={{ dns_snapshot.dmarc_adkim or "r" }}</span>
<span class="status-chip chip-info">aspf={{ dns_snapshot.dmarc_aspf or "r" }}</span>
</div>
<code class="dw-dns-record">{{ dns_snapshot.dmarc_record or "No DMARC record found." }}</code>
</article>
<article class="dw-dns-panel">
<div class="dw-list-row">
<span>SPF</span>
<span class="dw-chip {{ 'dw-chip-pass' if dns_snapshot.spf_record else 'dw-chip-warning' }}">{{ dns_snapshot.spf_all or "missing" }}</span>
</div>
<div class="dw-policy-chip-row">
{% for include in dns_snapshot.spf_includes %}
<span class="status-chip chip-info">include:{{ include }}</span>
{% else %}
<span class="status-chip chip-info">no includes</span>
{% endfor %}
</div>
<code class="dw-dns-record">{{ dns_snapshot.spf_record or "No SPF record found." }}</code>
</article>
<article class="dw-dns-panel">
<div class="dw-list-row">
<span>DKIM</span>
<span class="dw-chip dw-chip-info">{{ dns_snapshot.dkim_records | length }} selectors</span>
</div>
<div class="dw-dns-record-list">
{% for item in dns_snapshot.dkim_records %}
<div>
<strong>{{ item.selector }}</strong>
<code>{{ item.record or item.error or "No DKIM record found." }}</code>
</div>
{% else %}
<code>No observed DKIM selectors yet.</code>
{% endfor %}
</div>
</article>
<article class="dw-dns-panel">
<div class="dw-list-row">
<span>MX</span>
<time>{{ dns_snapshot.checked_at | fmt_dt }}</time>
</div>
<div class="dw-dns-record-list">
{% for mx in dns_snapshot.mx_records %}
<code>{{ mx }}</code>
{% else %}
<code>No MX records found.</code>
{% endfor %}
</div>
{% if dns_snapshot.errors %}
<div class="dw-dns-errors">
{% for error in dns_snapshot.errors %}
<span>{{ error }}</span>
{% endfor %}
</div>
{% endif %}
</article>
</div>
{% else %}
<div class="dw-list-card">
<div class="dw-list-empty">No DNS snapshot yet.</div>
</div>
{% endif %}
</section>
<section class="dw-domain-summary-section"> <section class="dw-domain-summary-section">
<h2 class="dw-sidebar-kicker">Latest LLM Posture Summary</h2> <h2 class="dw-sidebar-kicker">Latest LLM Posture Summary</h2>
<article class="dw-summary-card dw-domain-summary-card"> <article class="dw-summary-card dw-domain-summary-card">
@@ -106,6 +189,13 @@
<time>{{ (alert.report_end or alert.report_start or alert.report_time) | fmt_dt }}</time> <time>{{ (alert.report_end or alert.report_start or alert.report_time) | fmt_dt }}</time>
</span> </span>
<strong>{{ alert.title }}</strong> <strong>{{ alert.title }}</strong>
{% if alert.published_policy_label or alert.receiver_action_label %}
<span class="dw-policy-chip-row">
{% if alert.published_policy_label %}<span class="status-chip chip-info">published {{ alert.published_policy_label }}</span>{% endif %}
{% if alert.receiver_action_label %}<span class="status-chip {{ 'chip-fail' if alert.receiver_action_label == 'receiver reject' else ('chip-warning' if alert.receiver_action_label == 'receiver quarantine' else 'chip-info') }}">{{ alert.receiver_action_label }}</span>{% endif %}
{% if alert.policy_override_label %}<span class="status-chip chip-warning">{{ alert.policy_override_label }}</span>{% endif %}
</span>
{% endif %}
<p>{{ alert.llm_summary or alert.summary }}</p> <p>{{ alert.llm_summary or alert.summary }}</p>
</a> </a>
{% else %} {% else %}
+8 -1
View File
@@ -32,7 +32,7 @@ There is no repository-local Ruff configuration or requirement in `requirements.
The Docker image starts the FastAPI app with: The Docker image starts the FastAPI app with:
```bash ```bash
uvicorn app.main:app --host 0.0.0.0 --port 8000 alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000
``` ```
The compose service: The compose service:
@@ -45,6 +45,13 @@ The compose service:
- attaches to the external `npm_proxy` network with static address `192.168.99.18`. - attaches to the external `npm_proxy` network with static address `192.168.99.18`.
The app initializes database tables on import through `app.main` calling `init_db()`. The app initializes database tables on import through `app.main` calling `init_db()`.
Alembic migrations are run first in Docker and are the preservation path for existing SQLite databases. `init_db()` remains as a lightweight fallback for missing tables in local/test workflows.
Manual migration command inside the container:
```bash
docker compose exec dmarc-sentinel alembic upgrade head
```
## CLI Backlog Processing ## CLI Backlog Processing
-1
View File
@@ -1 +0,0 @@
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.config import get_settings
from app.db import Base
from app import models # noqa: F401
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def _database_url() -> str:
return get_settings().app.database_url
def run_migrations_offline() -> None:
context.configure(
url=_database_url(),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
section = config.get_section(config.config_ini_section, {})
section["sqlalchemy.url"] = _database_url()
connectable = engine_from_config(
section,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+24
View File
@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,66 @@
"""add domain dns snapshots
Revision ID: 20260520_0001
Revises:
Create Date: 2026-05-20 00:00:00.000000+00:00
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260520_0001"
down_revision = None
branch_labels = None
depends_on = None
def _domain_dns_snapshots() -> sa.Table:
return sa.Table(
"domain_dns_snapshots",
sa.MetaData(),
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("domain", sa.String(length=255), nullable=False),
sa.Column("checked_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("dmarc_record", sa.Text(), nullable=True),
sa.Column("dmarc_policy_p", sa.String(length=40), nullable=True),
sa.Column("dmarc_policy_sp", sa.String(length=40), nullable=True),
sa.Column("dmarc_policy_pct", sa.Integer(), nullable=True),
sa.Column("dmarc_adkim", sa.String(length=20), nullable=True),
sa.Column("dmarc_aspf", sa.String(length=20), nullable=True),
sa.Column("dmarc_fo", sa.String(length=80), nullable=True),
sa.Column("dmarc_rua", sa.Text(), nullable=True),
sa.Column("dmarc_ruf", sa.Text(), nullable=True),
sa.Column("spf_record", sa.Text(), nullable=True),
sa.Column("spf_all", sa.String(length=20), nullable=True),
sa.Column("spf_includes_json", sa.Text(), nullable=False, server_default="[]"),
sa.Column("dkim_records_json", sa.Text(), nullable=False, server_default="[]"),
sa.Column("mx_records_json", sa.Text(), nullable=False, server_default="[]"),
sa.Column("errors_json", sa.Text(), nullable=False, server_default="[]"),
)
def upgrade() -> None:
bind = op.get_bind()
table = _domain_dns_snapshots()
table.create(bind=bind, checkfirst=True)
op.create_index(
op.f("ix_domain_dns_snapshots_checked_at"),
"domain_dns_snapshots",
["checked_at"],
unique=False,
if_not_exists=True,
)
op.create_index(
op.f("ix_domain_dns_snapshots_domain"),
"domain_dns_snapshots",
["domain"],
unique=False,
if_not_exists=True,
)
def downgrade() -> None:
op.drop_index(op.f("ix_domain_dns_snapshots_domain"), table_name="domain_dns_snapshots", if_exists=True)
op.drop_index(op.f("ix_domain_dns_snapshots_checked_at"), table_name="domain_dns_snapshots", if_exists=True)
_domain_dns_snapshots().drop(bind=op.get_bind(), checkfirst=True)
+2
View File
@@ -7,7 +7,9 @@ PyYAML==6.0.2
Jinja2==3.1.5 Jinja2==3.1.5
python-multipart==0.0.20 python-multipart==0.0.20
APScheduler==3.10.4 APScheduler==3.10.4
alembic==1.14.0
defusedxml==0.7.1 defusedxml==0.7.1
openai==1.58.1 openai==1.58.1
pytest==8.3.4 pytest==8.3.4
httpx==0.28.1 httpx==0.28.1
dnspython==2.7.0
+35 -1
View File
@@ -41,6 +41,11 @@ def _report(
dkim_aligned: bool | None = None, dkim_aligned: bool | None = None,
report_time: datetime | None = None, report_time: datetime | None = None,
org_name: str = "google.com", org_name: str = "google.com",
policy_p: str | None = None,
policy_sp: str | None = None,
policy_pct: int | None = None,
disposition: str = "none",
reason_type: str | None = None,
) -> Report: ) -> Report:
dkim_aligned = dmarc_pass if dkim_aligned is None else dkim_aligned dkim_aligned = dmarc_pass if dkim_aligned is None else dkim_aligned
report_time = report_time or datetime.now(timezone.utc) report_time = report_time or datetime.now(timezone.utc)
@@ -52,6 +57,9 @@ def _report(
domain="tukutoi.com", domain="tukutoi.com",
date_begin=report_time - timedelta(hours=1), date_begin=report_time - timedelta(hours=1),
date_end=report_time, date_end=report_time,
policy_p=policy_p,
policy_sp=policy_sp,
policy_pct=policy_pct,
) )
session.add(report) session.add(report)
session.flush() session.flush()
@@ -60,7 +68,7 @@ def _report(
report=report, report=report,
source_ip=source_ip, source_ip=source_ip,
count=count, count=count,
disposition="none", disposition=disposition,
policy_dkim="pass" if dkim_aligned else "fail", policy_dkim="pass" if dkim_aligned else "fail",
policy_spf="pass" if spf_aligned else "fail", policy_spf="pass" if spf_aligned else "fail",
dkim_aligned=dkim_aligned, dkim_aligned=dkim_aligned,
@@ -70,6 +78,7 @@ def _report(
known_sender_id="mailcow" if known else None, known_sender_id="mailcow" if known else None,
known_sender_name="mailcow outbound" if known else None, known_sender_name="mailcow outbound" if known else None,
is_known_sender=known, is_known_sender=known,
reason_type=reason_type,
) )
) )
session.commit() session.commit()
@@ -175,3 +184,28 @@ def test_missing_reporter_gap_does_not_create_alert():
alerts = analyze_report(session, settings, report) alerts = analyze_report(session, settings, report)
assert not any(alert.type == "missing_reporter" for alert, _, _ in alerts) assert not any(alert.type == "missing_reporter" for alert, _, _ in alerts)
def test_alert_details_include_published_policy_and_receiver_action():
session = _session()
report = _report(
session,
source_ip="203.0.113.91",
count=25,
known=False,
dmarc_pass=False,
policy_p="reject",
policy_sp="quarantine",
policy_pct=100,
disposition="reject",
)
alerts = analyze_report(session, _settings(), report)
alert = next(alert for alert, _, _ in alerts if alert.type == "unknown_source_failed_both")
details = json.loads(alert.details_json)
assert details["published_policy"]["p"] == "reject"
assert details["published_policy"]["effective"] == "reject"
assert details["published_policy"]["effective_source"] == "p"
assert details["receiver_action"]["disposition"] == "reject"
assert "Published DMARC policy was p=reject; pct=100" in alert.summary
+47
View File
@@ -0,0 +1,47 @@
from app.dns_policy import collect_domain_dns_policy, parse_dmarc_records, parse_spf_records
def test_parse_dmarc_record_extracts_policy_tags():
parsed, errors = parse_dmarc_records(["v=DMARC1; p=reject; sp=quarantine; pct=50; adkim=s; aspf=r; rua=mailto:d@example.com"])
assert errors == []
assert parsed.p == "reject"
assert parsed.sp == "quarantine"
assert parsed.pct == 50
assert parsed.adkim == "s"
assert parsed.rua == "mailto:d@example.com"
def test_parse_spf_record_extracts_includes_and_all_mechanism():
parsed, errors = parse_spf_records(["v=spf1 include:_spf.google.com include:mailgun.org -all"])
assert errors == []
assert parsed.includes == ["_spf.google.com", "mailgun.org"]
assert parsed.all_mechanism == "-all"
def test_collect_domain_dns_policy_uses_observed_dkim_selectors():
txt_records = {
"_dmarc.example.com": ["v=DMARC1; p=reject; pct=100"],
"example.com": ["v=spf1 include:_spf.example.net -all"],
"s1._domainkey.example.com": ["v=DKIM1; k=rsa; p=abc"],
}
def txt_lookup(name: str) -> list[str]:
if name not in txt_records:
raise RuntimeError("not found")
return txt_records[name]
policy = collect_domain_dns_policy(
"example.com",
selectors=["s1"],
txt_lookup=txt_lookup,
mx_lookup=lambda name: ["10 mail.example.com"],
)
assert policy.dmarc.p == "reject"
assert policy.spf.all_mechanism == "-all"
assert policy.mx_records == ["10 mail.example.com"]
assert policy.dkim[0].selector == "s1"
assert policy.dkim[0].record == "v=DKIM1; k=rsa; p=abc"
assert policy.errors == []