diff --git a/Dockerfile b/Dockerfile index c1018ec..7ce8a03 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,9 @@ RUN apt-get update \ COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt +COPY alembic.ini . COPY app ./app +COPY migrations ./migrations RUN mkdir -p /app/config /app/data /app/logs RUN groupadd --gid 1000 app \ @@ -21,4 +23,4 @@ RUN groupadd --gid 1000 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"] diff --git a/README.md b/README.md index 6b43b59..f55f244 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ nano config/config.yml 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: ```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. +## 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 Endpoint: diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..030992b --- /dev/null +++ b/alembic.ini @@ -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 diff --git a/app/analyzer.py b/app/analyzer.py index 7b848ed..7aba525 100644 --- a/app/analyzer.py +++ b/app/analyzer.py @@ -62,6 +62,60 @@ def _merge_details(existing: str, incoming: dict[str, Any]) -> str: 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( session: Session, *, @@ -112,6 +166,11 @@ def _record_details(record: Record, report: Report) -> dict[str, Any]: "dkim_aligned": record.dkim_aligned, "dmarc_pass": record.dmarc_pass, "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_id": record.known_sender_id, "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: 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: + policy_context = _policy_context_sentence(record, report) created.append( create_or_update_alert( session, @@ -213,11 +273,12 @@ def analyze_report(session: Session, settings: Settings, report: Report, llm: LL alert_type="unknown_source_failed_both", key=record.source_ip, 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, ) ) 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( create_or_update_alert( session, @@ -227,11 +288,12 @@ def analyze_report(session: Session, settings: Settings, report: Report, llm: LL alert_type="known_sender_dmarc_failure", key=record.known_sender_id or record.source_ip, 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, ) ) if record.disposition in {"quarantine", "reject"} and record.count > 0: + policy_context = _policy_context_sentence(record, report) created.append( create_or_update_alert( session, @@ -241,7 +303,7 @@ def analyze_report(session: Session, settings: Settings, report: Report, llm: LL alert_type="quarantine_or_reject_seen", key=f"{record.disposition}:{record.source_ip}", 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, ) ) @@ -260,7 +322,7 @@ def analyze_report(session: Session, settings: Settings, report: Report, llm: LL alert_type="new_unknown_source", key=record.source_ip, 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, ) ) diff --git a/app/dns_policy.py b/app/dns_policy.py new file mode 100644 index 0000000..ec4b1e4 --- /dev/null +++ b/app/dns_policy.py @@ -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 diff --git a/app/main.py b/app/main.py index 63cb881..bafb2e9 100644 --- a/app/main.py +++ b/app/main.py @@ -8,7 +8,7 @@ from pathlib import Path from types import SimpleNamespace 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.templating import Jinja2Templates 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.config import Settings, configure_logging, get_settings 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.inbox_locks import InboxRunLease, inbox_run_locks 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.schemas import BacklogRequest, ProcessNowRequest 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 = _infer_alert_report_details(session, alert, details) 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_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"]: @@ -236,6 +243,11 @@ def _alert_view(alert: Alert, session: Session | None = None) -> SimpleNamespace report_db_id=report_db_id, report_db_ids=report_db_ids, 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, ) @@ -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." +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)]) 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) @@ -443,10 +532,19 @@ def domain_page(domain: str, request: Request, source_page: int = 1, alert_page: "dispositions": dispositions, "known_unknown": known_unknown, "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)]) 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)) @@ -915,4 +1013,3 @@ def api_import_job(job_id: str): @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)): return _inbox_status_payload(inbox_id, session) - diff --git a/app/models.py b/app/models.py index 9594a53..0f602b0 100644 --- a/app/models.py +++ b/app/models.py @@ -167,6 +167,29 @@ class Alert(Base): 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): __tablename__ = "daily_stats" __table_args__ = (UniqueConstraint("domain", "date", name="uq_daily_stat_domain_date"),) diff --git a/app/static/app.css b/app/static/app.css index ac71844..21fd85e 100644 --- a/app/static/app.css +++ b/app/static/app.css @@ -785,6 +785,7 @@ button { } .dw-domain-summary-section, +.dw-domain-dns-section, .dw-domain-main-grid, .dw-domain-lower-grid, .dw-reports-section { @@ -1139,6 +1140,78 @@ button { 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 { color: var(--dw-text); font-size: 20px; @@ -1289,6 +1362,7 @@ button { .dw-domain-main-grid, .dw-domain-lower-grid, + .dw-dns-grid, .dw-settings-layout, .dw-settings-board, .dw-inbox-row { diff --git a/app/templates/alerts.html b/app/templates/alerts.html index 173a8d9..40fd740 100644 --- a/app/templates/alerts.html +++ b/app/templates/alerts.html @@ -98,6 +98,19 @@ Source report {% endif %} + {% if alert.published_policy_label or alert.receiver_action_label %} +
+ {% if alert.published_policy_label %} + published {{ alert.published_policy_label }} + {% endif %} + {% if alert.receiver_action_label %} + {{ alert.receiver_action_label }} + {% endif %} + {% if alert.policy_override_label %} + {{ alert.policy_override_label }} + {% endif %} +
+ {% endif %}

{{ alert.llm_summary or alert.summary }}

{% if alert.llm_recommended_action %}

{{ alert.llm_recommended_action }}

diff --git a/app/templates/base.html b/app/templates/base.html index f7ba4fa..e2c5b90 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -12,7 +12,7 @@ - +
diff --git a/app/templates/domain.html b/app/templates/domain.html index 54926b3..18444ce 100644 --- a/app/templates/domain.html +++ b/app/templates/domain.html @@ -25,6 +25,89 @@ +
+
+

DNS Policy

+
+ +
+
+ {% if dns_snapshot %} +
+
+
+ DMARC + p={{ dns_snapshot.dmarc_policy_p or "missing" }} +
+
+ sp={{ dns_snapshot.dmarc_policy_sp or "inherit" }} + pct={{ dns_snapshot.dmarc_policy_pct if dns_snapshot.dmarc_policy_pct is not none else "unset" }} + adkim={{ dns_snapshot.dmarc_adkim or "r" }} + aspf={{ dns_snapshot.dmarc_aspf or "r" }} +
+ {{ dns_snapshot.dmarc_record or "No DMARC record found." }} +
+
+
+ SPF + {{ dns_snapshot.spf_all or "missing" }} +
+
+ {% for include in dns_snapshot.spf_includes %} + include:{{ include }} + {% else %} + no includes + {% endfor %} +
+ {{ dns_snapshot.spf_record or "No SPF record found." }} +
+
+
+ DKIM + {{ dns_snapshot.dkim_records | length }} selectors +
+
+ {% for item in dns_snapshot.dkim_records %} +
+ {{ item.selector }} + {{ item.record or item.error or "No DKIM record found." }} +
+ {% else %} + No observed DKIM selectors yet. + {% endfor %} +
+
+
+
+ MX + +
+
+ {% for mx in dns_snapshot.mx_records %} + {{ mx }} + {% else %} + No MX records found. + {% endfor %} +
+ {% if dns_snapshot.errors %} +
+ {% for error in dns_snapshot.errors %} + {{ error }} + {% endfor %} +
+ {% endif %} +
+
+ {% else %} +
+
No DNS snapshot yet.
+
+ {% endif %} +
+

Latest LLM Posture Summary

@@ -106,6 +189,13 @@ {{ alert.title }} + {% if alert.published_policy_label or alert.receiver_action_label %} + + {% if alert.published_policy_label %}published {{ alert.published_policy_label }}{% endif %} + {% if alert.receiver_action_label %}{{ alert.receiver_action_label }}{% endif %} + {% if alert.policy_override_label %}{{ alert.policy_override_label }}{% endif %} + + {% endif %}

{{ alert.llm_summary or alert.summary }}

{% else %} diff --git a/docs/runtime.md b/docs/runtime.md index b780953..79eac22 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -32,7 +32,7 @@ There is no repository-local Ruff configuration or requirement in `requirements. The Docker image starts the FastAPI app with: ```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: @@ -45,6 +45,13 @@ The compose service: - 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()`. +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 diff --git a/logs/.gitkeep b/logs/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/logs/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..b20bf83 --- /dev/null +++ b/migrations/env.py @@ -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() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..c6284cb --- /dev/null +++ b/migrations/script.py.mako @@ -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"} diff --git a/migrations/versions/20260520_0001_domain_dns_snapshots.py b/migrations/versions/20260520_0001_domain_dns_snapshots.py new file mode 100644 index 0000000..85f97a3 --- /dev/null +++ b/migrations/versions/20260520_0001_domain_dns_snapshots.py @@ -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) diff --git a/requirements.txt b/requirements.txt index 6848ddc..a12e683 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,9 @@ PyYAML==6.0.2 Jinja2==3.1.5 python-multipart==0.0.20 APScheduler==3.10.4 +alembic==1.14.0 defusedxml==0.7.1 openai==1.58.1 pytest==8.3.4 httpx==0.28.1 +dnspython==2.7.0 diff --git a/tests/test_analyzer.py b/tests/test_analyzer.py index 57a8fa3..15afac0 100644 --- a/tests/test_analyzer.py +++ b/tests/test_analyzer.py @@ -41,6 +41,11 @@ def _report( dkim_aligned: bool | None = None, report_time: datetime | None = None, 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: dkim_aligned = dmarc_pass if dkim_aligned is None else dkim_aligned report_time = report_time or datetime.now(timezone.utc) @@ -52,6 +57,9 @@ def _report( domain="tukutoi.com", date_begin=report_time - timedelta(hours=1), date_end=report_time, + policy_p=policy_p, + policy_sp=policy_sp, + policy_pct=policy_pct, ) session.add(report) session.flush() @@ -60,7 +68,7 @@ def _report( report=report, source_ip=source_ip, count=count, - disposition="none", + disposition=disposition, policy_dkim="pass" if dkim_aligned else "fail", policy_spf="pass" if spf_aligned else "fail", dkim_aligned=dkim_aligned, @@ -70,6 +78,7 @@ def _report( known_sender_id="mailcow" if known else None, known_sender_name="mailcow outbound" if known else None, is_known_sender=known, + reason_type=reason_type, ) ) session.commit() @@ -175,3 +184,28 @@ def test_missing_reporter_gap_does_not_create_alert(): alerts = analyze_report(session, settings, report) 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 diff --git a/tests/test_dns_policy.py b/tests/test_dns_policy.py new file mode 100644 index 0000000..08fdf6d --- /dev/null +++ b/tests/test_dns_policy.py @@ -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 == []