Files
gh-christianlouis-dmarq/backend/app/services/bimi.py
T
2026-05-23 17:05:05 +02:00

204 lines
6.4 KiB
Python

"""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