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
+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)
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,
)
)
+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 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)
+23
View File
@@ -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"),)
+74
View File
@@ -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 {
+13
View File
@@ -98,6 +98,19 @@
<a class="dw-inline-link" href="/reports/{{ alert.report_db_id }}">Source report</a>
{% endif %}
</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>
{% if alert.llm_recommended_action %}
<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>
window.adminPostHeaders = { "X-Requested-With": "XMLHttpRequest" };
</script>
<link rel="stylesheet" href="/static/app.css?v=9">
<link rel="stylesheet" href="/static/app.css?v=10">
</head>
<body>
<header class="dw-topbar">
+90
View File
@@ -25,6 +25,89 @@
</article>
</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">
<h2 class="dw-sidebar-kicker">Latest LLM Posture Summary</h2>
<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>
</span>
<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>
</a>
{% else %}