feat: add posture dashboard playbooks
This commit is contained in:
@@ -131,6 +131,50 @@ class DNSHealthResponse(BaseModel):
|
|||||||
recommendations: List[DNSHealthRecommendation]
|
recommendations: List[DNSHealthRecommendation]
|
||||||
|
|
||||||
|
|
||||||
|
class PostureCoverageItem(BaseModel):
|
||||||
|
"""Operator-facing coverage state for one posture area."""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
status: str
|
||||||
|
message: str
|
||||||
|
evidence_count: int
|
||||||
|
href: str
|
||||||
|
|
||||||
|
|
||||||
|
class PostureChangeSummary(BaseModel):
|
||||||
|
"""Concise summary of observed posture drift."""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
detail: str
|
||||||
|
severity: str
|
||||||
|
observed_at: Optional[str] = None
|
||||||
|
evidence: List[DNSHealthEvidence] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class OperatorPlaybook(BaseModel):
|
||||||
|
"""Short remediation playbook for a posture recommendation."""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
title: str
|
||||||
|
summary: str
|
||||||
|
steps: List[str]
|
||||||
|
evidence: List[DNSHealthEvidence] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class PostureDashboardResponse(BaseModel):
|
||||||
|
"""Evidence-first posture dashboard response for a domain."""
|
||||||
|
|
||||||
|
domain: str
|
||||||
|
status: str
|
||||||
|
score: int
|
||||||
|
summary: str
|
||||||
|
coverage: List[PostureCoverageItem]
|
||||||
|
recommendations: List[DNSHealthRecommendation]
|
||||||
|
changes: List[PostureChangeSummary]
|
||||||
|
playbooks: List[OperatorPlaybook]
|
||||||
|
|
||||||
|
|
||||||
class MTAStsResponse(BaseModel):
|
class MTAStsResponse(BaseModel):
|
||||||
"""MTA-STS posture result for a domain."""
|
"""MTA-STS posture result for a domain."""
|
||||||
|
|
||||||
@@ -560,7 +604,10 @@ def _bimi_recommendation(
|
|||||||
severity="info",
|
severity="info",
|
||||||
title="BIMI record needs provider-readiness review",
|
title="BIMI record needs provider-readiness review",
|
||||||
detail="; ".join(result.warnings),
|
detail="; ".join(result.warnings),
|
||||||
action="Confirm the SVG logo profile and add a certificate URL if mailbox providers require one.",
|
action=(
|
||||||
|
"Confirm the SVG logo profile and add a certificate URL if mailbox "
|
||||||
|
"providers require one."
|
||||||
|
),
|
||||||
evidence=bimi_evidence,
|
evidence=bimi_evidence,
|
||||||
)
|
)
|
||||||
return DNSHealthRecommendation(
|
return DNSHealthRecommendation(
|
||||||
@@ -621,6 +668,303 @@ def _mta_sts_recommendation(result: MTAStsResult) -> Optional[DNSHealthRecommend
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_domain_dns_health( # pylint: disable=too-many-locals
|
||||||
|
db: Session,
|
||||||
|
store: ReportStore,
|
||||||
|
domain_id: str,
|
||||||
|
*,
|
||||||
|
refresh: bool = False,
|
||||||
|
) -> DNSHealthResponse:
|
||||||
|
"""Build the shared DNS/posture health payload for a monitored domain."""
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
mta_sts_result, _, _ = await check_mta_sts_cached(
|
||||||
|
db,
|
||||||
|
provider,
|
||||||
|
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",
|
||||||
|
"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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_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 not in {"mta_sts", "bimi"}:
|
||||||
|
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))
|
||||||
|
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 = (
|
||||||
|
"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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _coverage_href(check: DNSHealthCheck) -> str:
|
||||||
|
if check.evidence:
|
||||||
|
return check.evidence[0].href
|
||||||
|
return "#posture-dashboard"
|
||||||
|
|
||||||
|
|
||||||
|
def _posture_summary(health: DNSHealthResponse) -> str:
|
||||||
|
if health.status == "healthy":
|
||||||
|
return "All configured posture checks are passing."
|
||||||
|
failed = sum(1 for check in health.checks if check.status == "fail")
|
||||||
|
area = "area" if failed == 1 else "areas"
|
||||||
|
if health.status == "degraded":
|
||||||
|
verb = "needs" if failed == 1 else "need"
|
||||||
|
return f"{failed} posture {area} {verb} review before this domain is fully ready."
|
||||||
|
return f"{failed} posture {area} need attention before this domain is safe to tighten."
|
||||||
|
|
||||||
|
|
||||||
|
def _change_title(change: Dict[str, Any]) -> str:
|
||||||
|
record_type = change.get("record_type") or "DNS"
|
||||||
|
record_name = change.get("record_name") or "record"
|
||||||
|
change_type = change.get("change_type") or "changed"
|
||||||
|
return f"{record_type} {record_name} {change_type}"
|
||||||
|
|
||||||
|
|
||||||
|
def _change_summaries(changes: List[Dict[str, Any]]) -> List[PostureChangeSummary]:
|
||||||
|
if not changes:
|
||||||
|
return [
|
||||||
|
PostureChangeSummary(
|
||||||
|
title="No tracked DNS drift yet",
|
||||||
|
detail=(
|
||||||
|
"Provider-backed DNS change tracking has not observed a DMARC, SPF, "
|
||||||
|
"DKIM, MTA-STS, or BIMI record change for this domain."
|
||||||
|
),
|
||||||
|
severity="info",
|
||||||
|
evidence=[
|
||||||
|
DNSHealthEvidence(
|
||||||
|
label="Change history",
|
||||||
|
value="No provider-backed DNS changes recorded",
|
||||||
|
href="#posture-changes",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
summaries: List[PostureChangeSummary] = []
|
||||||
|
for change in changes[:5]:
|
||||||
|
previous = change.get("previous_content") or "none"
|
||||||
|
current = change.get("current_content") or "none"
|
||||||
|
summaries.append(
|
||||||
|
PostureChangeSummary(
|
||||||
|
title=_change_title(change),
|
||||||
|
detail="DNS provider history recorded a posture-relevant record change.",
|
||||||
|
severity=(
|
||||||
|
"warning" if change.get("change_type") in {"modified", "removed"} else "info"
|
||||||
|
),
|
||||||
|
observed_at=change.get("observed_at"),
|
||||||
|
evidence=[
|
||||||
|
DNSHealthEvidence(
|
||||||
|
label="Previous", value=str(previous), href="#posture-changes"
|
||||||
|
),
|
||||||
|
DNSHealthEvidence(label="Current", value=str(current), href="#posture-changes"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return summaries
|
||||||
|
|
||||||
|
|
||||||
|
def _playbook_steps(recommendation: DNSHealthRecommendation) -> List[str]:
|
||||||
|
playbooks = {
|
||||||
|
"missing_dmarc": [
|
||||||
|
"Publish one TXT record at _dmarc for this domain.",
|
||||||
|
"Start with p=none and rua pointing at the reporting mailbox DMARQ imports.",
|
||||||
|
"Refresh DNS health and wait for aggregate reports before tightening policy.",
|
||||||
|
],
|
||||||
|
"missing_spf": [
|
||||||
|
"List every service that is allowed to send mail for this domain.",
|
||||||
|
"Publish one root SPF TXT record that includes those senders.",
|
||||||
|
"Keep the SPF record to a single TXT value and refresh DNS health.",
|
||||||
|
],
|
||||||
|
"missing_dkim": [
|
||||||
|
"Add the sending provider's DKIM selector to this domain in DMARQ.",
|
||||||
|
"Publish the provider's selector TXT record in DNS.",
|
||||||
|
"Refresh DNS health and confirm at least one selector resolves.",
|
||||||
|
],
|
||||||
|
"policy_enforcement_ready": [
|
||||||
|
"Review the linked failed sources before changing policy.",
|
||||||
|
"Move to quarantine gradually with a small pct value.",
|
||||||
|
"Watch DMARC failures for several report cycles before increasing pct.",
|
||||||
|
],
|
||||||
|
"policy_enforcement_review": [
|
||||||
|
"Open the linked sending sources and identify the remaining failures.",
|
||||||
|
"Fix SPF or DKIM alignment for legitimate senders.",
|
||||||
|
"Re-check readiness after compliance is consistently above the threshold.",
|
||||||
|
],
|
||||||
|
"policy_not_ready": [
|
||||||
|
"Treat unknown full-fail senders as untrusted until verified.",
|
||||||
|
"Configure SPF and DKIM for legitimate sources first.",
|
||||||
|
"Keep monitoring mode until failures drop to a safe level.",
|
||||||
|
],
|
||||||
|
"missing_mta_sts": [
|
||||||
|
"Publish _mta-sts TXT with a stable id value.",
|
||||||
|
"Host a valid policy file at the linked well-known HTTPS URL.",
|
||||||
|
"Start in testing mode, then move to enforce after MX coverage is verified.",
|
||||||
|
],
|
||||||
|
"mta_sts_review": [
|
||||||
|
"Open the linked policy evidence and confirm all MX hosts are covered.",
|
||||||
|
"Fix policy warnings and increase max_age when stable.",
|
||||||
|
"Move to enforce only after successful validation.",
|
||||||
|
],
|
||||||
|
"missing_bimi": [
|
||||||
|
"Complete DMARC enforcement prerequisites first.",
|
||||||
|
"Publish a default._bimi TXT record with an HTTPS SVG logo URL.",
|
||||||
|
"Add a certificate URL if the mailbox providers you care about require it.",
|
||||||
|
],
|
||||||
|
"bimi_dmarc_not_ready": [
|
||||||
|
"Move DMARC to quarantine or reject at pct=100.",
|
||||||
|
"Confirm subdomain policy does not weaken enforcement.",
|
||||||
|
"Refresh BIMI readiness after the DMARC record is updated.",
|
||||||
|
],
|
||||||
|
"bimi_review": [
|
||||||
|
"Confirm the logo is a valid HTTPS SVG asset.",
|
||||||
|
"Add or verify the certificate URL for providers that require it.",
|
||||||
|
"Refresh BIMI readiness and keep the evidence links with the record.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return playbooks.get(
|
||||||
|
recommendation.type,
|
||||||
|
[
|
||||||
|
recommendation.action,
|
||||||
|
"Use the linked evidence to confirm the record or report data.",
|
||||||
|
"Refresh posture after the change is published.",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _operator_playbooks(
|
||||||
|
recommendations: List[DNSHealthRecommendation],
|
||||||
|
) -> List[OperatorPlaybook]:
|
||||||
|
return [
|
||||||
|
OperatorPlaybook(
|
||||||
|
key=recommendation.type,
|
||||||
|
title=recommendation.title,
|
||||||
|
summary=recommendation.action,
|
||||||
|
steps=_playbook_steps(recommendation),
|
||||||
|
evidence=recommendation.evidence,
|
||||||
|
)
|
||||||
|
for recommendation in recommendations
|
||||||
|
if recommendation.severity in {"error", "warning"}
|
||||||
|
or recommendation.type.startswith("policy_")
|
||||||
|
][:6]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_posture_dashboard(
|
||||||
|
domain_id: str,
|
||||||
|
health: DNSHealthResponse,
|
||||||
|
changes: List[Dict[str, Any]],
|
||||||
|
) -> PostureDashboardResponse:
|
||||||
|
passing = sum(1 for check in health.checks if check.status == "pass")
|
||||||
|
score = round((passing / len(health.checks)) * 100) if health.checks else 0
|
||||||
|
coverage = [
|
||||||
|
PostureCoverageItem(
|
||||||
|
key=check.key,
|
||||||
|
label=check.label,
|
||||||
|
status=check.status,
|
||||||
|
message=check.message,
|
||||||
|
evidence_count=len(check.evidence),
|
||||||
|
href=_coverage_href(check),
|
||||||
|
)
|
||||||
|
for check in health.checks
|
||||||
|
]
|
||||||
|
return PostureDashboardResponse(
|
||||||
|
domain=domain_id,
|
||||||
|
status=health.status,
|
||||||
|
score=score,
|
||||||
|
summary=_posture_summary(health),
|
||||||
|
coverage=coverage,
|
||||||
|
recommendations=health.recommendations,
|
||||||
|
changes=_change_summaries(changes),
|
||||||
|
playbooks=_operator_playbooks(health.recommendations),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/summary", response_model=DomainSummaryResponse)
|
@router.get("/summary", response_model=DomainSummaryResponse)
|
||||||
async def get_domains_summary(db: Session = Depends(get_db)):
|
async def get_domains_summary(db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
@@ -933,108 +1277,27 @@ async def get_domain_dns_health(
|
|||||||
detail="Domain not found",
|
detail="Domain not found",
|
||||||
)
|
)
|
||||||
|
|
||||||
manual_selectors = _get_domain_selectors_from_db(db, domain_id)
|
return await _build_domain_dns_health(db, store, domain_id, refresh=refresh)
|
||||||
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,
|
|
||||||
)
|
|
||||||
mta_sts_result, _, _ = await check_mta_sts_cached(
|
|
||||||
db,
|
|
||||||
provider,
|
|
||||||
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",
|
|
||||||
"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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
_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 not in {"mta_sts", "bimi"}:
|
|
||||||
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))
|
|
||||||
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")
|
@router.get("/{domain_id}/posture", response_model=PostureDashboardResponse)
|
||||||
health_status = (
|
async def get_domain_posture_dashboard(
|
||||||
"healthy" if failed_checks == 0 else "degraded" if failed_checks < 3 else "critical"
|
domain_id: str = Path(..., title="The domain ID or name"),
|
||||||
)
|
refresh: bool = Query(False, title="Refresh cached DNS posture"),
|
||||||
return DNSHealthResponse(
|
db: Session = Depends(get_db),
|
||||||
status=health_status,
|
):
|
||||||
policy=policy,
|
"""Return an evidence-first posture dashboard for a monitored domain."""
|
||||||
compliance_rate=float(summary.get("compliance_rate", 0.0) or 0.0),
|
store = ReportStore.get_instance()
|
||||||
total_emails=int(summary.get("total_count", 0) or 0),
|
hydrate_report_store_from_db(db, store)
|
||||||
failed_emails=int(summary.get("failed_count", 0) or 0),
|
if not _domain_exists(db, store, domain_id):
|
||||||
checks=checks,
|
raise HTTPException(
|
||||||
recommendations=recommendations,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
)
|
detail="Domain not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
health = await _build_domain_dns_health(db, store, domain_id, refresh=refresh)
|
||||||
|
changes = list_dns_record_changes(db, domain_id, limit=10)
|
||||||
|
return _build_posture_dashboard(domain_id, health, changes)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{domain_id}/dns/mta-sts", response_model=MTAStsResponse)
|
@router.get("/{domain_id}/dns/mta-sts", response_model=MTAStsResponse)
|
||||||
|
|||||||
@@ -128,63 +128,127 @@
|
|||||||
{% endcall %}
|
{% endcall %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- DNS Health Summary -->
|
<!-- Posture Dashboard -->
|
||||||
<section id="dns-health-summary">
|
<section id="posture-dashboard">
|
||||||
{% call card() %}
|
{% call card() %}
|
||||||
{% call card_header() %}
|
{% call card_header() %}
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
{% call card_title() %}DNS Health Summary{% endcall %}
|
<div>
|
||||||
<span class="inline-flex items-center rounded px-2 py-1 text-xs font-semibold capitalize"
|
{% call card_title() %}Posture Dashboard{% endcall %}
|
||||||
:class="dnsHealthStatusClass"
|
{% call card_description() %}
|
||||||
x-text="dnsHealth.status || 'checking'"></span>
|
Evidence-linked coverage, drift, and operator playbooks
|
||||||
|
{% endcall %}
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<span class="inline-flex items-center rounded px-2 py-1 text-xs font-semibold capitalize"
|
||||||
|
:class="postureStatusClass"
|
||||||
|
x-text="posture.status || 'checking'"></span>
|
||||||
|
<div class="mt-1 text-xs text-base-content/60">
|
||||||
|
<span x-text="posture.score"></span><span>/100</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% call card_description() %}
|
|
||||||
Evidence-linked authentication posture and enforcement readiness
|
|
||||||
{% endcall %}
|
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% call card_content() %}
|
{% call card_content() %}
|
||||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<div class="grid gap-4 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||||||
<template x-for="check in dnsHealth.checks" :key="check.key">
|
<div class="rounded border border-base-300 p-4">
|
||||||
<div class="rounded border border-base-300 p-4">
|
<div class="text-sm text-base-content/60">Posture score</div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="mt-2 text-4xl font-bold" x-text="posture.score"></div>
|
||||||
<h3 class="font-semibold" x-text="check.label"></h3>
|
<p class="mt-2 text-sm text-base-content/70" x-text="posture.summary"></p>
|
||||||
<span class="rounded px-2 py-1 text-xs font-semibold capitalize"
|
</div>
|
||||||
:class="check.status === 'pass' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||||
x-text="check.status"></span>
|
<template x-for="item in posture.coverage" :key="item.key">
|
||||||
</div>
|
<a :href="item.href" class="rounded border border-base-300 p-3 hover:border-primary">
|
||||||
<p class="mt-2 text-sm text-base-content/70" x-text="check.message"></p>
|
<div class="flex items-center justify-between gap-2">
|
||||||
<div class="mt-3 space-y-1">
|
<h3 class="font-semibold" x-text="item.label"></h3>
|
||||||
<template x-for="item in check.evidence" :key="item.label + item.value">
|
<span class="rounded px-2 py-1 text-xs font-semibold capitalize"
|
||||||
<a :href="item.href" class="block text-xs link link-primary">
|
:class="item.status === 'pass' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
||||||
<span x-text="item.label"></span>:
|
x-text="item.status"></span>
|
||||||
<span class="font-mono break-all" x-text="item.value"></span>
|
</div>
|
||||||
</a>
|
<p class="mt-2 text-xs text-base-content/70" x-text="item.message"></p>
|
||||||
</template>
|
<p class="mt-2 text-xs text-base-content/50">
|
||||||
</div>
|
<span x-text="item.evidence_count"></span>
|
||||||
</div>
|
<span> evidence links</span>
|
||||||
</template>
|
</p>
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-5 space-y-3">
|
<div class="mt-5 grid gap-5 xl:grid-cols-2">
|
||||||
<template x-for="recommendation in dnsHealth.recommendations" :key="recommendation.type">
|
<div class="space-y-3">
|
||||||
<div class="rounded border border-base-300 p-4">
|
<h3 class="font-semibold">Recommendations</h3>
|
||||||
<div class="flex items-start gap-3">
|
<template x-for="recommendation in posture.recommendations" :key="recommendation.type">
|
||||||
<span class="mt-1 h-2.5 w-2.5 rounded-full"
|
<div class="rounded border border-base-300 p-4">
|
||||||
:class="recommendationSeverityClass(recommendation.severity)"></span>
|
<div class="flex items-start gap-3">
|
||||||
<div class="min-w-0 flex-1">
|
<span class="mt-1 h-2.5 w-2.5 rounded-full"
|
||||||
<h3 class="font-semibold" x-text="recommendation.title"></h3>
|
:class="recommendationSeverityClass(recommendation.severity)"></span>
|
||||||
<p class="mt-1 text-sm text-base-content/70" x-text="recommendation.detail"></p>
|
<div class="min-w-0 flex-1">
|
||||||
<p class="mt-2 text-sm font-medium" x-text="recommendation.action"></p>
|
<h4 class="font-semibold" x-text="recommendation.title"></h4>
|
||||||
<div class="mt-3 flex flex-wrap gap-2">
|
<p class="mt-1 text-sm text-base-content/70" x-text="recommendation.detail"></p>
|
||||||
<template x-for="item in recommendation.evidence" :key="item.label + item.value">
|
<p class="mt-2 text-sm font-medium" x-text="recommendation.action"></p>
|
||||||
<a :href="item.href" class="badge badge-outline gap-1">
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
<span x-text="item.label"></span>
|
<template x-for="item in recommendation.evidence" :key="item.label + item.value">
|
||||||
<span x-text="item.value"></span>
|
<a :href="item.href" class="badge badge-outline gap-1">
|
||||||
</a>
|
<span x-text="item.label"></span>
|
||||||
</template>
|
<span x-text="item.value"></span>
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3" id="posture-changes">
|
||||||
|
<h3 class="font-semibold">What Changed</h3>
|
||||||
|
<template x-for="change in posture.changes" :key="change.title + change.observed_at">
|
||||||
|
<div class="rounded border border-base-300 p-4">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="mt-1 h-2.5 w-2.5 rounded-full"
|
||||||
|
:class="recommendationSeverityClass(change.severity)"></span>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h4 class="font-semibold" x-text="change.title"></h4>
|
||||||
|
<p class="mt-1 text-sm text-base-content/70" x-text="change.detail"></p>
|
||||||
|
<p class="mt-1 text-xs text-base-content/50" x-show="change.observed_at" x-text="formatIsoDate(change.observed_at)"></p>
|
||||||
|
<div class="mt-3 space-y-1">
|
||||||
|
<template x-for="item in change.evidence" :key="item.label + item.value">
|
||||||
|
<div class="text-xs">
|
||||||
|
<span class="font-medium" x-text="item.label"></span>:
|
||||||
|
<span class="font-mono break-all" x-text="item.value"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 grid gap-3 lg:grid-cols-3">
|
||||||
|
<template x-for="playbook in posture.playbooks" :key="playbook.key">
|
||||||
|
<div class="rounded border border-base-300 p-4">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold" x-text="playbook.title"></h3>
|
||||||
|
<p class="mt-1 text-sm text-base-content/70" x-text="playbook.summary"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ol class="mt-3 list-decimal space-y-1 pl-4 text-sm">
|
||||||
|
<template x-for="step in playbook.steps" :key="step">
|
||||||
|
<li x-text="step"></li>
|
||||||
|
</template>
|
||||||
|
</ol>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<template x-for="item in playbook.evidence" :key="item.label + item.value">
|
||||||
|
<a :href="item.href" class="badge badge-outline gap-1">
|
||||||
|
<span x-text="item.label"></span>
|
||||||
|
<span x-text="item.value"></span>
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -634,6 +698,15 @@ function domainDetailsApp(domainId) {
|
|||||||
checks: [],
|
checks: [],
|
||||||
recommendations: []
|
recommendations: []
|
||||||
},
|
},
|
||||||
|
posture: {
|
||||||
|
status: '',
|
||||||
|
score: 0,
|
||||||
|
summary: '',
|
||||||
|
coverage: [],
|
||||||
|
recommendations: [],
|
||||||
|
changes: [],
|
||||||
|
playbooks: []
|
||||||
|
},
|
||||||
mtaSts: {
|
mtaSts: {
|
||||||
status: '',
|
status: '',
|
||||||
dns_record: '',
|
dns_record: '',
|
||||||
@@ -671,6 +744,7 @@ function domainDetailsApp(domainId) {
|
|||||||
this.fetchDomainStats();
|
this.fetchDomainStats();
|
||||||
this.fetchDNSRecords();
|
this.fetchDNSRecords();
|
||||||
this.fetchDNSHealth();
|
this.fetchDNSHealth();
|
||||||
|
this.fetchPosture();
|
||||||
this.fetchMtaSts();
|
this.fetchMtaSts();
|
||||||
this.fetchBimi();
|
this.fetchBimi();
|
||||||
this.fetchSelectors();
|
this.fetchSelectors();
|
||||||
@@ -715,6 +789,13 @@ function domainDetailsApp(domainId) {
|
|||||||
return 'bg-base-200 text-base-content/70';
|
return 'bg-base-200 text-base-content/70';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
get postureStatusClass() {
|
||||||
|
if (this.posture.status === 'healthy') return 'bg-green-100 text-green-700';
|
||||||
|
if (this.posture.status === 'degraded') return 'bg-yellow-100 text-yellow-800';
|
||||||
|
if (this.posture.status === 'critical') return 'bg-red-100 text-red-700';
|
||||||
|
return 'bg-base-200 text-base-content/70';
|
||||||
|
},
|
||||||
|
|
||||||
recommendationSeverityClass(severity) {
|
recommendationSeverityClass(severity) {
|
||||||
if (severity === 'error') return 'bg-red-500';
|
if (severity === 'error') return 'bg-red-500';
|
||||||
if (severity === 'warning') return 'bg-yellow-500';
|
if (severity === 'warning') return 'bg-yellow-500';
|
||||||
@@ -756,6 +837,17 @@ function domainDetailsApp(domainId) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async fetchPosture() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/domains/${this.domainId}/posture`);
|
||||||
|
if (response.ok) {
|
||||||
|
this.posture = await response.json();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching posture dashboard:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async fetchMtaSts() {
|
async fetchMtaSts() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/domains/${this.domainId}/dns/mta-sts`);
|
const response = await fetch(`/api/v1/domains/${this.domainId}/dns/mta-sts`);
|
||||||
@@ -807,6 +899,7 @@ function domainDetailsApp(domainId) {
|
|||||||
this.fetchSelectors();
|
this.fetchSelectors();
|
||||||
this.fetchDNSRecords();
|
this.fetchDNSRecords();
|
||||||
this.fetchDNSHealth();
|
this.fetchDNSHealth();
|
||||||
|
this.fetchPosture();
|
||||||
this.fetchMtaSts();
|
this.fetchMtaSts();
|
||||||
} else {
|
} else {
|
||||||
const err = await response.json();
|
const err = await response.json();
|
||||||
@@ -829,6 +922,7 @@ function domainDetailsApp(domainId) {
|
|||||||
this.fetchSelectors();
|
this.fetchSelectors();
|
||||||
this.fetchDNSRecords();
|
this.fetchDNSRecords();
|
||||||
this.fetchDNSHealth();
|
this.fetchDNSHealth();
|
||||||
|
this.fetchPosture();
|
||||||
this.fetchMtaSts();
|
this.fetchMtaSts();
|
||||||
} else {
|
} else {
|
||||||
console.error('Error deleting selector:', response.status);
|
console.error('Error deleting selector:', response.status);
|
||||||
@@ -1045,6 +1139,12 @@ function domainDetailsApp(domainId) {
|
|||||||
const date = new Date(timestamp * 1000);
|
const date = new Date(timestamp * 1000);
|
||||||
return date.toLocaleDateString();
|
return date.toLocaleDateString();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
formatIsoDate(value) {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString();
|
||||||
|
},
|
||||||
|
|
||||||
getPassRateClass(rate) {
|
getPassRateClass(rate) {
|
||||||
if (rate >= 90) return 'bg-green-100 text-green-800';
|
if (rate >= 90) return 'bg-green-100 text-green-800';
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from sqlalchemy.exc import IntegrityError
|
|||||||
|
|
||||||
from app.api.api_v1.endpoints import domains as domains_endpoint
|
from app.api.api_v1.endpoints import domains as domains_endpoint
|
||||||
from app.api.api_v1.endpoints.domains import _spf_fix_hint
|
from app.api.api_v1.endpoints.domains import _spf_fix_hint
|
||||||
from app.models.dns_cache import DNSCache
|
from app.models.dns_cache import DNSCache, DNSRecordChange
|
||||||
from app.models.domain import Domain
|
from app.models.domain import Domain
|
||||||
from app.services.bimi import BIMIResult
|
from app.services.bimi import BIMIResult
|
||||||
from app.services.dns_cache import _selectors_key, resolve_domain_dns_cached
|
from app.services.dns_cache import _selectors_key, resolve_domain_dns_cached
|
||||||
@@ -580,6 +580,82 @@ def test_bimi_endpoint_returns_404_for_unknown_domain(client: TestClient):
|
|||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_posture_dashboard_links_recommendations_changes_and_playbooks(
|
||||||
|
client: TestClient, db_session
|
||||||
|
):
|
||||||
|
"""The posture dashboard is actionable and links back to underlying evidence."""
|
||||||
|
db_session.add(
|
||||||
|
DNSRecordChange(
|
||||||
|
domain=DOMAIN,
|
||||||
|
provider="cloudflare",
|
||||||
|
zone_id="zone-1",
|
||||||
|
record_key="dmarc",
|
||||||
|
record_type="TXT",
|
||||||
|
record_name=f"_dmarc.{DOMAIN}",
|
||||||
|
change_type="modified",
|
||||||
|
previous_content="v=DMARC1; p=none",
|
||||||
|
current_content="v=DMARC1; p=quarantine; pct=100",
|
||||||
|
observed_at=datetime(2026, 5, 23, 12, 0, 0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
missing_spf = DomainDNSResult(
|
||||||
|
dmarc=True,
|
||||||
|
dmarc_record="v=DMARC1; p=quarantine; pct=100; rua=mailto:dmarc@example.com",
|
||||||
|
spf=False,
|
||||||
|
dkim=True,
|
||||||
|
dkim_selectors=["google"],
|
||||||
|
dkim_record="v=DKIM1; k=rsa; p=ABC",
|
||||||
|
)
|
||||||
|
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"],
|
||||||
|
)
|
||||||
|
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_spf),
|
||||||
|
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}/posture")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["status"] == "degraded"
|
||||||
|
assert data["score"] == 80
|
||||||
|
assert any(item["key"] == "spf" and item["href"] == "#dns-records" for item in data["coverage"])
|
||||||
|
missing_spf_recommendation = next(
|
||||||
|
item for item in data["recommendations"] if item["type"] == "missing_spf"
|
||||||
|
)
|
||||||
|
assert missing_spf_recommendation["evidence"][0]["href"] == "#dns-records"
|
||||||
|
assert data["changes"][0]["title"] == f"TXT _dmarc.{DOMAIN} modified"
|
||||||
|
assert data["changes"][0]["evidence"][0]["value"] == "v=DMARC1; p=none"
|
||||||
|
assert any(playbook["key"] == "missing_spf" for playbook in data["playbooks"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_posture_dashboard_returns_404_for_unknown_domain(client: TestClient):
|
||||||
|
response = client.get("/api/v1/domains/unknown.example.com/posture")
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
def test_domain_detail_data_endpoints_support_manually_configured_domain(
|
def test_domain_detail_data_endpoints_support_manually_configured_domain(
|
||||||
client: TestClient, db_session
|
client: TestClient, db_session
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ 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.
|
- 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.
|
- 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: 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.
|
- 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.
|
||||||
|
- Posture dashboard and operator playbooks: delivered a per-domain posture score, coverage cards for DMARC/SPF/DKIM/MTA-STS/BIMI, evidence-linked recommendations, provider-backed DNS drift summaries, and short remediation playbooks.
|
||||||
- Extended DNS checks that support the posture surface (e.g., MX/BIMI; optional DANE/TLSA where relevant).
|
- Extended DNS checks that support the posture surface (e.g., MX/BIMI; optional DANE/TLSA where relevant).
|
||||||
|
|
||||||
Exit criteria:
|
Exit criteria:
|
||||||
|
|||||||
@@ -105,6 +105,18 @@ GET /domains/{domain_id}/dns/bimi
|
|||||||
Returns the cached BIMI TXT posture for the default selector, including the
|
Returns the cached BIMI TXT posture for the default selector, including the
|
||||||
queried DNS name, record text, logo URL, certificate URL, warnings, and errors.
|
queried DNS name, record text, logo URL, certificate URL, warnings, and errors.
|
||||||
|
|
||||||
|
#### Get Posture Dashboard
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /domains/{domain_id}/posture
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the evidence-first posture dashboard for one domain. The response
|
||||||
|
contains the posture score, coverage for DMARC, SPF, DKIM, MTA-STS, and BIMI,
|
||||||
|
actionable recommendations, recent provider-backed DNS drift summaries, and
|
||||||
|
short operator playbooks. Recommendation and playbook evidence links point back
|
||||||
|
to the page section that triggered the finding.
|
||||||
|
|
||||||
#### Add Domain
|
#### Add Domain
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -63,6 +63,22 @@ DMARQ provides a health check feature for each domain:
|
|||||||
- MX record confirmation
|
- MX record confirmation
|
||||||
- BIMI record validation (if applicable)
|
- BIMI record validation (if applicable)
|
||||||
|
|
||||||
|
### Posture Dashboard
|
||||||
|
|
||||||
|
The domain detail page starts with an evidence-first posture dashboard. It
|
||||||
|
summarizes coverage for DMARC, SPF, DKIM, MTA-STS, and BIMI, assigns a simple
|
||||||
|
posture score, and shows each recommendation with links back to the DNS record,
|
||||||
|
report trend, sending-source table, or posture evidence that triggered it.
|
||||||
|
|
||||||
|
The same surface includes a **What Changed** panel when provider-backed DNS
|
||||||
|
change tracking has observed additions, edits, or removals. Those summaries are
|
||||||
|
designed for drift review: operators can see the previous and current values
|
||||||
|
without reading logs.
|
||||||
|
|
||||||
|
Operator playbooks sit beside the recommendations. They are short remediation
|
||||||
|
checklists for common gaps such as missing SPF, missing DKIM, policy enforcement
|
||||||
|
readiness, MTA-STS setup, or BIMI prerequisites.
|
||||||
|
|
||||||
### MTA-STS Posture
|
### 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`.
|
The domain detail page checks `_mta-sts.<domain>` and fetches the policy from `https://mta-sts.<domain>/.well-known/mta-sts.txt`.
|
||||||
|
|||||||
Reference in New Issue
Block a user