feat: add BIMI posture readiness checks

Closes #141
This commit is contained in:
Christian Krakau-Louis
2026-05-23 17:09:26 +02:00
committed by GitHub
8 changed files with 631 additions and 8 deletions
+171 -7
View File
@@ -15,6 +15,7 @@ from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import require_admin_auth
from app.models.domain import Domain
from app.services.bimi import BIMIResult, check_bimi_cached
from app.services.cloudflare_dns import (
analyze_dns_records,
discover_cloudflare_zones,
@@ -146,6 +147,22 @@ class MTAStsResponse(BaseModel):
checked_at: Optional[str] = None
class BIMIResponse(BaseModel):
"""BIMI posture result for a domain."""
status: str
selector: str = "default"
query_name: str
dns_record: Optional[str] = None
logo_url: Optional[str] = None
certificate_url: Optional[str] = None
evidence_url: Optional[str] = None
errors: List[str] = Field(default_factory=list)
warnings: List[str] = Field(default_factory=list)
cached: bool = False
checked_at: Optional[str] = None
class CloudflareZoneResponse(BaseModel):
"""Cloudflare zone available for import."""
@@ -462,6 +479,100 @@ def _enforcement_recommendation(
)
def _dmarc_tags(record: Optional[str]) -> Dict[str, str]:
if not record:
return {}
return {
part.split("=", 1)[0].strip().lower(): part.split("=", 1)[1].strip().lower()
for part in record.split(";")
if "=" in part
}
def _bimi_dmarc_readiness(record: Optional[str]) -> tuple[bool, List[str], List[DNSHealthEvidence]]:
tags = _dmarc_tags(record)
policy = tags.get("p", "none")
subdomain_policy = tags.get("sp")
pct = tags.get("pct", "100")
issues = []
if policy not in {"quarantine", "reject"}:
issues.append("DMARC policy must be p=quarantine or p=reject for BIMI.")
if pct != "100":
issues.append("DMARC pct must be 100 or omitted for BIMI.")
if subdomain_policy and subdomain_policy not in {"quarantine", "reject"}:
issues.append("DMARC subdomain policy must also be enforced when sp= is present.")
evidence = [
_record_evidence("DMARC TXT", record),
_summary_evidence("Policy", f"p={policy}", "#dns-records"),
_summary_evidence(
"Subdomain policy", f"sp={subdomain_policy or 'inherit'}", "#dns-records"
),
_summary_evidence("Percentage", f"pct={pct}", "#dns-records"),
]
return not issues, issues, evidence
def _bimi_check(result: BIMIResult, dmarc_ready: bool) -> DNSHealthCheck:
evidence = [
_record_evidence("BIMI TXT", result.dns_record, "#bimi-posture"),
_record_evidence("Logo URL", result.logo_url, "#bimi-posture"),
]
if result.certificate_url:
evidence.append(
_record_evidence("Certificate URL", result.certificate_url, "#bimi-posture")
)
if result.status == "pass" and dmarc_ready:
message = "BIMI record is published and DMARC is enforcement-ready."
elif result.status == "pass":
message = "BIMI record is published, but DMARC enforcement is not ready."
else:
message = result.errors[0] if result.errors else "BIMI posture needs attention."
return DNSHealthCheck(
key="bimi",
label="BIMI",
status="pass" if result.status == "pass" and dmarc_ready else "fail",
message=message,
evidence=evidence,
)
def _bimi_recommendation(
result: BIMIResult,
dmarc_ready: bool,
dmarc_issues: List[str],
dmarc_evidence: List[DNSHealthEvidence],
) -> Optional[DNSHealthRecommendation]:
bimi_evidence = _bimi_check(result, dmarc_ready).evidence
if result.status == "pass" and dmarc_ready and not result.warnings:
return None
if result.status == "pass" and not dmarc_ready:
return DNSHealthRecommendation(
type="bimi_dmarc_not_ready",
severity="warning",
title="DMARC enforcement is blocking BIMI readiness",
detail="; ".join(dmarc_issues),
action="Move DMARC to quarantine or reject at pct=100 before relying on BIMI.",
evidence=dmarc_evidence + bimi_evidence,
)
if result.status == "pass":
return DNSHealthRecommendation(
type="bimi_review",
severity="info",
title="BIMI record needs provider-readiness review",
detail="; ".join(result.warnings),
action="Confirm the SVG logo profile and add a certificate URL if mailbox providers require one.",
evidence=bimi_evidence,
)
return DNSHealthRecommendation(
type="missing_bimi",
severity="info",
title="Publish BIMI after DMARC enforcement",
detail="; ".join(result.errors or ["No BIMI record is published."]),
action="Publish a BIMI TXT record at default._bimi with an HTTPS SVG logo URL.",
evidence=dmarc_evidence + bimi_evidence,
)
def _mta_sts_check(result: MTAStsResult) -> DNSHealthCheck:
evidence = [
_record_evidence("MTA-STS TXT", result.dns_record, "#dns-records"),
@@ -840,8 +951,17 @@ async def get_domain_dns_health(
domain_id,
refresh=refresh,
)
bimi_result, _, _ = await check_bimi_cached(
db,
provider,
domain_id,
refresh=refresh,
)
summary = store.get_domain_summary(domain_id)
policy = extract_dmarc_policy(result.dmarc_record) or "none"
bimi_dmarc_ready, bimi_dmarc_issues, bimi_dmarc_evidence = _bimi_dmarc_readiness(
result.dmarc_record
)
checks = [
_dns_check(
"dmarc",
@@ -874,10 +994,11 @@ async def get_domain_dns_health(
],
),
_mta_sts_check(mta_sts_result),
_bimi_check(bimi_result, bimi_dmarc_ready),
]
recommendations: List[DNSHealthRecommendation] = []
for check in checks:
if check.status == "fail" and check.key != "mta_sts":
if check.status == "fail" and check.key not in {"mta_sts", "bimi"}:
recommendations.append(
DNSHealthRecommendation(
type=f"missing_{check.key}",
@@ -892,6 +1013,14 @@ async def get_domain_dns_health(
mta_sts_recommendation = _mta_sts_recommendation(mta_sts_result)
if mta_sts_recommendation:
recommendations.append(mta_sts_recommendation)
bimi_recommendation = _bimi_recommendation(
bimi_result,
bimi_dmarc_ready,
bimi_dmarc_issues,
bimi_dmarc_evidence,
)
if bimi_recommendation:
recommendations.append(bimi_recommendation)
failed_checks = sum(1 for check in checks if check.status == "fail")
health_status = (
@@ -943,6 +1072,43 @@ async def get_domain_mta_sts(
)
@router.get("/{domain_id}/dns/bimi", response_model=BIMIResponse)
async def get_domain_bimi(
domain_id: str = Path(..., title="The domain ID or name"),
selector: str = Query("default", title="BIMI selector"),
refresh: bool = Query(False, title="Refresh cached BIMI result"),
db: Session = Depends(get_db),
):
"""Return cached BIMI DNS posture for a domain."""
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",
)
result, cached, checked_at = await check_bimi_cached(
db,
get_default_provider(db),
domain_id,
selector=selector,
refresh=refresh,
)
return BIMIResponse(
status=result.status,
selector=result.selector,
query_name=result.query_name,
dns_record=result.dns_record,
logo_url=result.logo_url,
certificate_url=result.certificate_url,
evidence_url=result.evidence_url,
errors=result.errors,
warnings=result.warnings,
cached=cached,
checked_at=checked_at.isoformat(),
)
@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."""
@@ -1032,9 +1198,8 @@ async def get_domain_reports(
"""
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",
@@ -1086,7 +1251,7 @@ async def export_domain_reports(
store = ReportStore.get_instance()
hydrate_report_store_from_db(db, store)
if domain_id not in store.get_domains():
if not _domain_exists(db, store, domain_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found",
@@ -1399,9 +1564,8 @@ async def get_domain_sources(
"""
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",
@@ -1457,7 +1621,7 @@ async def get_domain_selectors(
"""
store = ReportStore.get_instance()
hydrate_report_store_from_db(db, store)
if domain_id not in store.get_domains():
if not _domain_exists(db, store, domain_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found",
+203
View File
@@ -0,0 +1,203 @@
"""BIMI DNS posture checks for monitored domains."""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass, field
from datetime import datetime, timedelta, timezone
from typing import List, Optional, Tuple
from urllib.parse import urlparse
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.models.dns_cache import DNSCache
from app.services.dns_cache import DEFAULT_DNS_CACHE_TTL_SECONDS
from app.services.dns_resolver import BaseDNSProvider
_CACHE_KEY_PREFIX = "bimi-v1"
@dataclass
class BIMIResult:
"""Operator-facing BIMI posture evidence."""
status: str = "fail"
selector: str = "default"
query_name: str = ""
dns_record: Optional[str] = None
logo_url: Optional[str] = None
certificate_url: Optional[str] = None
evidence_url: Optional[str] = None
errors: List[str] = field(default_factory=list)
warnings: List[str] = field(default_factory=list)
def _utcnow_naive() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
def _is_fresh(row: DNSCache, ttl_seconds: int, now: datetime) -> bool:
return row.checked_at >= now - timedelta(seconds=ttl_seconds)
def _result_from_json(value: str) -> BIMIResult:
data = json.loads(value)
return BIMIResult(
status=str(data.get("status") or "fail"),
selector=str(data.get("selector") or "default"),
query_name=str(data.get("query_name") or ""),
dns_record=data.get("dns_record"),
logo_url=data.get("logo_url"),
certificate_url=data.get("certificate_url"),
evidence_url=data.get("evidence_url"),
errors=list(data.get("errors") or []),
warnings=list(data.get("warnings") or []),
)
def _https_url(value: Optional[str]) -> bool:
if not value:
return False
parsed = urlparse(value)
return parsed.scheme == "https" and bool(parsed.netloc)
def _tags(record: str) -> dict[str, str]:
return {
part.split("=", 1)[0].strip().lower(): part.split("=", 1)[1].strip()
for part in record.split(";")
if "=" in part
}
def parse_bimi_record(records: List[str]) -> Tuple[Optional[BIMIResult], List[str], List[str]]:
"""Parse BIMI TXT records into a normalized result, warnings, and errors."""
bimi_records = [record for record in records if record.lower().startswith("v=bimi1")]
if not bimi_records:
return None, [], ["No BIMI TXT record was found at the selector."]
warnings: List[str] = []
errors: List[str] = []
if len(bimi_records) > 1:
warnings.append("Multiple BIMI TXT records were found; publish exactly one.")
record = bimi_records[0]
tags = _tags(record)
logo_url = tags.get("l")
certificate_url = tags.get("a")
if tags.get("v", "").lower() != "bimi1":
errors.append("The BIMI TXT record must start with v=BIMI1.")
if not logo_url:
errors.append("The BIMI TXT record must include an l= HTTPS SVG logo URL.")
elif not _https_url(logo_url):
errors.append("The BIMI logo URL must use HTTPS.")
elif not urlparse(logo_url).path.lower().endswith(".svg"):
warnings.append("The BIMI logo URL should point to an SVG file.")
if certificate_url and not _https_url(certificate_url):
errors.append("The BIMI certificate URL must use HTTPS when present.")
elif not certificate_url:
warnings.append("No BIMI certificate URL is published; some mailbox providers require one.")
result = BIMIResult(
status="pass" if not errors else "fail",
dns_record=record,
logo_url=logo_url,
certificate_url=certificate_url,
evidence_url=logo_url,
errors=errors,
warnings=warnings,
)
return result, warnings, errors
async def check_bimi(
domain: str,
provider: BaseDNSProvider,
*,
selector: str = "default",
) -> BIMIResult:
"""Resolve and validate the BIMI TXT record for a domain selector."""
normalized_selector = (selector or "default").strip().lower()
query_name = f"{normalized_selector}._bimi.{domain}"
result = BIMIResult(selector=normalized_selector, query_name=query_name)
try:
records = await provider.lookup_txt(query_name)
except LookupError as exc:
result.errors.append(f"BIMI DNS lookup failed: {exc}")
return result
parsed, warnings, errors = parse_bimi_record(records)
if parsed is None:
result.errors.extend(errors)
return result
parsed.selector = normalized_selector
parsed.query_name = query_name
parsed.warnings = warnings
parsed.errors = errors
return parsed
async def check_bimi_cached(
db: Session,
provider: BaseDNSProvider,
domain: str,
*,
selector: str = "default",
ttl_seconds: int = DEFAULT_DNS_CACHE_TTL_SECONDS,
refresh: bool = False,
) -> Tuple[BIMIResult, bool, datetime]:
"""Resolve BIMI posture, reusing the shared DNS cache semantics."""
normalized_selector = (selector or "default").strip().lower()
cache_key = f"{_CACHE_KEY_PREFIX}:{normalized_selector}"
now = _utcnow_naive()
provider_name = f"{provider.__class__.__name__}:bimi"
row = (
db.query(DNSCache)
.filter(
DNSCache.domain == domain,
DNSCache.provider == provider_name,
DNSCache.selectors_key == cache_key,
)
.first()
)
if row and not refresh and _is_fresh(row, ttl_seconds, now):
return _result_from_json(row.result_json), True, row.checked_at
result = await check_bimi(domain, provider, selector=normalized_selector)
payload = json.dumps(asdict(result), sort_keys=True, separators=(",", ":"))
if row is None:
row = DNSCache(
domain=domain,
provider=provider_name,
selectors_key=cache_key,
result_json=payload,
checked_at=now,
)
db.add(row)
else:
row.result_json = payload
row.checked_at = now
try:
db.commit()
except IntegrityError:
db.rollback()
row = (
db.query(DNSCache)
.filter(
DNSCache.domain == domain,
DNSCache.provider == provider_name,
DNSCache.selectors_key == cache_key,
)
.first()
)
if row is None:
raise
row.result_json = payload
row.checked_at = now
db.commit()
db.refresh(row)
return result, False, row.checked_at
+46
View File
@@ -243,6 +243,30 @@
</div>
</div>
</div>
<div id="bimi-posture">
<h3 class="font-semibold mb-1 flex items-center">
<span class="mr-2">BIMI</span>
<span x-show="bimi.status === 'pass'" class="inline-flex h-2 w-2 rounded-full bg-green-500"></span>
<span x-show="bimi.status === 'fail'" class="inline-flex h-2 w-2 rounded-full bg-red-500"></span>
<span x-show="!bimi.status" class="inline-flex h-2 w-2 rounded-full bg-base-300"></span>
</h3>
<div class="space-y-2">
<div class="bg-muted p-2 rounded text-sm overflow-x-auto font-mono" x-text="bimi.dns_record || 'No BIMI TXT record found at default._bimi'">-</div>
<div class="rounded border border-base-300 p-3 text-sm">
<div class="grid gap-1">
<div><span class="font-medium">Selector:</span> <span class="font-mono" x-text="bimi.selector || 'default'"></span></div>
<div><span class="font-medium">Logo:</span> <span class="font-mono break-all" x-text="bimi.logo_url || 'none'"></span></div>
<div><span class="font-medium">Certificate:</span> <span class="font-mono break-all" x-text="bimi.certificate_url || 'none'"></span></div>
</div>
<template x-if="bimi.errors && bimi.errors.length">
<p class="mt-2 text-xs text-red-600" x-text="bimi.errors[0]"></p>
</template>
<template x-if="bimi.warnings && bimi.warnings.length">
<p class="mt-2 text-xs text-yellow-700" x-text="bimi.warnings[0]"></p>
</template>
</div>
</div>
</div>
<!-- DKIM Selectors — live check result -->
<div>
<h3 class="font-semibold mb-1 flex items-center">
@@ -619,6 +643,16 @@ function domainDetailsApp(domainId) {
errors: [],
warnings: []
},
bimi: {
status: '',
selector: 'default',
query_name: '',
dns_record: '',
logo_url: '',
certificate_url: '',
errors: [],
warnings: []
},
selectors: [],
reportSelectors: [],
newSelector: '',
@@ -638,6 +672,7 @@ function domainDetailsApp(domainId) {
this.fetchDNSRecords();
this.fetchDNSHealth();
this.fetchMtaSts();
this.fetchBimi();
this.fetchSelectors();
this.fetchReports();
this.fetchSources();
@@ -732,6 +767,17 @@ function domainDetailsApp(domainId) {
}
},
async fetchBimi() {
try {
const response = await fetch(`/api/v1/domains/${this.domainId}/dns/bimi`);
if (response.ok) {
this.bimi = await response.json();
}
} catch (error) {
console.error('Error fetching BIMI posture:', error);
}
},
async fetchSelectors() {
try {
const response = await fetch(`/api/v1/domains/${this.domainId}/selectors`);
+118
View File
@@ -0,0 +1,118 @@
from datetime import datetime
from unittest.mock import AsyncMock
import pytest
from sqlalchemy.exc import IntegrityError
from app.models.dns_cache import DNSCache
from app.services.bimi import check_bimi, check_bimi_cached, parse_bimi_record
def test_parse_bimi_record_requires_single_bimi_record():
result, warnings, errors = parse_bimi_record(
["v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem"]
)
assert result is not None
assert result.status == "pass"
assert result.logo_url == "https://example.com/logo.svg"
assert result.certificate_url == "https://example.com/vmc.pem"
assert warnings == []
assert errors == []
def test_parse_bimi_record_reports_missing_and_malformed_records():
missing, missing_warnings, missing_errors = parse_bimi_record([])
malformed, _, malformed_errors = parse_bimi_record(["v=BIMI1; l=http://example.com/logo.png"])
assert missing is None
assert missing_warnings == []
assert missing_errors == ["No BIMI TXT record was found at the selector."]
assert malformed is not None
assert malformed.status == "fail"
assert "HTTPS" in malformed_errors[0]
def test_parse_bimi_record_warns_without_certificate():
result, warnings, errors = parse_bimi_record(["v=BIMI1; l=https://example.com/logo.svg"])
assert result is not None
assert result.status == "pass"
assert errors == []
assert warnings == ["No BIMI certificate URL is published; some mailbox providers require one."]
@pytest.mark.asyncio
async def test_check_bimi_looks_up_default_selector():
provider = AsyncMock()
provider.lookup_txt = AsyncMock(
return_value=["v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem"]
)
result = await check_bimi("example.com", provider)
assert result.status == "pass"
assert result.query_name == "default._bimi.example.com"
provider.lookup_txt.assert_awaited_once_with("default._bimi.example.com")
@pytest.mark.asyncio
async def test_check_bimi_cached_reuses_fresh_result(db_session):
provider = AsyncMock()
provider.lookup_txt = AsyncMock(return_value=["v=BIMI1; l=https://example.com/logo.svg"])
first, first_cached, first_checked = await check_bimi_cached(
db_session, provider, "example.com"
)
second, second_cached, second_checked = await check_bimi_cached(
db_session, provider, "example.com"
)
assert first.status == "pass"
assert first_cached is False
assert second_cached is True
assert second.logo_url == "https://example.com/logo.svg"
assert second_checked == first_checked
assert provider.lookup_txt.await_count == 1
@pytest.mark.asyncio
async def test_check_bimi_cached_recovers_from_concurrent_insert(db_session, monkeypatch):
provider = AsyncMock()
provider.lookup_txt = AsyncMock(return_value=["v=BIMI1; l=https://example.com/logo.svg"])
original_commit = db_session.commit
original_rollback = db_session.rollback
commit_calls = 0
def fake_commit():
nonlocal commit_calls
commit_calls += 1
if commit_calls == 1:
raise IntegrityError("insert", {}, Exception("duplicate"))
original_commit()
def fake_rollback():
original_rollback()
db_session.add(
DNSCache(
domain="example.com",
provider=f"{provider.__class__.__name__}:bimi",
selectors_key="bimi-v1:default",
result_json=(
'{"certificate_url":null,"dns_record":null,"errors":["missing"],'
'"evidence_url":null,"logo_url":null,"query_name":"default._bimi.example.com",'
'"selector":"default","status":"fail","warnings":[]}'
),
checked_at=datetime(2026, 5, 23, 12, 0, 0),
)
)
original_commit()
monkeypatch.setattr(db_session, "commit", fake_commit)
monkeypatch.setattr(db_session, "rollback", fake_rollback)
result, cached, _checked = await check_bimi_cached(db_session, provider, "example.com")
assert result.status == "pass"
assert cached is False
assert db_session.query(DNSCache).count() == 1
+68
View File
@@ -19,6 +19,7 @@ from app.api.api_v1.endpoints import domains as domains_endpoint
from app.api.api_v1.endpoints.domains import _spf_fix_hint
from app.models.dns_cache import DNSCache
from app.models.domain import Domain
from app.services.bimi import BIMIResult
from app.services.dns_cache import _selectors_key, resolve_domain_dns_cached
from app.services.dns_resolver import DomainDNSResult
from app.services.mta_sts import MTAStsResult
@@ -430,12 +431,22 @@ def test_dns_health_links_checks_to_evidence(client: TestClient):
max_age=86400,
mx=["*.example.com"],
)
bimi = BIMIResult(
status="pass",
dns_record="v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem",
logo_url="https://example.com/logo.svg",
certificate_url="https://example.com/vmc.pem",
)
with (
_mock_dns(result=missing_dkim),
patch(
"app.api.api_v1.endpoints.domains.check_mta_sts_cached",
new=AsyncMock(return_value=(mta_sts, False, None)),
),
patch(
"app.api.api_v1.endpoints.domains.check_bimi_cached",
new=AsyncMock(return_value=(bimi, False, None)),
),
):
response = client.get(f"/api/v1/domains/{DOMAIN}/dns/health")
@@ -444,10 +455,14 @@ def test_dns_health_links_checks_to_evidence(client: TestClient):
assert data["status"] == "degraded"
dkim_check = next(check for check in data["checks"] if check["key"] == "dkim")
mta_sts_check = next(check for check in data["checks"] if check["key"] == "mta_sts")
bimi_check = next(check for check in data["checks"] if check["key"] == "bimi")
assert dkim_check["status"] == "fail"
assert dkim_check["evidence"][0]["href"] == "#dns-records"
assert mta_sts_check["status"] == "pass"
assert mta_sts_check["evidence"][1]["href"] == "#mta-sts-posture"
assert bimi_check["status"] == "fail"
assert bimi_check["evidence"][0]["href"] == "#bimi-posture"
assert any(item["type"] == "bimi_dmarc_not_ready" for item in data["recommendations"])
assert any(item["type"] == "missing_dkim" for item in data["recommendations"])
@@ -498,6 +513,7 @@ def test_dns_health_marks_all_missing_records_critical(client: TestClient):
recommendation_types = [item["type"] for item in data["recommendations"]]
assert {"missing_dmarc", "missing_spf", "missing_dkim"}.issubset(set(recommendation_types))
assert recommendation_types.count("missing_mta_sts") == 1
assert recommendation_types.count("missing_bimi") == 1
assert any(item["type"] == "policy_needs_more_data" for item in data["recommendations"])
@@ -531,6 +547,58 @@ def test_mta_sts_endpoint_returns_404_for_unknown_domain(client: TestClient):
assert response.status_code == 404
def test_bimi_endpoint_returns_cached_posture(client: TestClient):
"""The domain detail page can fetch BIMI posture with cache metadata."""
checked_at = datetime(2026, 5, 23, 12, 0, 0)
result = BIMIResult(
status="pass",
selector="default",
query_name=f"default._bimi.{DOMAIN}",
dns_record="v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem",
logo_url="https://example.com/logo.svg",
certificate_url="https://example.com/vmc.pem",
)
with patch(
"app.api.api_v1.endpoints.domains.check_bimi_cached",
new=AsyncMock(return_value=(result, True, checked_at)),
):
response = client.get(f"/api/v1/domains/{DOMAIN}/dns/bimi")
assert response.status_code == 200
data = response.json()
assert data["status"] == "pass"
assert data["query_name"] == f"default._bimi.{DOMAIN}"
assert data["logo_url"] == "https://example.com/logo.svg"
assert data["cached"] is True
assert data["checked_at"] == checked_at.isoformat()
def test_bimi_endpoint_returns_404_for_unknown_domain(client: TestClient):
response = client.get("/api/v1/domains/unknown.example.com/dns/bimi")
assert response.status_code == 404
def test_domain_detail_data_endpoints_support_manually_configured_domain(
client: TestClient, db_session
):
"""Manually monitored domains should render empty detail data instead of 404s."""
db_session.add(Domain(name="manual.example", active=True))
db_session.commit()
reports = client.get("/api/v1/domains/manual.example/reports")
sources = client.get("/api/v1/domains/manual.example/sources")
selectors = client.get("/api/v1/domains/manual.example/selectors")
assert reports.status_code == 200
assert reports.json()["reports"] == []
assert sources.status_code == 200
assert sources.json()["sources"] == []
assert selectors.status_code == 200
assert selectors.json() == {"selectors": [], "report_selectors": []}
@pytest.mark.parametrize(
("policy", "summary", "expected_type", "expected_severity"),
[
+1 -1
View File
@@ -221,7 +221,7 @@ Goal: turn DMARQ into a broader email authentication posture console (still priv
Planned:
- MTA-STS posture: delivered cached `_mta-sts` TXT checks, HTTPS policy validation, domain-detail evidence, and operator guidance for missing, invalid, or non-enforcing policies. Optional helper tooling remains a future enhancement.
- TLS reporting posture: delivered authenticated TLS-RPT upload for `.json`, `.json.gz`, and `.zip` attachments; duplicate-safe persistence by report ID and policy domain; daily session trends; top failure-cause grouping; affected-domain summaries; and explicit privacy controls that avoid storing message content or recipient data.
- BIMI posture: record validation + readiness checks + operator guidance.
- BIMI posture: delivered default-selector BIMI TXT validation, HTTPS logo/certificate URL checks, DMARC enforcement readiness checks, domain-detail evidence, and operator guidance for missing or blocked BIMI prerequisites.
- Extended DNS checks that support the posture surface (e.g., MX/BIMI; optional DANE/TLSA where relevant).
Exit criteria:
+9
View File
@@ -96,6 +96,15 @@ Returns details for a specific domain.
}
```
#### Get BIMI Posture
```
GET /domains/{domain_id}/dns/bimi
```
Returns the cached BIMI TXT posture for the default selector, including the
queried DNS name, record text, logo URL, certificate URL, warnings, and errors.
#### Add Domain
```
+15
View File
@@ -71,6 +71,21 @@ DMARQ marks the check healthy when the TXT record contains `v=STSv1` with an `id
MTA-STS posture uses the same cached DNS refresh behavior as the existing DNS health checks. Use the DNS refresh action when you publish or update a policy and need DMARQ to re-check immediately.
### BIMI Readiness
The domain detail page checks the default BIMI selector at
`default._bimi.<domain>`.
DMARQ validates that the BIMI TXT record starts with `v=BIMI1`, includes an
HTTPS `l=` SVG logo URL, and uses HTTPS for the optional `a=` certificate URL.
The readiness guidance also checks whether DMARC is ready for BIMI: the domain
must use `p=quarantine` or `p=reject`, `pct` must be `100` or omitted, and any
published `sp=` subdomain policy must also enforce.
BIMI posture is read-only. Findings link back to the BIMI TXT record, logo URL,
certificate URL, and DMARC policy evidence so operators can see which
prerequisite is blocking readiness.
## Domain Groups
If you manage multiple domains, you can organize them into groups: