feat: add mta-sts posture checks

This commit is contained in:
Christian Krakau-Louis
2026-05-23 16:16:55 +02:00
parent b30e28838b
commit 57e164d282
8 changed files with 866 additions and 17 deletions
+111 -1
View File
@@ -29,6 +29,7 @@ from app.services.dns_resolver import (
extract_dmarc_policy,
get_default_provider,
)
from app.services.mta_sts import MTAStsResult, check_mta_sts_cached
from app.services.report_persistence import (
delete_persisted_domain,
hydrate_report_store_from_db,
@@ -129,6 +130,22 @@ class DNSHealthResponse(BaseModel):
recommendations: List[DNSHealthRecommendation]
class MTAStsResponse(BaseModel):
"""MTA-STS posture result for a domain."""
status: str
dns_record: Optional[str] = None
policy_url: Optional[str] = None
policy_text: Optional[str] = None
mode: Optional[str] = None
max_age: Optional[int] = None
mx: List[str] = Field(default_factory=list)
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."""
@@ -445,6 +462,54 @@ def _enforcement_recommendation(
)
def _mta_sts_check(result: MTAStsResult) -> DNSHealthCheck:
evidence = [
_record_evidence("MTA-STS TXT", result.dns_record, "#dns-records"),
_record_evidence("Policy URL", result.policy_url, "#mta-sts-posture"),
]
if result.mode:
evidence.append(_record_evidence("Mode", result.mode, "#mta-sts-posture"))
if result.mx:
evidence.append(_record_evidence("MX patterns", ", ".join(result.mx), "#mta-sts-posture"))
message = (
"MTA-STS DNS and HTTPS policy are valid."
if result.status == "pass"
else (result.errors[0] if result.errors else "MTA-STS posture needs attention.")
)
return DNSHealthCheck(
key="mta_sts",
label="MTA-STS",
status=result.status,
message=message,
evidence=evidence,
)
def _mta_sts_recommendation(result: MTAStsResult) -> Optional[DNSHealthRecommendation]:
if result.status == "pass" and not result.warnings:
return None
severity = "warning" if result.status == "pass" else "error"
title = "MTA-STS policy needs review" if result.status == "pass" else "Publish MTA-STS"
detail = (
"; ".join(result.warnings)
if result.status == "pass"
else "; ".join(result.errors or ["MTA-STS is not configured or is not valid."])
)
action = (
"Move the policy to mode: enforce once MX coverage is confirmed."
if result.status == "pass"
else "Publish _mta-sts TXT and a valid HTTPS policy at the well-known URL."
)
return DNSHealthRecommendation(
type="mta_sts_review" if result.status == "pass" else "missing_mta_sts",
severity=severity,
title=title,
detail=detail,
action=action,
evidence=_mta_sts_check(result).evidence,
)
@router.get("/summary", response_model=DomainSummaryResponse)
async def get_domains_summary(db: Session = Depends(get_db)):
"""
@@ -769,6 +834,12 @@ async def get_domain_dns_health(
selectors=combined_selectors,
refresh=refresh,
)
mta_sts_result, _, _ = await check_mta_sts_cached(
db,
provider,
domain_id,
refresh=refresh,
)
summary = store.get_domain_summary(domain_id)
policy = extract_dmarc_policy(result.dmarc_record) or "none"
checks = [
@@ -802,10 +873,11 @@ async def get_domain_dns_health(
_record_evidence("DKIM TXT", result.dkim_record),
],
),
_mta_sts_check(mta_sts_result),
]
recommendations: List[DNSHealthRecommendation] = []
for check in checks:
if check.status == "fail":
if check.status == "fail" and check.key != "mta_sts":
recommendations.append(
DNSHealthRecommendation(
type=f"missing_{check.key}",
@@ -817,6 +889,9 @@ async def get_domain_dns_health(
)
)
recommendations.append(_enforcement_recommendation(policy, summary))
mta_sts_recommendation = _mta_sts_recommendation(mta_sts_result)
if mta_sts_recommendation:
recommendations.append(mta_sts_recommendation)
failed_checks = sum(1 for check in checks if check.status == "fail")
health_status = (
@@ -833,6 +908,41 @@ async def get_domain_dns_health(
)
@router.get("/{domain_id}/dns/mta-sts", response_model=MTAStsResponse)
async def get_domain_mta_sts(
domain_id: str = Path(..., title="The domain ID or name"),
refresh: bool = Query(False, title="Refresh cached MTA-STS result"),
db: Session = Depends(get_db),
):
"""Return cached MTA-STS DNS and HTTPS policy 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_mta_sts_cached(
db,
get_default_provider(db),
domain_id,
refresh=refresh,
)
return MTAStsResponse(
status=result.status,
dns_record=result.dns_record,
policy_url=result.policy_url,
policy_text=result.policy_text,
mode=result.mode,
max_age=result.max_age,
mx=result.mx,
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."""
+24 -3
View File
@@ -8,6 +8,7 @@ from dataclasses import asdict
from datetime import datetime, timedelta, timezone
from typing import List, Tuple
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.models.dns_cache import DNSCache
@@ -74,18 +75,38 @@ async def resolve_domain_dns_cached(
return _result_from_json(row.result_json), True, row.checked_at
result = await provider.check_domain(domain, selectors=selectors)
payload = _result_to_json(result)
if row is None:
row = DNSCache(
domain=domain,
provider=provider_name,
selectors_key=selectors_key,
result_json=_result_to_json(result),
result_json=payload,
checked_at=now,
)
db.add(row)
else:
row.result_json = _result_to_json(result)
row.result_json = payload
row.checked_at = now
db.commit()
try:
db.commit()
except IntegrityError:
db.rollback()
row = (
db.query(DNSCache)
.filter(
DNSCache.domain == domain,
DNSCache.provider == provider_name,
DNSCache.selectors_key == selectors_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
+216
View File
@@ -0,0 +1,216 @@
"""MTA-STS 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 Any, Dict, List, Optional, Tuple
import httpx
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 = "mta-sts-v1"
_POLICY_TIMEOUT_SECONDS = 5.0
_VALID_MODES = {"enforce", "testing", "none"}
@dataclass
class MTAStsResult:
"""Operator-facing MTA-STS posture evidence."""
status: str = "fail"
dns_record: Optional[str] = None
policy_url: Optional[str] = None
policy_text: Optional[str] = None
mode: Optional[str] = None
max_age: Optional[int] = None
mx: List[str] = field(default_factory=list)
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) -> MTAStsResult:
data = json.loads(value)
return MTAStsResult(
status=str(data.get("status") or "fail"),
dns_record=data.get("dns_record"),
policy_url=data.get("policy_url"),
policy_text=data.get("policy_text"),
mode=data.get("mode"),
max_age=data.get("max_age"),
mx=list(data.get("mx") or []),
errors=list(data.get("errors") or []),
warnings=list(data.get("warnings") or []),
)
def parse_mta_sts_record(records: List[str]) -> Tuple[Optional[str], List[str], List[str]]:
"""Return the selected MTA-STS TXT record, warnings, and errors."""
sts_records = [record for record in records if record.lower().startswith("v=stsv1")]
if not sts_records:
return None, [], ["No _mta-sts TXT record was found."]
warnings = []
if len(sts_records) > 1:
warnings.append("Multiple _mta-sts TXT records were found; publish exactly one.")
record = sts_records[0]
tags = {
part.split("=", 1)[0].strip().lower(): part.split("=", 1)[1].strip()
for part in record.split(";")
if "=" in part
}
errors = []
if tags.get("v", "").lower() != "stsv1":
errors.append("The _mta-sts TXT record must start with v=STSv1.")
if not tags.get("id"):
errors.append("The _mta-sts TXT record must include a non-empty id tag.")
return record, warnings, errors
def parse_mta_sts_policy( # noqa: C901
policy_text: str,
) -> Tuple[Dict[str, Any], List[str], List[str]]:
"""Parse and validate an MTA-STS policy file."""
data: Dict[str, Any] = {"mx": []}
for raw_line in policy_text.splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or ":" not in line:
continue
key, value = line.split(":", 1)
key = key.strip().lower()
value = value.strip()
if key == "mx":
data.setdefault("mx", []).append(value)
else:
data[key] = value
errors = []
warnings = []
if str(data.get("version", "")).upper() != "STSV1":
errors.append("The policy file must contain version: STSv1.")
mode = str(data.get("mode", "")).lower()
if mode not in _VALID_MODES:
errors.append("The policy file must contain mode: enforce, testing, or none.")
elif mode in {"testing", "none"}:
warnings.append(f"MTA-STS policy is valid but not enforcing mail delivery ({mode}).")
try:
max_age = int(str(data.get("max_age", "")))
if max_age <= 0:
errors.append("The policy max_age must be greater than zero.")
data["max_age"] = max_age
except ValueError:
errors.append("The policy file must contain an integer max_age value.")
if not data.get("mx"):
errors.append("The policy file must contain at least one mx entry.")
return data, warnings, errors
async def check_mta_sts(domain: str, provider: BaseDNSProvider) -> MTAStsResult:
"""Resolve the MTA-STS TXT record and validate the HTTPS policy file."""
result = MTAStsResult(policy_url=f"https://mta-sts.{domain}/.well-known/mta-sts.txt")
try:
records = await provider.lookup_txt(f"_mta-sts.{domain}")
except LookupError as exc:
result.errors.append(f"MTA-STS DNS lookup failed: {exc}")
return result
record, warnings, errors = parse_mta_sts_record(records)
result.dns_record = record
result.warnings.extend(warnings)
result.errors.extend(errors)
if record is None:
return result
try:
async with httpx.AsyncClient(
timeout=_POLICY_TIMEOUT_SECONDS, follow_redirects=False
) as client:
response = await client.get(result.policy_url)
response.raise_for_status()
result.policy_text = response.text
except (httpx.RequestError, httpx.HTTPStatusError, httpx.TimeoutException) as exc:
result.errors.append(f"MTA-STS policy fetch failed: {exc}")
return result
policy, policy_warnings, policy_errors = parse_mta_sts_policy(result.policy_text or "")
result.warnings.extend(policy_warnings)
result.errors.extend(policy_errors)
result.mode = policy.get("mode")
result.max_age = policy.get("max_age")
result.mx = list(policy.get("mx") or [])
result.status = "pass" if not result.errors else "fail"
return result
async def check_mta_sts_cached(
db: Session,
provider: BaseDNSProvider,
domain: str,
*,
ttl_seconds: int = DEFAULT_DNS_CACHE_TTL_SECONDS,
refresh: bool = False,
) -> Tuple[MTAStsResult, bool, datetime]:
"""Resolve MTA-STS posture, reusing the shared DNS cache semantics."""
now = _utcnow_naive()
provider_name = f"{provider.__class__.__name__}:mta-sts"
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_mta_sts(domain, provider)
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
+48 -1
View File
@@ -143,7 +143,7 @@
{% endcall %}
{% endcall %}
{% call card_content() %}
<div class="grid gap-4 lg:grid-cols-3">
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<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">
@@ -219,6 +219,30 @@
</h3>
<div class="bg-muted p-2 rounded text-sm overflow-x-auto font-mono" x-text="dns.spfRecord || 'No SPF record found'">-</div>
</div>
<div id="mta-sts-posture">
<h3 class="font-semibold mb-1 flex items-center">
<span class="mr-2">MTA-STS</span>
<span x-show="mtaSts.status === 'pass'" class="inline-flex h-2 w-2 rounded-full bg-green-500"></span>
<span x-show="mtaSts.status === 'fail'" class="inline-flex h-2 w-2 rounded-full bg-red-500"></span>
<span x-show="!mtaSts.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="mtaSts.dns_record || 'No _mta-sts TXT record found'">-</div>
<div class="rounded border border-base-300 p-3 text-sm">
<div class="grid gap-1">
<div><span class="font-medium">Mode:</span> <span x-text="mtaSts.mode || 'unknown'"></span></div>
<div><span class="font-medium">Max age:</span> <span x-text="mtaSts.max_age || 'unknown'"></span></div>
<div><span class="font-medium">MX:</span> <span class="font-mono" x-text="mtaSts.mx && mtaSts.mx.length ? mtaSts.mx.join(', ') : 'none'"></span></div>
</div>
<template x-if="mtaSts.errors && mtaSts.errors.length">
<p class="mt-2 text-xs text-red-600" x-text="mtaSts.errors[0]"></p>
</template>
<template x-if="mtaSts.warnings && mtaSts.warnings.length">
<p class="mt-2 text-xs text-yellow-700" x-text="mtaSts.warnings[0]"></p>
</template>
</div>
</div>
</div>
<!-- DKIM Selectors — live check result -->
<div>
<h3 class="font-semibold mb-1 flex items-center">
@@ -586,6 +610,15 @@ function domainDetailsApp(domainId) {
checks: [],
recommendations: []
},
mtaSts: {
status: '',
dns_record: '',
mode: '',
max_age: null,
mx: [],
errors: [],
warnings: []
},
selectors: [],
reportSelectors: [],
newSelector: '',
@@ -604,6 +637,7 @@ function domainDetailsApp(domainId) {
this.fetchDomainStats();
this.fetchDNSRecords();
this.fetchDNSHealth();
this.fetchMtaSts();
this.fetchSelectors();
this.fetchReports();
this.fetchSources();
@@ -687,6 +721,17 @@ function domainDetailsApp(domainId) {
}
},
async fetchMtaSts() {
try {
const response = await fetch(`/api/v1/domains/${this.domainId}/dns/mta-sts`);
if (response.ok) {
this.mtaSts = await response.json();
}
} catch (error) {
console.error('Error fetching MTA-STS posture:', error);
}
},
async fetchSelectors() {
try {
const response = await fetch(`/api/v1/domains/${this.domainId}/selectors`);
@@ -716,6 +761,7 @@ function domainDetailsApp(domainId) {
this.fetchSelectors();
this.fetchDNSRecords();
this.fetchDNSHealth();
this.fetchMtaSts();
} else {
const err = await response.json();
this.selectorError = err.detail || 'Failed to add selector';
@@ -737,6 +783,7 @@ function domainDetailsApp(domainId) {
this.fetchSelectors();
this.fetchDNSRecords();
this.fetchDNSHealth();
this.fetchMtaSts();
} else {
console.error('Error deleting selector:', response.status);
}
+152 -9
View File
@@ -8,16 +8,20 @@ that the endpoints can find the test domain.
DNS lookups are mocked so no real network calls are made.
"""
from datetime import datetime
from unittest.mock import AsyncMock, patch
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.exc import IntegrityError
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.dns_cache import _selectors_key, resolve_domain_dns_cached
from app.services.dns_resolver import DomainDNSResult
from app.services.mta_sts import MTAStsResult
from app.services.report_store import ReportStore
# ---------------------------------------------------------------------------
@@ -68,9 +72,12 @@ def _seed_report_store():
def _mock_dns(result: DomainDNSResult = MOCK_DNS_RESULT):
"""Return a context manager that patches the DNS provider's check_domain."""
provider = AsyncMock()
provider.check_domain = AsyncMock(return_value=result)
provider.lookup_txt = AsyncMock(side_effect=LookupError("MTA-STS not configured"))
return patch(
"app.api.api_v1.endpoints.domains.get_default_provider",
return_value=AsyncMock(check_domain=AsyncMock(return_value=result)),
return_value=provider,
)
@@ -278,6 +285,72 @@ def test_dns_endpoint_uses_cached_result(client: TestClient, db_session):
assert db_session.query(DNSCache).count() == 1
@pytest.mark.asyncio
async def test_dns_cache_recovers_from_concurrent_insert(db_session, monkeypatch):
"""Concurrent DNS widgets should not fail on a duplicate cache insert."""
mock_provider = AsyncMock(check_domain=AsyncMock(return_value=MOCK_DNS_RESULT))
selectors = ["google"]
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=DOMAIN,
provider=mock_provider.__class__.__name__,
selectors_key=_selectors_key(selectors),
result_json=(
'{"dmarc":false,"spf":false,"dkim":false,'
'"dkim_selectors":[],"selectors_checked":[]}'
),
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 resolve_domain_dns_cached(
db_session,
mock_provider,
DOMAIN,
selectors=selectors,
)
assert result == MOCK_DNS_RESULT
assert cached is False
assert db_session.query(DNSCache).count() == 1
@pytest.mark.asyncio
async def test_dns_cache_reraises_when_conflict_row_missing(db_session, monkeypatch):
"""Unexpected cache collisions should still surface when no row can be recovered."""
mock_provider = AsyncMock(check_domain=AsyncMock(return_value=MOCK_DNS_RESULT))
def fake_commit():
raise IntegrityError("insert", {}, Exception("duplicate"))
monkeypatch.setattr(db_session, "commit", fake_commit)
with pytest.raises(IntegrityError):
await resolve_domain_dns_cached(
db_session,
mock_provider,
DOMAIN,
selectors=["google"],
)
def test_dns_endpoint_refresh_bypasses_cache(client: TestClient):
"""The refresh query parameter forces a new DNS lookup."""
mock_provider = AsyncMock(check_domain=AsyncMock(return_value=MOCK_DNS_RESULT))
@@ -349,15 +422,32 @@ def test_dns_health_links_checks_to_evidence(client: TestClient):
selectors_checked=["google"],
)
with _mock_dns(result=missing_dkim):
mta_sts = MTAStsResult(
status="pass",
dns_record="v=STSv1; id=20260523",
policy_url="https://mta-sts.example.com/.well-known/mta-sts.txt",
mode="enforce",
max_age=86400,
mx=["*.example.com"],
)
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)),
),
):
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")
mta_sts_check = next(check for check in data["checks"] if check["key"] == "mta_sts")
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 any(item["type"] == "missing_dkim" for item in data["recommendations"])
@@ -405,21 +495,74 @@ def test_dns_health_marks_all_missing_records_critical(client: TestClient):
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)
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 any(item["type"] == "policy_needs_more_data" for item in data["recommendations"])
def test_mta_sts_endpoint_returns_cached_posture(client: TestClient):
"""The domain detail page can fetch MTA-STS posture with cache metadata."""
checked_at = datetime(2026, 5, 23, 12, 0, 0)
result = MTAStsResult(
status="fail",
dns_record=None,
policy_url=f"https://mta-sts.{DOMAIN}/.well-known/mta-sts.txt",
errors=["No _mta-sts TXT record was found."],
)
with patch(
"app.api.api_v1.endpoints.domains.check_mta_sts_cached",
new=AsyncMock(return_value=(result, True, checked_at)),
):
response = client.get(f"/api/v1/domains/{DOMAIN}/dns/mta-sts")
assert response.status_code == 200
data = response.json()
assert data["status"] == "fail"
assert data["cached"] is True
assert data["checked_at"] == checked_at.isoformat()
assert data["errors"] == ["No _mta-sts TXT record was found."]
def test_mta_sts_endpoint_returns_404_for_unknown_domain(client: TestClient):
response = client.get("/api/v1/domains/unknown.example.com/dns/mta-sts")
assert response.status_code == 404
@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"),
(
"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):
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)
+303
View File
@@ -0,0 +1,303 @@
from datetime import datetime
from unittest.mock import AsyncMock
import httpx
import pytest
from sqlalchemy.exc import IntegrityError
from app.models.dns_cache import DNSCache
from app.services.mta_sts import (
_CACHE_KEY,
MTAStsResult,
check_mta_sts,
check_mta_sts_cached,
parse_mta_sts_policy,
parse_mta_sts_record,
)
def test_parse_mta_sts_record_requires_single_record_with_id():
record, warnings, errors = parse_mta_sts_record(["v=STSv1; id=20260523"])
assert record == "v=STSv1; id=20260523"
assert warnings == []
assert errors == []
def test_parse_mta_sts_record_reports_missing_id_and_multiple_records():
record, warnings, errors = parse_mta_sts_record(["v=STSv1", "v=STSv1; id=two"])
assert record == "v=STSv1"
assert warnings == ["Multiple _mta-sts TXT records were found; publish exactly one."]
assert "id tag" in errors[0]
def test_parse_mta_sts_record_reports_missing_and_malformed_versions():
missing_record, missing_warnings, missing_errors = parse_mta_sts_record([])
malformed_record, malformed_warnings, malformed_errors = parse_mta_sts_record(
["v=STSv1x; id=bad"]
)
assert missing_record is None
assert missing_warnings == []
assert missing_errors == ["No _mta-sts TXT record was found."]
assert malformed_record == "v=STSv1x; id=bad"
assert malformed_warnings == []
assert malformed_errors == ["The _mta-sts TXT record must start with v=STSv1."]
def test_parse_mta_sts_policy_validates_required_fields():
policy, warnings, errors = parse_mta_sts_policy(
"version: STSv1\nmode: testing\nmx: mail.example.com\nmax_age: 86400\n"
)
assert policy["mode"] == "testing"
assert policy["mx"] == ["mail.example.com"]
assert policy["max_age"] == 86400
assert warnings == ["MTA-STS policy is valid but not enforcing mail delivery (testing)."]
assert errors == []
def test_parse_mta_sts_policy_reports_invalid_fields():
policy, warnings, errors = parse_mta_sts_policy(
"# comment\nignored line\nmode: invalid\nmax_age: 0\n"
)
bad_age_policy, bad_age_warnings, bad_age_errors = parse_mta_sts_policy(
"version: STSv1\nmode: enforce\nmax_age: nope\n"
)
assert policy["max_age"] == 0
assert warnings == []
assert "version: STSv1" in errors[0]
assert "mode: enforce, testing, or none" in errors[1]
assert "greater than zero" in errors[2]
assert "at least one mx" in errors[3]
assert bad_age_policy["mode"] == "enforce"
assert bad_age_warnings == []
assert "integer max_age" in bad_age_errors[0]
assert "at least one mx" in bad_age_errors[1]
class _FakeAsyncClient:
def __init__(self, response):
self.response = response
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return False
async def get(self, _url):
return self.response
@pytest.mark.asyncio
async def test_check_mta_sts_validates_dns_and_policy(monkeypatch):
provider = AsyncMock()
provider.lookup_txt = AsyncMock(return_value=["v=STSv1; id=20260523"])
request = httpx.Request("GET", "https://mta-sts.example.com/.well-known/mta-sts.txt")
response = httpx.Response(
200,
text="version: STSv1\nmode: enforce\nmx: *.example.com\nmax_age: 86400\n",
request=request,
)
monkeypatch.setattr(
"app.services.mta_sts.httpx.AsyncClient",
lambda **_: _FakeAsyncClient(response),
)
result = await check_mta_sts("example.com", provider)
assert result.status == "pass"
assert result.dns_record == "v=STSv1; id=20260523"
assert result.mode == "enforce"
assert result.mx == ["*.example.com"]
assert result.errors == []
@pytest.mark.asyncio
async def test_check_mta_sts_cached_reuses_fresh_result(db_session, monkeypatch):
provider = AsyncMock()
provider.lookup_txt = AsyncMock(return_value=["v=STSv1; id=20260523"])
request = httpx.Request("GET", "https://mta-sts.example.com/.well-known/mta-sts.txt")
response = httpx.Response(
200,
text="version: STSv1\nmode: enforce\nmx: mail.example.com\nmax_age: 86400\n",
request=request,
)
monkeypatch.setattr(
"app.services.mta_sts.httpx.AsyncClient",
lambda **_: _FakeAsyncClient(response),
)
first, first_cached, first_checked = await check_mta_sts_cached(
db_session, provider, "example.com"
)
second, second_cached, second_checked = await check_mta_sts_cached(
db_session, provider, "example.com"
)
assert isinstance(first, MTAStsResult)
assert first.status == "pass"
assert first_cached is False
assert second.status == "pass"
assert second_cached is True
assert isinstance(first_checked, datetime)
assert second_checked == first_checked
provider.lookup_txt.assert_awaited_once()
@pytest.mark.asyncio
async def test_check_mta_sts_returns_missing_record_without_policy_fetch(monkeypatch):
provider = AsyncMock()
provider.lookup_txt = AsyncMock(return_value=[])
fetch_attempted = False
class _UnexpectedAsyncClient:
async def __aenter__(self):
nonlocal fetch_attempted
fetch_attempted = True
return self
async def __aexit__(self, *_args):
return False
monkeypatch.setattr(
"app.services.mta_sts.httpx.AsyncClient",
lambda **_: _UnexpectedAsyncClient(),
)
result = await check_mta_sts("example.com", provider)
assert result.status == "fail"
assert result.errors == ["No _mta-sts TXT record was found."]
assert fetch_attempted is False
@pytest.mark.asyncio
async def test_check_mta_sts_reports_policy_fetch_error(monkeypatch):
provider = AsyncMock()
provider.lookup_txt = AsyncMock(return_value=["v=STSv1; id=20260523"])
request = httpx.Request("GET", "https://mta-sts.example.com/.well-known/mta-sts.txt")
response = httpx.Response(404, text="missing", request=request)
monkeypatch.setattr(
"app.services.mta_sts.httpx.AsyncClient",
lambda **_: _FakeAsyncClient(response),
)
result = await check_mta_sts("example.com", provider)
assert result.status == "fail"
assert result.policy_text is None
assert result.errors[0].startswith("MTA-STS policy fetch failed:")
@pytest.mark.asyncio
async def test_check_mta_sts_cached_refresh_updates_existing_row(db_session, monkeypatch):
provider = AsyncMock()
provider.lookup_txt = AsyncMock(return_value=["v=STSv1; id=20260523"])
request = httpx.Request("GET", "https://mta-sts.example.com/.well-known/mta-sts.txt")
responses = [
httpx.Response(
200,
text="version: STSv1\nmode: testing\nmx: mail.example.com\nmax_age: 86400\n",
request=request,
),
httpx.Response(
200,
text="version: STSv1\nmode: enforce\nmx: mail.example.com\nmax_age: 86400\n",
request=request,
),
]
monkeypatch.setattr(
"app.services.mta_sts.httpx.AsyncClient",
lambda **_: _FakeAsyncClient(responses.pop(0)),
)
first, first_cached, _first_checked = await check_mta_sts_cached(
db_session, provider, "example.com"
)
second, second_cached, _second_checked = await check_mta_sts_cached(
db_session, provider, "example.com", refresh=True
)
assert first.mode == "testing"
assert first_cached is False
assert second.mode == "enforce"
assert second_cached is False
assert db_session.query(DNSCache).count() == 1
@pytest.mark.asyncio
async def test_check_mta_sts_cached_recovers_from_concurrent_insert(db_session, monkeypatch):
"""Concurrent page widgets should not fail on a duplicate cache insert."""
provider = AsyncMock()
provider.lookup_txt = AsyncMock(return_value=["v=STSv1; id=20260523"])
request = httpx.Request("GET", "https://mta-sts.example.com/.well-known/mta-sts.txt")
response = httpx.Response(
200,
text="version: STSv1\nmode: enforce\nmx: mail.example.com\nmax_age: 86400\n",
request=request,
)
monkeypatch.setattr(
"app.services.mta_sts.httpx.AsyncClient",
lambda **_: _FakeAsyncClient(response),
)
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__}:mta-sts",
selectors_key=_CACHE_KEY,
result_json='{"status":"fail","errors":["stale"],"warnings":[],"mx":[]}',
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_mta_sts_cached(db_session, provider, "example.com")
assert result.status == "pass"
assert cached is False
assert db_session.query(DNSCache).count() == 1
@pytest.mark.asyncio
async def test_check_mta_sts_cached_reraises_when_conflict_row_missing(db_session, monkeypatch):
provider = AsyncMock()
provider.lookup_txt = AsyncMock(return_value=["v=STSv1; id=20260523"])
request = httpx.Request("GET", "https://mta-sts.example.com/.well-known/mta-sts.txt")
response = httpx.Response(
200,
text="version: STSv1\nmode: enforce\nmx: mail.example.com\nmax_age: 86400\n",
request=request,
)
monkeypatch.setattr(
"app.services.mta_sts.httpx.AsyncClient",
lambda **_: _FakeAsyncClient(response),
)
def fake_commit():
raise IntegrityError("insert", {}, Exception("duplicate"))
monkeypatch.setattr(db_session, "commit", fake_commit)
with pytest.raises(IntegrityError):
await check_mta_sts_cached(db_session, provider, "example.com")
+2 -2
View File
@@ -214,12 +214,12 @@ Exit criteria:
## Milestone 13: Email Security Posture (Beyond DMARC)
Status: Backlog
Status: In Progress
Goal: turn DMARQ into a broader email authentication posture console (still privacy-first and self-hostable).
Planned:
- MTA-STS posture: DNS record evaluation + policy fetch validation (plus optional helper tooling).
- 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: ingest and summarize TLS report data (where available) with actionable failure grouping.
- BIMI posture: record validation + readiness checks + operator guidance.
- Extended DNS checks that support the posture surface (e.g., MX/BIMI; optional DANE/TLSA where relevant).
+10 -1
View File
@@ -59,9 +59,18 @@ DMARQ provides a health check feature for each domain:
- SPF record validation
- DKIM selector verification
- DMARC record syntax check
- MTA-STS TXT and HTTPS policy validation
- MX record confirmation
- BIMI record validation (if applicable)
### MTA-STS Posture
The domain detail page checks `_mta-sts.<domain>` and fetches the policy from `https://mta-sts.<domain>/.well-known/mta-sts.txt`.
DMARQ marks the check healthy when the TXT record contains `v=STSv1` with an `id`, the HTTPS policy is reachable, and the policy includes `version`, `mode`, `mx`, and `max_age`. Findings include the DNS record, policy URL, mode, MX patterns, and actionable guidance for missing records, fetch failures, invalid policies, or non-enforcing `testing`/`none` modes.
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.
## Domain Groups
If you manage multiple domains, you can organize them into groups:
@@ -86,4 +95,4 @@ To remove a domain from DMARQ:
4. Select **Remove Domain**
5. Confirm the removal
Note that removing a domain will delete all stored DMARC reports for that domain.
Note that removing a domain will delete all stored DMARC reports for that domain.