Add db migration and DNS dmarc entries
This commit is contained in:
+3
-1
@@ -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"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
[alembic]
|
||||
script_location = migrations
|
||||
prepend_sys_path = .
|
||||
timezone = UTC
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
+66
-4
@@ -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 %}
|
||||
|
||||
+8
-1
@@ -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
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,66 @@
|
||||
"""add domain dns snapshots
|
||||
|
||||
Revision ID: 20260520_0001
|
||||
Revises:
|
||||
Create Date: 2026-05-20 00:00:00.000000+00:00
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "20260520_0001"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _domain_dns_snapshots() -> sa.Table:
|
||||
return sa.Table(
|
||||
"domain_dns_snapshots",
|
||||
sa.MetaData(),
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("domain", sa.String(length=255), nullable=False),
|
||||
sa.Column("checked_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("dmarc_record", sa.Text(), nullable=True),
|
||||
sa.Column("dmarc_policy_p", sa.String(length=40), nullable=True),
|
||||
sa.Column("dmarc_policy_sp", sa.String(length=40), nullable=True),
|
||||
sa.Column("dmarc_policy_pct", sa.Integer(), nullable=True),
|
||||
sa.Column("dmarc_adkim", sa.String(length=20), nullable=True),
|
||||
sa.Column("dmarc_aspf", sa.String(length=20), nullable=True),
|
||||
sa.Column("dmarc_fo", sa.String(length=80), nullable=True),
|
||||
sa.Column("dmarc_rua", sa.Text(), nullable=True),
|
||||
sa.Column("dmarc_ruf", sa.Text(), nullable=True),
|
||||
sa.Column("spf_record", sa.Text(), nullable=True),
|
||||
sa.Column("spf_all", sa.String(length=20), nullable=True),
|
||||
sa.Column("spf_includes_json", sa.Text(), nullable=False, server_default="[]"),
|
||||
sa.Column("dkim_records_json", sa.Text(), nullable=False, server_default="[]"),
|
||||
sa.Column("mx_records_json", sa.Text(), nullable=False, server_default="[]"),
|
||||
sa.Column("errors_json", sa.Text(), nullable=False, server_default="[]"),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
table = _domain_dns_snapshots()
|
||||
table.create(bind=bind, checkfirst=True)
|
||||
op.create_index(
|
||||
op.f("ix_domain_dns_snapshots_checked_at"),
|
||||
"domain_dns_snapshots",
|
||||
["checked_at"],
|
||||
unique=False,
|
||||
if_not_exists=True,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_domain_dns_snapshots_domain"),
|
||||
"domain_dns_snapshots",
|
||||
["domain"],
|
||||
unique=False,
|
||||
if_not_exists=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_domain_dns_snapshots_domain"), table_name="domain_dns_snapshots", if_exists=True)
|
||||
op.drop_index(op.f("ix_domain_dns_snapshots_checked_at"), table_name="domain_dns_snapshots", if_exists=True)
|
||||
_domain_dns_snapshots().drop(bind=op.get_bind(), checkfirst=True)
|
||||
@@ -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
|
||||
|
||||
+35
-1
@@ -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
|
||||
|
||||
@@ -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 == []
|
||||
Reference in New Issue
Block a user