feat: add BIMI posture readiness checks
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user