diff --git a/backend/app/api/api_v1/endpoints/domains.py b/backend/app/api/api_v1/endpoints/domains.py index a6b64c1..5c075f6 100644 --- a/backend/app/api/api_v1/endpoints/domains.py +++ b/backend/app/api/api_v1/endpoints/domains.py @@ -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.""" diff --git a/backend/app/templates/domain_details.html b/backend/app/templates/domain_details.html index 1cfc1d6..38e0921 100644 --- a/backend/app/templates/domain_details.html +++ b/backend/app/templates/domain_details.html @@ -112,6 +112,7 @@ +
{% call card() %} {% call card_header() %} {% call card_title() %}Compliance Over Time{% endcall %} @@ -125,8 +126,74 @@ {% endcall %} {% endcall %} +
+ + +
+ {% call card() %} + {% call card_header() %} +
+ {% call card_title() %}DNS Health Summary{% endcall %} + +
+ {% call card_description() %} + Evidence-linked authentication posture and enforcement readiness + {% endcall %} + {% endcall %} + {% call card_content() %} +
+ +
+ +
+ +
+ {% endcall %} + {% endcall %} +
+
{% call card() %} {% call card_header() %} {% call card_title() %}DNS Records{% endcall %} @@ -217,8 +284,10 @@ {% endcall %} {% endcall %} +
+
{% call card() %} {% call card_header() %}
@@ -405,6 +474,7 @@ {% endcall %} {% endcall %} {% endcall %} +
{% 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); } diff --git a/backend/app/tests/test_dns_endpoints.py b/backend/app/tests/test_dns_endpoints.py index ff11acc..f86d3df 100644 --- a/backend/app/tests/test_dns_endpoints.py +++ b/backend/app/tests/test_dns_endpoints.py @@ -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) # ---------------------------------------------------------------------------