Add db migration and DNS dmarc entries
This commit is contained in:
+66
-4
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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"),)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user