feat: add evidence-linked DNS health guidance
This commit is contained in:
@@ -88,6 +88,47 @@ class DNSRecordResponse(BaseModel):
|
||||
checkedAt: Optional[str] = None
|
||||
|
||||
|
||||
class DNSHealthEvidence(BaseModel):
|
||||
"""Evidence backing a DNS health recommendation."""
|
||||
|
||||
label: str
|
||||
value: str
|
||||
href: str
|
||||
|
||||
|
||||
class DNSHealthCheck(BaseModel):
|
||||
"""Single DNS health check result."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
status: str
|
||||
message: str
|
||||
evidence: List[DNSHealthEvidence] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DNSHealthRecommendation(BaseModel):
|
||||
"""Actionable DNS or enforcement recommendation."""
|
||||
|
||||
type: str
|
||||
severity: str
|
||||
title: str
|
||||
detail: str
|
||||
action: str
|
||||
evidence: List[DNSHealthEvidence] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DNSHealthResponse(BaseModel):
|
||||
"""Evidence-linked DNS health summary for a domain."""
|
||||
|
||||
status: str
|
||||
policy: str
|
||||
compliance_rate: float
|
||||
total_emails: int
|
||||
failed_emails: int
|
||||
checks: List[DNSHealthCheck]
|
||||
recommendations: List[DNSHealthRecommendation]
|
||||
|
||||
|
||||
class CloudflareZoneResponse(BaseModel):
|
||||
"""Cloudflare zone available for import."""
|
||||
|
||||
@@ -310,6 +351,96 @@ def _domain_names_for_summary(db: Session, store: ReportStore) -> List[str]:
|
||||
return list(dict.fromkeys(stored_domains + report_domains))
|
||||
|
||||
|
||||
def _domain_exists(db: Session, store: ReportStore, domain_name: str) -> bool:
|
||||
return domain_name in store.get_domains() or bool(
|
||||
db.query(Domain.id).filter(Domain.name == domain_name).first()
|
||||
)
|
||||
|
||||
|
||||
def _record_evidence(label: str, value: Optional[str], href: str = "#dns-records") -> DNSHealthEvidence:
|
||||
return DNSHealthEvidence(label=label, value=value or "Not found", href=href)
|
||||
|
||||
|
||||
def _summary_evidence(label: str, value: object, href: str = "#compliance-chart") -> DNSHealthEvidence:
|
||||
return DNSHealthEvidence(label=label, value=str(value), href=href)
|
||||
|
||||
|
||||
def _dns_check(
|
||||
key: str,
|
||||
label: str,
|
||||
present: bool,
|
||||
present_message: str,
|
||||
missing_message: str,
|
||||
evidence: List[DNSHealthEvidence],
|
||||
) -> DNSHealthCheck:
|
||||
return DNSHealthCheck(
|
||||
key=key,
|
||||
label=label,
|
||||
status="pass" if present else "fail",
|
||||
message=present_message if present else missing_message,
|
||||
evidence=evidence,
|
||||
)
|
||||
|
||||
|
||||
def _enforcement_recommendation(
|
||||
policy: str,
|
||||
summary: Dict[str, Any],
|
||||
) -> DNSHealthRecommendation:
|
||||
total = int(summary.get("total_count", 0) or 0)
|
||||
failed = int(summary.get("failed_count", 0) or 0)
|
||||
compliance = float(summary.get("compliance_rate", 0.0) or 0.0)
|
||||
evidence = [
|
||||
_summary_evidence("Policy", f"p={policy}", "#dns-records"),
|
||||
_summary_evidence("Total messages", total),
|
||||
_summary_evidence("Compliance", f"{compliance}%"),
|
||||
_summary_evidence("Failed messages", failed, "#sending-sources"),
|
||||
]
|
||||
if policy != "none":
|
||||
return DNSHealthRecommendation(
|
||||
type="policy_already_enforced",
|
||||
severity="info",
|
||||
title="DMARC policy is already enforced",
|
||||
detail="This domain is already beyond monitoring mode.",
|
||||
action="Continue watching failure trends before tightening further.",
|
||||
evidence=evidence,
|
||||
)
|
||||
if total < 100:
|
||||
return DNSHealthRecommendation(
|
||||
type="policy_needs_more_data",
|
||||
severity="warning",
|
||||
title="Collect more report volume before enforcement",
|
||||
detail="DMARQ needs at least 100 observed messages before recommending quarantine.",
|
||||
action="Keep p=none until more aggregate reports arrive.",
|
||||
evidence=evidence,
|
||||
)
|
||||
if compliance >= 98.0 and failed <= max(2, int(total * 0.02)):
|
||||
return DNSHealthRecommendation(
|
||||
type="policy_enforcement_ready",
|
||||
severity="info",
|
||||
title="Ready to plan quarantine",
|
||||
detail="Recent report volume is high and failures are low enough to plan enforcement.",
|
||||
action="Move gradually: set p=quarantine with a low pct value, then watch failures.",
|
||||
evidence=evidence,
|
||||
)
|
||||
if compliance >= 90.0:
|
||||
return DNSHealthRecommendation(
|
||||
type="policy_enforcement_review",
|
||||
severity="warning",
|
||||
title="Close remaining failures before enforcement",
|
||||
detail="Compliance is improving, but failures still need review before policy changes.",
|
||||
action="Review failing sources and SPF/DKIM alignment before changing p=none.",
|
||||
evidence=evidence,
|
||||
)
|
||||
return DNSHealthRecommendation(
|
||||
type="policy_not_ready",
|
||||
severity="error",
|
||||
title="Not ready for enforcement",
|
||||
detail="Current DMARC compliance is too low for a safe policy change.",
|
||||
action="Fix unauthenticated or unknown senders before moving beyond p=none.",
|
||||
evidence=evidence,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/summary", response_model=DomainSummaryResponse)
|
||||
async def get_domains_summary(db: Session = Depends(get_db)):
|
||||
"""
|
||||
@@ -540,9 +671,8 @@ async def get_domain_stats(
|
||||
"""
|
||||
store = ReportStore.get_instance()
|
||||
hydrate_report_store_from_db(db, store)
|
||||
domains = store.get_domains()
|
||||
domains = _domain_names_for_summary(db, store)
|
||||
|
||||
# For Milestone 1, domain_id is simply the domain name
|
||||
if domain_id not in domains:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
@@ -579,9 +709,8 @@ async def get_domain_dns_records(
|
||||
"""
|
||||
store = ReportStore.get_instance()
|
||||
hydrate_report_store_from_db(db, store)
|
||||
domains = store.get_domains()
|
||||
|
||||
if domain_id not in domains:
|
||||
if not _domain_exists(db, store, domain_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Domain not found",
|
||||
@@ -612,6 +741,95 @@ async def get_domain_dns_records(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{domain_id}/dns/health", response_model=DNSHealthResponse)
|
||||
async def get_domain_dns_health(
|
||||
domain_id: str = Path(..., title="The domain ID or name"),
|
||||
refresh: bool = Query(False, title="Refresh cached DNS result"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Return evidence-linked DNS health and enforcement readiness guidance."""
|
||||
store = ReportStore.get_instance()
|
||||
hydrate_report_store_from_db(db, store)
|
||||
if not _domain_exists(db, store, domain_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Domain not found",
|
||||
)
|
||||
|
||||
manual_selectors = _get_domain_selectors_from_db(db, domain_id)
|
||||
report_selectors = _get_selectors_from_reports(store, domain_id)
|
||||
combined_selectors = list(dict.fromkeys(manual_selectors + report_selectors))
|
||||
|
||||
provider = get_default_provider(db)
|
||||
result, _, _ = await resolve_domain_dns_cached(
|
||||
db,
|
||||
provider,
|
||||
domain_id,
|
||||
selectors=combined_selectors,
|
||||
refresh=refresh,
|
||||
)
|
||||
summary = store.get_domain_summary(domain_id)
|
||||
policy = extract_dmarc_policy(result.dmarc_record) or "none"
|
||||
checks = [
|
||||
_dns_check(
|
||||
"dmarc",
|
||||
"DMARC",
|
||||
result.dmarc,
|
||||
"DMARC record is published.",
|
||||
"No DMARC record was found.",
|
||||
[_record_evidence("DMARC TXT", result.dmarc_record)],
|
||||
),
|
||||
_dns_check(
|
||||
"spf",
|
||||
"SPF",
|
||||
result.spf,
|
||||
"SPF record is published.",
|
||||
"No SPF record was found at the domain root.",
|
||||
[_record_evidence("SPF TXT", result.spf_record)],
|
||||
),
|
||||
_dns_check(
|
||||
"dkim",
|
||||
"DKIM",
|
||||
result.dkim,
|
||||
"At least one DKIM selector resolved.",
|
||||
"No DKIM record was found for configured or observed selectors.",
|
||||
[
|
||||
_record_evidence(
|
||||
"Selectors checked",
|
||||
", ".join(combined_selectors or result.selectors_checked or []),
|
||||
),
|
||||
_record_evidence("DKIM TXT", result.dkim_record),
|
||||
],
|
||||
),
|
||||
]
|
||||
recommendations: List[DNSHealthRecommendation] = []
|
||||
for check in checks:
|
||||
if check.status == "fail":
|
||||
recommendations.append(
|
||||
DNSHealthRecommendation(
|
||||
type=f"missing_{check.key}",
|
||||
severity="error" if check.key == "dmarc" else "warning",
|
||||
title=f"{check.label} needs attention",
|
||||
detail=check.message,
|
||||
action=f"Publish or repair the {check.label} DNS record, then refresh DNS health.",
|
||||
evidence=check.evidence,
|
||||
)
|
||||
)
|
||||
recommendations.append(_enforcement_recommendation(policy, summary))
|
||||
|
||||
failed_checks = sum(1 for check in checks if check.status == "fail")
|
||||
health_status = "healthy" if failed_checks == 0 else "degraded" if failed_checks < 3 else "critical"
|
||||
return DNSHealthResponse(
|
||||
status=health_status,
|
||||
policy=policy,
|
||||
compliance_rate=float(summary.get("compliance_rate", 0.0) or 0.0),
|
||||
total_emails=int(summary.get("total_count", 0) or 0),
|
||||
failed_emails=int(summary.get("failed_count", 0) or 0),
|
||||
checks=checks,
|
||||
recommendations=recommendations,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/cloudflare/discover", response_model=List[CloudflareZoneResponse])
|
||||
async def discover_cloudflare_domains(db: Session = Depends(get_db)):
|
||||
"""Discover active Cloudflare zones visible to the configured API token."""
|
||||
|
||||
@@ -112,6 +112,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Compliance Chart -->
|
||||
<section id="compliance-chart-section">
|
||||
{% call card() %}
|
||||
{% call card_header() %}
|
||||
{% call card_title() %}Compliance Over Time{% endcall %}
|
||||
@@ -125,8 +126,74 @@
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
</section>
|
||||
|
||||
<!-- DNS Health Summary -->
|
||||
<section id="dns-health-summary">
|
||||
{% call card() %}
|
||||
{% call card_header() %}
|
||||
<div class="flex items-center justify-between">
|
||||
{% call card_title() %}DNS Health Summary{% endcall %}
|
||||
<span class="inline-flex items-center rounded px-2 py-1 text-xs font-semibold capitalize"
|
||||
:class="dnsHealthStatusClass"
|
||||
x-text="dnsHealth.status || 'checking'"></span>
|
||||
</div>
|
||||
{% call card_description() %}
|
||||
Evidence-linked authentication posture and enforcement readiness
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% call card_content() %}
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<template x-for="check in dnsHealth.checks" :key="check.key">
|
||||
<div class="rounded border border-base-300 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-semibold" x-text="check.label"></h3>
|
||||
<span class="rounded px-2 py-1 text-xs font-semibold capitalize"
|
||||
:class="check.status === 'pass' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
||||
x-text="check.status"></span>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-base-content/70" x-text="check.message"></p>
|
||||
<div class="mt-3 space-y-1">
|
||||
<template x-for="item in check.evidence" :key="item.label + item.value">
|
||||
<a :href="item.href" class="block text-xs link link-primary">
|
||||
<span x-text="item.label"></span>:
|
||||
<span class="font-mono break-all" x-text="item.value"></span>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 space-y-3">
|
||||
<template x-for="recommendation in dnsHealth.recommendations" :key="recommendation.type">
|
||||
<div class="rounded border border-base-300 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="mt-1 h-2.5 w-2.5 rounded-full"
|
||||
:class="recommendationSeverityClass(recommendation.severity)"></span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="font-semibold" x-text="recommendation.title"></h3>
|
||||
<p class="mt-1 text-sm text-base-content/70" x-text="recommendation.detail"></p>
|
||||
<p class="mt-2 text-sm font-medium" x-text="recommendation.action"></p>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<template x-for="item in recommendation.evidence" :key="item.label + item.value">
|
||||
<a :href="item.href" class="badge badge-outline gap-1">
|
||||
<span x-text="item.label"></span>
|
||||
<span x-text="item.value"></span>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
</section>
|
||||
|
||||
<!-- DNS Records -->
|
||||
<section id="dns-records">
|
||||
{% call card() %}
|
||||
{% call card_header() %}
|
||||
{% call card_title() %}DNS Records{% endcall %}
|
||||
@@ -217,8 +284,10 @@
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
</section>
|
||||
|
||||
<!-- Source Report Table -->
|
||||
<section id="sending-sources">
|
||||
{% call card() %}
|
||||
{% call card_header() %}
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -405,6 +474,7 @@
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
</section>
|
||||
|
||||
<!-- Recent Reports -->
|
||||
{% call card() %}
|
||||
@@ -511,6 +581,11 @@ function domainDetailsApp(domainId) {
|
||||
dkim: false,
|
||||
dkimSelectors: []
|
||||
},
|
||||
dnsHealth: {
|
||||
status: '',
|
||||
checks: [],
|
||||
recommendations: []
|
||||
},
|
||||
selectors: [],
|
||||
reportSelectors: [],
|
||||
newSelector: '',
|
||||
@@ -528,6 +603,7 @@ function domainDetailsApp(domainId) {
|
||||
init() {
|
||||
this.fetchDomainStats();
|
||||
this.fetchDNSRecords();
|
||||
this.fetchDNSHealth();
|
||||
this.fetchSelectors();
|
||||
this.fetchReports();
|
||||
this.fetchSources();
|
||||
@@ -563,6 +639,13 @@ function domainDetailsApp(domainId) {
|
||||
return 'Verified';
|
||||
},
|
||||
|
||||
get dnsHealthStatusClass() {
|
||||
if (this.dnsHealth.status === 'healthy') return 'bg-green-100 text-green-700';
|
||||
if (this.dnsHealth.status === 'degraded') return 'bg-yellow-100 text-yellow-800';
|
||||
if (this.dnsHealth.status === 'critical') return 'bg-red-100 text-red-700';
|
||||
return 'bg-base-200 text-base-content/70';
|
||||
},
|
||||
|
||||
recommendationSeverityClass(severity) {
|
||||
if (severity === 'error') return 'bg-red-500';
|
||||
if (severity === 'warning') return 'bg-yellow-500';
|
||||
@@ -593,6 +676,17 @@ function domainDetailsApp(domainId) {
|
||||
}
|
||||
},
|
||||
|
||||
async fetchDNSHealth() {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/domains/${this.domainId}/dns/health`);
|
||||
if (response.ok) {
|
||||
this.dnsHealth = await response.json();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching DNS health:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async fetchSelectors() {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/domains/${this.domainId}/selectors`);
|
||||
@@ -621,6 +715,7 @@ function domainDetailsApp(domainId) {
|
||||
// Refresh selectors (both manual and report) and DNS check
|
||||
this.fetchSelectors();
|
||||
this.fetchDNSRecords();
|
||||
this.fetchDNSHealth();
|
||||
} else {
|
||||
const err = await response.json();
|
||||
this.selectorError = err.detail || 'Failed to add selector';
|
||||
@@ -641,6 +736,7 @@ function domainDetailsApp(domainId) {
|
||||
// Refresh selectors (both manual and report) and DNS check
|
||||
this.fetchSelectors();
|
||||
this.fetchDNSRecords();
|
||||
this.fetchDNSHealth();
|
||||
} else {
|
||||
console.error('Error deleting selector:', response.status);
|
||||
}
|
||||
|
||||
@@ -320,6 +320,114 @@ def test_dns_endpoint_404_for_unknown_domain(client: TestClient):
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_dns_endpoint_supports_manually_configured_domain(client: TestClient, db_session):
|
||||
"""A domain created before reports arrive can still run DNS checks."""
|
||||
db_session.add(Domain(name="manual.example", active=True))
|
||||
db_session.commit()
|
||||
|
||||
with _mock_dns():
|
||||
response = client.get("/api/v1/domains/manual.example/dns")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["dmarc"] is True
|
||||
|
||||
|
||||
def test_dns_health_404_for_unknown_domain(client: TestClient):
|
||||
with _mock_dns():
|
||||
response = client.get("/api/v1/domains/unknown.example.com/dns/health")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_dns_health_links_checks_to_evidence(client: TestClient):
|
||||
"""DNS health returns provider-neutral checks, recommendations, and evidence links."""
|
||||
missing_dkim = DomainDNSResult(
|
||||
dmarc=True,
|
||||
dmarc_record="v=DMARC1; p=none; rua=mailto:dmarc@example.com",
|
||||
spf=True,
|
||||
spf_record="v=spf1 include:_spf.google.com ~all",
|
||||
dkim=False,
|
||||
selectors_checked=["google"],
|
||||
)
|
||||
|
||||
with _mock_dns(result=missing_dkim):
|
||||
response = client.get(f"/api/v1/domains/{DOMAIN}/dns/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "degraded"
|
||||
dkim_check = next(check for check in data["checks"] if check["key"] == "dkim")
|
||||
assert dkim_check["status"] == "fail"
|
||||
assert dkim_check["evidence"][0]["href"] == "#dns-records"
|
||||
assert any(item["type"] == "missing_dkim" for item in data["recommendations"])
|
||||
|
||||
|
||||
def test_dns_health_recommends_enforcement_when_evidence_supports_it(client: TestClient):
|
||||
"""High-volume p=none domains with strong compliance get plan-only guidance."""
|
||||
store = ReportStore.get_instance()
|
||||
store.clear()
|
||||
store.add_report(
|
||||
{
|
||||
**MINIMAL_REPORT,
|
||||
"summary": {"total_count": 500, "passed_count": 495, "failed_count": 5},
|
||||
"records": [
|
||||
{
|
||||
**MINIMAL_REPORT["records"][0],
|
||||
"count": 500,
|
||||
"dkim_result": "pass",
|
||||
"spf_result": "pass",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
with _mock_dns():
|
||||
response = client.get(f"/api/v1/domains/{DOMAIN}/dns/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
recommendations = response.json()["recommendations"]
|
||||
readiness = next(item for item in recommendations if item["type"] == "policy_enforcement_ready")
|
||||
assert "low pct" in readiness["action"]
|
||||
assert any(item["label"] == "Compliance" for item in readiness["evidence"])
|
||||
|
||||
|
||||
def test_dns_health_marks_all_missing_records_critical(client: TestClient):
|
||||
"""Missing DMARC, SPF, and DKIM produce specific repair recommendations."""
|
||||
missing_all = DomainDNSResult(
|
||||
dmarc=False,
|
||||
spf=False,
|
||||
dkim=False,
|
||||
selectors_checked=["google"],
|
||||
)
|
||||
|
||||
with _mock_dns(result=missing_all):
|
||||
response = client.get(f"/api/v1/domains/{DOMAIN}/dns/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "critical"
|
||||
recommendation_types = {item["type"] for item in data["recommendations"]}
|
||||
assert {"missing_dmarc", "missing_spf", "missing_dkim"}.issubset(recommendation_types)
|
||||
assert any(item["type"] == "policy_needs_more_data" for item in data["recommendations"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("policy", "summary", "expected_type", "expected_severity"),
|
||||
[
|
||||
("quarantine", {"total_count": 1000, "failed_count": 50, "compliance_rate": 95.0}, "policy_already_enforced", "info"),
|
||||
("none", {"total_count": 50, "failed_count": 0, "compliance_rate": 100.0}, "policy_needs_more_data", "warning"),
|
||||
("none", {"total_count": 200, "failed_count": 15, "compliance_rate": 92.5}, "policy_enforcement_review", "warning"),
|
||||
("none", {"total_count": 200, "failed_count": 80, "compliance_rate": 60.0}, "policy_not_ready", "error"),
|
||||
],
|
||||
)
|
||||
def test_enforcement_recommendation_common_states(policy, summary, expected_type, expected_severity):
|
||||
"""Policy guidance covers enforced, low-volume, review, and not-ready states."""
|
||||
recommendation = domains_endpoint._enforcement_recommendation(policy, summary)
|
||||
|
||||
assert recommendation.type == expected_type
|
||||
assert recommendation.severity == expected_severity
|
||||
assert recommendation.evidence[0].value == f"p={policy}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/v1/domains/summary (DNS fields included)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user