feat: add posture dashboard playbooks

This commit is contained in:
Christian Krakau-Louis
2026-05-23 17:24:40 +02:00
parent 6883b40080
commit 2ff0c6d144
6 changed files with 616 additions and 148 deletions
+364 -101
View File
@@ -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)
+146 -46
View File
@@ -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);
@@ -1046,6 +1140,12 @@ function domainDetailsApp(domainId) {
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';
if (rate >= 50) return 'bg-yellow-100 text-yellow-800'; if (rate >= 50) return 'bg-yellow-100 text-yellow-800';
+77 -1
View File
@@ -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
): ):
+1
View File
@@ -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:
+12
View File
@@ -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
``` ```
+16
View File
@@ -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`.