Skip to main content

seer_core/
posture.rs

1//! Email / DNS security-posture inspection.
2//!
3//! Resolves and interprets the DNS-published mail-security policies for a
4//! domain — SPF, DMARC, MTA-STS, BIMI, and DANE (TLSA) — and returns a
5//! per-mechanism verdict with advisory notes. A lax or absent DMARC policy is
6//! the single strongest signal that a domain is spoofable, which directly feeds
7//! phishing-risk assessment.
8//!
9//! This mirrors [`crate::caa`]'s tree-walk-plus-advisory shape and reuses the
10//! [`DnsResolver`] TXT/MX/TLSA path — no new network protocol. The follow-up
11//! lookups the RFCs require (SPF `redirect=`, DMARC's organizational-domain
12//! fallback, DANE at the MX hosts) are DNS-only and bounded. All parsing is
13//! pure and unit-tested; only the record fetch is async.
14
15use std::collections::HashMap;
16
17use serde::{Deserialize, Serialize};
18
19use crate::dns::{DnsResolver, RecordData, RecordType};
20use crate::error::Result;
21use crate::validation::{normalize_domain, normalize_host};
22
23/// A coarse enforcement verdict for one posture mechanism, HTTP security
24/// header, or cookie. `seer headers` grades on this same scale (exported
25/// there as [`crate::headers::HeaderVerdict`]) so both reports read alike.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "kebab-case")]
28pub enum PostureVerdict {
29    /// Not configured / not sent at all.
30    Absent,
31    /// Configured, but permissive / monitoring-only (offers little protection).
32    Weak,
33    /// Configured with partial enforcement.
34    Moderate,
35    /// Configured with full enforcement.
36    Strict,
37    /// Configured; the mechanism has no weak/strict axis (presence is the signal).
38    Present,
39    /// The DNS lookup failed (SERVFAIL, timeout, …), so the state is not
40    /// known. Posture only: never read it as `Absent` — a transient resolver
41    /// error is no evidence that the domain is spoofable.
42    Unknown,
43}
44
45impl PostureVerdict {
46    /// The kebab-case label serde emits, for text renderers and CSV cells.
47    pub fn as_str(self) -> &'static str {
48        match self {
49            PostureVerdict::Absent => "absent",
50            PostureVerdict::Weak => "weak",
51            PostureVerdict::Moderate => "moderate",
52            PostureVerdict::Strict => "strict",
53            PostureVerdict::Present => "present",
54            PostureVerdict::Unknown => "unknown",
55        }
56    }
57}
58
59/// SPF (Sender Policy Framework) posture.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct SpfPolicy {
62    pub present: bool,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub record: Option<String>,
65    /// The qualifier on the terminal `all` mechanism: `-` (fail), `~`
66    /// (softfail), `?` (neutral), `+` (pass — permits anyone).
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub all_qualifier: Option<String>,
69    pub verdict: PostureVerdict,
70}
71
72/// DMARC posture.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct DmarcPolicy {
75    pub present: bool,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub record: Option<String>,
78    /// The `p=` policy: `none`, `quarantine`, or `reject`.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub policy: Option<String>,
81    /// The `sp=` subdomain policy, if set.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub subdomain_policy: Option<String>,
84    /// Aggregate report (`rua=`) destinations.
85    #[serde(skip_serializing_if = "Vec::is_empty")]
86    pub aggregate_reports: Vec<String>,
87    /// The `pct=` percentage of mail the policy applies to (defaults to 100).
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub percent: Option<u8>,
90    pub verdict: PostureVerdict,
91}
92
93/// MTA-STS posture (DNS signal only; enforcement mode lives in the HTTPS
94/// policy file and is not fetched here to avoid an outbound SSRF surface).
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct MtaStsPolicy {
97    pub present: bool,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub record: Option<String>,
100    /// The policy `id=` from the TXT record (changes when the policy updates).
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub id: Option<String>,
103    pub verdict: PostureVerdict,
104}
105
106/// BIMI posture.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct BimiPolicy {
109    pub present: bool,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub record: Option<String>,
112    /// The `l=` logo URL.
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub logo_url: Option<String>,
115    /// The `a=` VMC (verified mark certificate) authority URL.
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub authority_url: Option<String>,
118    pub verdict: PostureVerdict,
119}
120
121/// A single DANE TLSA record with the port/protocol scope it was found at.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct TlsaRecord {
124    /// The owner scope: `_25._tcp.<mx-host>` for SMTP records at an MX host
125    /// (RFC 7672), `_25._tcp` for a domain without MX (its implicit MX is the
126    /// domain itself), or `_443._tcp` (HTTPS).
127    pub scope: String,
128    pub cert_usage: u8,
129    pub selector: u8,
130    pub matching: u8,
131    pub cert_data: String,
132}
133
134/// DANE (TLSA) posture across the common SMTP and HTTPS scopes.
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct DanePolicy {
137    pub present: bool,
138    #[serde(skip_serializing_if = "Vec::is_empty")]
139    pub records: Vec<TlsaRecord>,
140    pub verdict: PostureVerdict,
141}
142
143/// The aggregate email/DNS security posture for a domain.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct EmailPosture {
146    pub domain: String,
147    pub spf: SpfPolicy,
148    pub dmarc: DmarcPolicy,
149    pub mta_sts: MtaStsPolicy,
150    pub bimi: BimiPolicy,
151    pub dane: DanePolicy,
152    /// Human-readable advisory findings (e.g. "domain is spoofable").
153    pub notes: Vec<String>,
154}
155
156// --- Bounds -----------------------------------------------------------------
157
158/// The SPF DNS-lookup limit (RFC 7208 §4.6.4): evaluation that needs more
159/// than this many `include`/`a`/`mx`/`ptr`/`exists`/`redirect` terms is a
160/// permerror. It also bounds the `redirect=` hops and `include:` records
161/// seer fetches — each one counts toward it. Every fetch is one DNS TXT
162/// query — no outbound connection, so no SSRF surface.
163const MAX_SPF_LOOKUPS: usize = 10;
164
165/// Maximum parent names tried when looking for an inherited DMARC record
166/// (the walk also never leaves the organizational domain).
167const MAX_DMARC_WALK: usize = 7;
168
169/// Maximum MX hosts (in preference order) probed for SMTP DANE records.
170const MAX_DANE_MX_HOSTS: usize = 5;
171
172// --- Pure parsers -------------------------------------------------------
173
174/// True when `record` begins with the version tag `tag` (ASCII
175/// case-insensitive) followed by the end of the record, whitespace, or —
176/// for the `k=v;` policy records (`allow_semicolon`) — a `;`. A bare prefix
177/// test accepted `v=spf10 …` as SPF.
178fn has_version_tag(record: &str, tag: &str, allow_semicolon: bool) -> bool {
179    let record = record.trim_start();
180    let Some(head) = record.get(..tag.len()) else {
181        return false;
182    };
183    if !head.eq_ignore_ascii_case(tag) {
184        return false;
185    }
186    match record[tag.len()..].chars().next() {
187        None => true,
188        Some(c) => c.is_whitespace() || (allow_semicolon && c == ';'),
189    }
190}
191
192/// How many records in a TXT set carry a given version tag.
193#[derive(Debug, PartialEq, Eq)]
194enum Selection<'a> {
195    None,
196    One(&'a str),
197    /// More than one — SPF evaluation is a permerror (RFC 7208 §4.5) and
198    /// DMARC is not applied at all (RFC 7489 §6.6.3).
199    Multiple(usize),
200}
201
202fn select<'a>(records: &'a [String], tag: &str, allow_semicolon: bool) -> Selection<'a> {
203    let mut matching = records
204        .iter()
205        .filter(|r| has_version_tag(r, tag, allow_semicolon));
206    match (matching.next(), matching.count()) {
207        (None, _) => Selection::None,
208        (Some(record), 0) => Selection::One(record),
209        (Some(_), rest) => Selection::Multiple(rest + 1),
210    }
211}
212
213fn select_spf(records: &[String]) -> Selection<'_> {
214    select(records, "v=spf1", false)
215}
216
217fn select_dmarc(records: &[String]) -> Selection<'_> {
218    select(records, "v=DMARC1", true)
219}
220
221/// Returns the qualifier on the terminal `all` mechanism of an SPF record
222/// (`-`, `~`, `?`, or `+`), or `None` if the record has no `all` mechanism.
223fn parse_spf_all_qualifier(record: &str) -> Option<String> {
224    for token in record.split_whitespace() {
225        let t = token.to_ascii_lowercase();
226        if t == "all" {
227            return Some("+".to_string());
228        }
229        if let Some(qual) = t.strip_suffix("all") {
230            if matches!(qual, "-" | "~" | "?" | "+") {
231                return Some(qual.to_string());
232            }
233        }
234    }
235    None
236}
237
238/// The target of an SPF `redirect=` modifier, if any.
239fn parse_spf_redirect(record: &str) -> Option<String> {
240    record.split_whitespace().find_map(|token| {
241        let (name, value) = token.split_once('=')?;
242        name.eq_ignore_ascii_case("redirect")
243            .then(|| value.to_string())
244    })
245}
246
247/// What decides a single SPF record's verdict.
248#[derive(Debug, PartialEq, Eq)]
249enum SpfTerminal {
250    /// The `all` mechanism's qualifier.
251    All(String),
252    /// No `all`, but a `redirect=` modifier: the target record governs
253    /// (RFC 7208 §6.1 — `redirect` is ignored when `all` is present).
254    Redirect(String),
255    /// Neither: the default result is neutral, i.e. no protection.
256    Neither,
257}
258
259fn spf_terminal(record: &str) -> SpfTerminal {
260    match (parse_spf_all_qualifier(record), parse_spf_redirect(record)) {
261        (Some(qualifier), _) => SpfTerminal::All(qualifier),
262        (None, Some(target)) => SpfTerminal::Redirect(target),
263        (None, None) => SpfTerminal::Neither,
264    }
265}
266
267/// The DNS-querying terms of one SPF record (RFC 7208 §4.6.4): how many it
268/// has, and the `include:` / effective `redirect=` targets whose records
269/// count too.
270fn spf_lookup_terms(record: &str) -> (usize, Vec<String>) {
271    let mut count = 0;
272    let mut targets = Vec::new();
273    // The first token is the `v=spf1` version tag.
274    for token in record.split_whitespace().skip(1) {
275        let term = token.trim_start_matches(['+', '-', '~', '?']);
276        if term.contains('=') {
277            continue; // a modifier; `redirect=` is handled below
278        }
279        let name = term.split([':', '/']).next().unwrap_or(term);
280        if name.eq_ignore_ascii_case("include") {
281            count += 1;
282            targets.extend(term.split_once(':').map(|(_, t)| t.to_string()));
283        } else if ["a", "mx", "ptr", "exists"]
284            .iter()
285            .any(|m| name.eq_ignore_ascii_case(m))
286        {
287            count += 1;
288        }
289    }
290    // `redirect=` is only used (and so only counted) without an `all`.
291    if let SpfTerminal::Redirect(target) = spf_terminal(record) {
292        count += 1;
293        targets.push(target);
294    }
295    (count, targets)
296}
297
298/// Maps an SPF `all` qualifier to a verdict.
299fn spf_verdict(all_qualifier: Option<&str>) -> PostureVerdict {
300    match all_qualifier {
301        Some("-") => PostureVerdict::Strict,
302        Some("~") => PostureVerdict::Moderate,
303        // "?all" (neutral), "+all" (pass-all), or a record with no `all` term
304        // provides effectively no protection.
305        Some(_) | None => PostureVerdict::Weak,
306    }
307}
308
309/// Splits a `k=v; k=v` policy record (DMARC/BIMI/MTA-STS) into a tag map with
310/// lowercased keys.
311fn parse_tag_value(record: &str) -> HashMap<String, String> {
312    record
313        .split(';')
314        .filter_map(|pair| {
315            let (k, v) = pair.split_once('=')?;
316            Some((k.trim().to_ascii_lowercase(), v.trim().to_string()))
317        })
318        .collect()
319}
320
321/// Maps a DMARC `p=`/`sp=` policy to a verdict.
322fn dmarc_verdict(policy: Option<&str>) -> PostureVerdict {
323    match policy.map(|p| p.to_ascii_lowercase()) {
324        Some(ref p) if p == "reject" => PostureVerdict::Strict,
325        Some(ref p) if p == "quarantine" => PostureVerdict::Moderate,
326        // `none`, a missing or an invalid policy: monitoring only.
327        _ => PostureVerdict::Weak,
328    }
329}
330
331/// One enforcement band weaker (a `pct=` below 100 applies the policy to only
332/// part of the failing mail; RFC 7489 §6.6.4 gives the rest the next-lower
333/// policy).
334fn downgrade(verdict: PostureVerdict) -> PostureVerdict {
335    match verdict {
336        PostureVerdict::Strict => PostureVerdict::Moderate,
337        PostureVerdict::Moderate => PostureVerdict::Weak,
338        other => other,
339    }
340}
341
342/// Extracts the TXT record strings from a resolved record set.
343fn txt_strings(records: &[crate::dns::DnsRecord]) -> Vec<String> {
344    records
345        .iter()
346        .filter_map(|r| match &r.data {
347            RecordData::TXT { text, .. } => Some(text.clone()),
348            _ => None,
349        })
350        .collect()
351}
352
353// --- Assemblers (pure given the fetched records) -----------------------
354
355/// A mechanism's policy plus the advisory notes it contributes.
356#[derive(Debug)]
357struct Assessed<T> {
358    policy: T,
359    notes: Vec<String>,
360}
361
362/// A TXT record set, or the sanitized reason its lookup failed.
363type TxtLookup = std::result::Result<Vec<String>, String>;
364
365/// The result of following an SPF `redirect=` chain.
366#[derive(Debug)]
367enum RedirectOutcome {
368    /// The chain ended at `target`, whose record's `all` qualifier (if any)
369    /// is the effective one.
370    Resolved {
371        target: String,
372        qualifier: Option<String>,
373    },
374    /// The chain cannot be evaluated (a permerror, or a macro seer does not
375    /// expand).
376    Failed { target: String, reason: String },
377    /// A DNS lookup on the chain failed: the effective policy is unknown.
378    LookupFailed { target: String, reason: String },
379}
380
381/// What SPF evaluation found beyond the apex record itself.
382#[derive(Debug, Default)]
383struct SpfFollowUp {
384    /// The `redirect=` outcome, when the record has one and no `all`.
385    redirect: Option<RedirectOutcome>,
386    /// DNS-querying terms counted across the record and the records it
387    /// includes or redirects to (stops once past [`MAX_SPF_LOOKUPS`]).
388    lookups: usize,
389}
390
391fn spf_verdict_note(verdict: PostureVerdict) -> Option<String> {
392    match verdict {
393        PostureVerdict::Weak => Some(
394            "SPF does not end in -all/~all — it permits unlisted senders (spoofable).".to_string(),
395        ),
396        PostureVerdict::Moderate => {
397            Some("SPF uses ~all (softfail); -all provides stricter enforcement.".to_string())
398        }
399        _ => None,
400    }
401}
402
403/// The SPF policy when the apex TXT lookup itself failed.
404fn unknown_spf(reason: &str) -> Assessed<SpfPolicy> {
405    Assessed {
406        policy: SpfPolicy {
407            present: false,
408            record: None,
409            all_qualifier: None,
410            verdict: PostureVerdict::Unknown,
411        },
412        notes: vec![format!(
413            "SPF lookup failed ({reason}) — SPF posture is unknown, not absent."
414        )],
415    }
416}
417
418/// Assembles the SPF policy from the apex TXT set and what evaluating its
419/// record found (see [`assess_spf`]).
420fn build_spf(apex_txt: &[String], follow_up: &SpfFollowUp) -> Assessed<SpfPolicy> {
421    let record = match select_spf(apex_txt) {
422        Selection::None => {
423            return Assessed {
424                policy: SpfPolicy {
425                    present: false,
426                    record: None,
427                    all_qualifier: None,
428                    verdict: PostureVerdict::Absent,
429                },
430                notes: vec!["No SPF record — sending sources are undeclared.".to_string()],
431            };
432        }
433        Selection::Multiple(count) => {
434            return Assessed {
435                policy: SpfPolicy {
436                    present: true,
437                    record: None,
438                    all_qualifier: None,
439                    verdict: PostureVerdict::Weak,
440                },
441                notes: vec![format!(
442                    "{count} SPF records are published — SPF evaluation fails with permerror \
443                     (RFC 7208 §4.5), so receivers get no SPF result. Merge them into one record."
444                )],
445            };
446        }
447        Selection::One(record) => record,
448    };
449
450    // A policy whose verdict is decided before its `all` qualifier.
451    let settled = |verdict: PostureVerdict, note: String| Assessed {
452        policy: SpfPolicy {
453            present: true,
454            record: Some(record.to_string()),
455            all_qualifier: None,
456            verdict,
457        },
458        notes: vec![note],
459    };
460
461    if follow_up.lookups > MAX_SPF_LOOKUPS {
462        return settled(
463            PostureVerdict::Weak,
464            format!(
465                "SPF needs more than {MAX_SPF_LOOKUPS} DNS lookups (include/a/mx/ptr/exists/\
466                 redirect, counted through its includes) — evaluation fails with permerror \
467                 (RFC 7208 §4.6.4), so receivers get no SPF result. Flatten or trim the record."
468            ),
469        );
470    }
471
472    let mut notes = Vec::new();
473    let all_qualifier = match (spf_terminal(record), &follow_up.redirect) {
474        (SpfTerminal::All(qualifier), _) => Some(qualifier),
475        (SpfTerminal::Redirect(_), Some(RedirectOutcome::Resolved { target, qualifier })) => {
476            notes.push(format!(
477                "SPF delegates to {target} via redirect= — the verdict reflects that record."
478            ));
479            qualifier.clone()
480        }
481        (SpfTerminal::Redirect(_), Some(RedirectOutcome::Failed { target, reason })) => {
482            return settled(
483                PostureVerdict::Weak,
484                format!(
485                    "SPF redirect={target} could not be evaluated ({reason}) — receivers get \
486                     no usable SPF result."
487                ),
488            );
489        }
490        (SpfTerminal::Redirect(_), Some(RedirectOutcome::LookupFailed { target, reason })) => {
491            return settled(
492                PostureVerdict::Unknown,
493                format!(
494                    "SPF redirect={target} lookup failed ({reason}) — the effective SPF policy \
495                     is unknown."
496                ),
497            );
498        }
499        (SpfTerminal::Redirect(_), None) | (SpfTerminal::Neither, _) => None,
500    };
501    let verdict = spf_verdict(all_qualifier.as_deref());
502    notes.extend(spf_verdict_note(verdict));
503    Assessed {
504        policy: SpfPolicy {
505            present: true,
506            verdict,
507            all_qualifier,
508            record: Some(record.to_string()),
509        },
510        notes,
511    }
512}
513
514/// Where the applicable DMARC record set was found.
515#[derive(Debug)]
516struct DmarcSource {
517    /// The TXT strings at `_dmarc.<at>`.
518    txts: Vec<String>,
519    /// The domain whose `_dmarc` name held the records.
520    at: String,
521    /// True when `at` is a parent of the checked domain.
522    inherited: bool,
523}
524
525/// The outcome of looking for the DMARC record set that applies.
526#[derive(Debug)]
527enum DmarcDiscovery {
528    Found(DmarcSource),
529    /// No DMARC record at the domain or any name up to its organizational
530    /// domain.
531    NotFound,
532    /// A lookup on the way failed, so a record may exist unseen.
533    LookupFailed {
534        name: String,
535        reason: String,
536    },
537}
538
539fn absent_dmarc() -> DmarcPolicy {
540    DmarcPolicy {
541        present: false,
542        record: None,
543        policy: None,
544        subdomain_policy: None,
545        aggregate_reports: Vec::new(),
546        percent: None,
547        verdict: PostureVerdict::Absent,
548    }
549}
550
551/// Assembles the DMARC policy for `domain` from the discovered record set.
552fn build_dmarc(domain: &str, discovery: &DmarcDiscovery) -> Assessed<DmarcPolicy> {
553    let spoofable =
554        || vec!["No DMARC record — the domain is trivially spoofable in phishing.".to_string()];
555    let source = match discovery {
556        DmarcDiscovery::Found(source) => source,
557        DmarcDiscovery::NotFound => {
558            return Assessed {
559                policy: absent_dmarc(),
560                notes: spoofable(),
561            }
562        }
563        DmarcDiscovery::LookupFailed { name, reason } => {
564            return Assessed {
565                policy: DmarcPolicy {
566                    verdict: PostureVerdict::Unknown,
567                    ..absent_dmarc()
568                },
569                notes: vec![format!(
570                    "DMARC lookup at {name} failed ({reason}) — DMARC posture is unknown, \
571                     not absent."
572                )],
573            }
574        }
575    };
576    let record = match select_dmarc(&source.txts) {
577        Selection::None => {
578            return Assessed {
579                policy: absent_dmarc(),
580                notes: spoofable(),
581            };
582        }
583        Selection::Multiple(count) => {
584            return Assessed {
585                policy: DmarcPolicy {
586                    present: true,
587                    ..absent_dmarc()
588                },
589                notes: vec![format!(
590                    "{count} DMARC records at _dmarc.{} — receivers do not apply DMARC at all \
591                     when more than one is published (RFC 7489 §6.6.3). Publish exactly one.",
592                    source.at
593                )],
594            };
595        }
596        Selection::One(record) => record,
597    };
598
599    let tags = parse_tag_value(record);
600    let policy = tags.get("p").cloned();
601    let subdomain_policy = tags.get("sp").cloned();
602    let percent: Option<u8> = tags.get("pct").and_then(|p| p.parse().ok());
603    let aggregate_reports = tags
604        .get("rua")
605        .map(|rua| {
606            rua.split(',')
607                .map(|s| s.trim().to_string())
608                .filter(|s| !s.is_empty())
609                .collect()
610        })
611        .unwrap_or_default();
612
613    // A subdomain governed by its organizational domain's record gets that
614    // record's `sp=` policy, falling back to `p=` (RFC 7489 §6.6.3 / §6.3).
615    let (label, effective) = match (&subdomain_policy, source.inherited) {
616        (Some(sp), true) => ("sp", Some(sp.as_str())),
617        _ => ("p", policy.as_deref()),
618    };
619    let base = dmarc_verdict(effective);
620    let partial_pct = percent.filter(|pct| *pct < 100);
621    let verdict = if partial_pct.is_some() {
622        downgrade(base)
623    } else {
624        base
625    };
626
627    let mut notes = Vec::new();
628    if source.inherited {
629        notes.push(format!(
630            "No DMARC record at _dmarc.{domain} — it inherits the organizational record at \
631             _dmarc.{} (RFC 7489 §6.6.3), whose {label}= policy applies.",
632            source.at
633        ));
634    }
635    let effective_lc = effective.map(str::to_ascii_lowercase);
636    match (base, effective_lc.as_deref()) {
637        (PostureVerdict::Moderate, _) => notes.push(format!(
638            "DMARC policy is {label}=quarantine — spoofed mail is filtered but not rejected."
639        )),
640        (PostureVerdict::Weak, Some("none")) => notes.push(format!(
641            "DMARC policy is {label}=none (monitoring only) — receivers will not reject spoofed mail."
642        )),
643        (PostureVerdict::Weak, _) => notes.push(format!(
644            "DMARC record has no valid {label}= policy — receivers treat it as monitoring only (p=none)."
645        )),
646        _ => {}
647    }
648    if let (Some(pct), Some(value)) = (partial_pct, effective_lc.as_deref()) {
649        if base != PostureVerdict::Weak {
650            notes.push(format!(
651                "DMARC pct={pct} — the {label}={value} policy is applied to only {pct}% of \
652                 failing mail (the rest gets the next-weaker treatment), so enforcement is partial."
653            ));
654        }
655    }
656
657    Assessed {
658        policy: DmarcPolicy {
659            present: true,
660            verdict,
661            subdomain_policy,
662            percent,
663            aggregate_reports,
664            policy,
665            record: Some(record.to_string()),
666        },
667        notes,
668    }
669}
670
671fn build_mta_sts(txt: &TxtLookup) -> MtaStsPolicy {
672    let absent = |verdict| MtaStsPolicy {
673        present: false,
674        record: None,
675        id: None,
676        verdict,
677    };
678    let Ok(txt) = txt else {
679        return absent(PostureVerdict::Unknown);
680    };
681    match txt.iter().find(|t| has_version_tag(t, "v=STSv1", true)) {
682        Some(record) => {
683            let tags = parse_tag_value(record);
684            MtaStsPolicy {
685                present: true,
686                id: tags.get("id").cloned(),
687                record: Some(record.clone()),
688                verdict: PostureVerdict::Present,
689            }
690        }
691        None => absent(PostureVerdict::Absent),
692    }
693}
694
695fn build_bimi(txt: &TxtLookup) -> BimiPolicy {
696    let absent = |verdict| BimiPolicy {
697        present: false,
698        record: None,
699        logo_url: None,
700        authority_url: None,
701        verdict,
702    };
703    let Ok(txt) = txt else {
704        return absent(PostureVerdict::Unknown);
705    };
706    match txt.iter().find(|t| has_version_tag(t, "v=BIMI1", true)) {
707        Some(record) => {
708            let tags = parse_tag_value(record);
709            BimiPolicy {
710                present: true,
711                logo_url: tags.get("l").filter(|s| !s.is_empty()).cloned(),
712                authority_url: tags.get("a").filter(|s| !s.is_empty()).cloned(),
713                record: Some(record.clone()),
714                verdict: PostureVerdict::Present,
715            }
716        }
717        None => absent(PostureVerdict::Absent),
718    }
719}
720
721/// The TLSA record sets DANE looked at, each with its owner scope, and
722/// whether any lookup on the way (MX included) failed.
723#[derive(Debug, Default)]
724struct TlsaSets {
725    sets: Vec<(String, Vec<crate::dns::DnsRecord>)>,
726    lookup_failed: bool,
727}
728
729/// Assembles the DANE policy from the TLSA sets. Records found are
730/// `Present` even when another lookup failed; with none found, a failed
731/// lookup makes the state `Unknown` rather than `Absent`.
732fn build_dane(tlsa: &TlsaSets) -> DanePolicy {
733    let mut records = Vec::new();
734    for (scope, set) in &tlsa.sets {
735        for r in set {
736            if let RecordData::TLSA {
737                cert_usage,
738                selector,
739                matching,
740                cert_data,
741            } = &r.data
742            {
743                records.push(TlsaRecord {
744                    scope: scope.clone(),
745                    cert_usage: *cert_usage,
746                    selector: *selector,
747                    matching: *matching,
748                    cert_data: cert_data.clone(),
749                });
750            }
751        }
752    }
753    let verdict = if !records.is_empty() {
754        PostureVerdict::Present
755    } else if tlsa.lookup_failed {
756        PostureVerdict::Unknown
757    } else {
758        PostureVerdict::Absent
759    };
760    DanePolicy {
761        present: !records.is_empty(),
762        verdict,
763        records,
764    }
765}
766
767/// Orders the advisory notes: DMARC, SPF, then the transport mechanisms.
768fn build_notes(
769    spf: &Assessed<SpfPolicy>,
770    dmarc: &Assessed<DmarcPolicy>,
771    mta_sts: &MtaStsPolicy,
772    bimi: &BimiPolicy,
773    dane: &DanePolicy,
774) -> Vec<String> {
775    let mut notes = dmarc.notes.clone();
776    notes.extend(spf.notes.iter().cloned());
777    let mta_sts_note = match mta_sts.verdict {
778        PostureVerdict::Absent => {
779            Some("MTA-STS is not configured — SMTP is vulnerable to downgrade.")
780        }
781        PostureVerdict::Unknown => Some("MTA-STS lookup failed — its state is unknown."),
782        _ => None,
783    };
784    let bimi_note = (bimi.verdict == PostureVerdict::Unknown)
785        .then_some("BIMI lookup failed — its state is unknown.");
786    let dane_note = match dane.verdict {
787        PostureVerdict::Absent => {
788            Some("No DANE (TLSA) records — no DNS-based TLS pinning for mail/HTTPS.")
789        }
790        PostureVerdict::Unknown => Some(
791            "DANE (TLSA/MX) lookup failed and no TLSA records were found — DANE state is unknown.",
792        ),
793        _ => None,
794    };
795    notes.extend(
796        [mta_sts_note, bimi_note, dane_note]
797            .into_iter()
798            .flatten()
799            .map(str::to_string),
800    );
801    notes
802}
803
804// --- Network steps (each bounded; DNS only) ------------------------------
805
806/// The resolver plus the caller's nameserver (`None` = the default
807/// upstream), so every posture query goes to the same server.
808#[derive(Clone, Copy)]
809struct Dns<'a> {
810    resolver: &'a DnsResolver,
811    nameserver: Option<&'a str>,
812}
813
814impl Dns<'_> {
815    async fn resolve(
816        &self,
817        name: &str,
818        record_type: RecordType,
819    ) -> Result<Vec<crate::dns::DnsRecord>> {
820        self.resolver
821            .resolve(name, record_type, self.nameserver)
822            .await
823    }
824
825    async fn txt(&self, name: &str) -> TxtLookup {
826        self.resolve(name, RecordType::TXT)
827            .await
828            .map(|records| txt_strings(&records))
829            .map_err(|e| e.sanitized_message())
830    }
831
832    /// The one SPF record at `target`, or `None` when it cannot be read
833    /// (macros, an invalid name, a failed lookup, none or several records).
834    async fn spf_record(&self, target: &str) -> Option<String> {
835        if target.contains('%') {
836            return None;
837        }
838        let name = normalize_host(target).ok()?;
839        match select_spf(&self.txt(&name).await.ok()?) {
840            Selection::One(record) => Some(record.to_string()),
841            _ => None,
842        }
843    }
844}
845
846/// Follows an SPF `redirect=` chain; each hop counts toward the
847/// [`MAX_SPF_LOOKUPS`] limit, so a longer chain is a permerror.
848async fn follow_spf_redirect(dns: Dns<'_>, first_target: String) -> RedirectOutcome {
849    let mut target = first_target;
850    for _ in 0..MAX_SPF_LOOKUPS {
851        if target.contains('%') {
852            return RedirectOutcome::Failed {
853                target,
854                reason: "it uses SPF macros, which seer does not expand".to_string(),
855            };
856        }
857        let Ok(name) = normalize_host(&target) else {
858            return RedirectOutcome::Failed {
859                target,
860                reason: "not a valid domain name — permerror".to_string(),
861            };
862        };
863        let txts = match dns.txt(&name).await {
864            Ok(txts) => txts,
865            Err(reason) => {
866                return RedirectOutcome::LookupFailed {
867                    target: name,
868                    reason,
869                }
870            }
871        };
872        match select_spf(&txts) {
873            Selection::None => {
874                return RedirectOutcome::Failed {
875                    target: name,
876                    reason: "it publishes no SPF record — permerror".to_string(),
877                }
878            }
879            Selection::Multiple(count) => {
880                return RedirectOutcome::Failed {
881                    target: name,
882                    reason: format!("it publishes {count} SPF records — permerror"),
883                }
884            }
885            Selection::One(record) => match spf_terminal(record) {
886                SpfTerminal::All(qualifier) => {
887                    return RedirectOutcome::Resolved {
888                        target: name,
889                        qualifier: Some(qualifier),
890                    }
891                }
892                SpfTerminal::Neither => {
893                    return RedirectOutcome::Resolved {
894                        target: name,
895                        qualifier: None,
896                    }
897                }
898                SpfTerminal::Redirect(next) => target = next,
899            },
900        }
901    }
902    RedirectOutcome::Failed {
903        target,
904        reason: format!("the redirect chain exceeds the {MAX_SPF_LOOKUPS} DNS-lookup limit"),
905    }
906}
907
908/// Counts the DNS-querying terms evaluating `record` needs (RFC 7208
909/// §4.6.4), following `include:` and `redirect=` into their records. Stops
910/// as soon as the count passes [`MAX_SPF_LOOKUPS`], so at most that many
911/// records are fetched; a target that cannot be read adds nothing beyond its
912/// own term (the count is then a lower bound).
913async fn count_spf_lookups(dns: Dns<'_>, record: &str) -> usize {
914    let (mut count, mut pending) = spf_lookup_terms(record);
915    while let Some(target) = pending.pop() {
916        if count > MAX_SPF_LOOKUPS {
917            break;
918        }
919        if let Some(included) = dns.spf_record(&target).await {
920            let (terms, targets) = spf_lookup_terms(&included);
921            count += terms;
922            pending.extend(targets);
923        }
924    }
925    count
926}
927
928/// SPF for the apex TXT lookup: follows `redirect=` when the record has no
929/// `all` term (RFC 7208 §6.1: the redirected record then governs — grading
930/// such a record on its own as "no -all/~all" called well-run delegated
931/// setups spoofable), and counts its DNS lookups against the §4.6.4 limit.
932async fn assess_spf(dns: Dns<'_>, apex_txt: &TxtLookup) -> Assessed<SpfPolicy> {
933    let apex_txt = match apex_txt {
934        Ok(txts) => txts,
935        Err(reason) => return unknown_spf(reason),
936    };
937    let mut follow_up = SpfFollowUp::default();
938    if let Selection::One(record) = select_spf(apex_txt) {
939        let redirect = async {
940            match spf_terminal(record) {
941                SpfTerminal::Redirect(target) => Some(follow_spf_redirect(dns, target).await),
942                _ => None,
943            }
944        };
945        (follow_up.redirect, follow_up.lookups) =
946            tokio::join!(redirect, count_spf_lookups(dns, record));
947    }
948    build_spf(apex_txt, &follow_up)
949}
950
951/// Finds the DMARC record set that applies to `domain`: its own
952/// `_dmarc.<domain>`, or — when that has no DMARC record — the nearest parent
953/// that has one, walking one label at a time (at most [`MAX_DMARC_WALK`])
954/// but never above the Organizational Domain (RFC 7489 §3.2: the registrable
955/// domain under the Public Suffix List). RFC 7489 §6.6.3 falls back to the
956/// Organizational Domain itself; the nearest publishing ancestor below it is
957/// what the DMARCbis tree walk adds. A query error stops the walk rather than
958/// skipping past a record we could not see.
959async fn discover_dmarc(dns: Dns<'_>, domain: &str, own: TxtLookup) -> DmarcDiscovery {
960    let failed = |name: String, reason: String| DmarcDiscovery::LookupFailed { name, reason };
961    let own = match own {
962        Ok(own) => own,
963        Err(reason) => return failed(format!("_dmarc.{domain}"), reason),
964    };
965    if select_dmarc(&own) != Selection::None {
966        return DmarcDiscovery::Found(DmarcSource {
967            txts: own,
968            at: domain.to_string(),
969            inherited: false,
970        });
971    }
972    // A registrable domain (or a bare suffix) has no organizational parent.
973    let Some(org) = crate::psl::registrable_parent(domain) else {
974        return DmarcDiscovery::NotFound;
975    };
976    let mut current = domain;
977    for _ in 0..MAX_DMARC_WALK {
978        let Some((_, parent)) = current.split_once('.') else {
979            break;
980        };
981        if parent.len() < org.len() {
982            break;
983        }
984        let name = format!("_dmarc.{parent}");
985        let txts = match dns.txt(&name).await {
986            Ok(txts) => txts,
987            Err(reason) => return failed(name, reason),
988        };
989        if select_dmarc(&txts) != Selection::None {
990            return DmarcDiscovery::Found(DmarcSource {
991                txts,
992                at: parent.to_string(),
993                inherited: true,
994            });
995        }
996        current = parent;
997    }
998    DmarcDiscovery::NotFound
999}
1000
1001/// SMTP DANE records per RFC 7672 §2.2: TLSA lives at `_25._tcp.<MX host>`,
1002/// not at the mail domain. Probes the first [`MAX_DANE_MX_HOSTS`] exchanges
1003/// (preference order) concurrently; a domain with no MX falls back to its
1004/// implicit MX, the domain itself (RFC 5321 §5.1). A null MX (RFC 7505)
1005/// accepts no mail, so there is nothing to pin. A failed MX lookup leaves
1006/// the hosts unknown: nothing is probed and the sets are marked failed.
1007async fn smtp_tlsa_records(
1008    dns: Dns<'_>,
1009    domain: &str,
1010    mx: Result<Vec<crate::dns::DnsRecord>>,
1011) -> TlsaSets {
1012    let Ok(mx) = mx else {
1013        return TlsaSets {
1014            sets: Vec::new(),
1015            lookup_failed: true,
1016        };
1017    };
1018    let mut hosts: Vec<String> = Vec::new();
1019    for record in &mx {
1020        if let RecordData::MX { exchange, .. } = &record.data {
1021            let host = exchange.trim_end_matches('.').to_ascii_lowercase();
1022            if !hosts.contains(&host) {
1023                hosts.push(host);
1024            }
1025        }
1026    }
1027    if hosts.iter().any(|h| h.is_empty()) {
1028        return TlsaSets::default();
1029    }
1030
1031    let owners: Vec<(String, String)> = if hosts.is_empty() {
1032        vec![("_25._tcp".to_string(), format!("_25._tcp.{domain}"))]
1033    } else {
1034        hosts
1035            .into_iter()
1036            .take(MAX_DANE_MX_HOSTS)
1037            .map(|host| {
1038                let owner = format!("_25._tcp.{host}");
1039                (owner.clone(), owner)
1040            })
1041            .collect()
1042    };
1043    let results = futures::future::join_all(
1044        owners
1045            .iter()
1046            .map(|(_, owner)| Box::pin(dns.resolve(owner, RecordType::TLSA))),
1047    )
1048    .await;
1049    let mut tlsa = TlsaSets::default();
1050    for ((scope, _), result) in owners.into_iter().zip(results) {
1051        match result {
1052            Ok(set) => tlsa.sets.push((scope, set)),
1053            Err(_) => tlsa.lookup_failed = true,
1054        }
1055    }
1056    tlsa
1057}
1058
1059/// Resolves and interprets the email/DNS security posture for `domain`,
1060/// querying `nameserver` (a nameserver spec as for
1061/// [`DnsResolver::resolve`]; `None` = the resolver's default upstream).
1062///
1063/// Each mechanism degrades independently: a resolver error (SERVFAIL,
1064/// timeout) for one record set yields that mechanism's
1065/// [`PostureVerdict::Unknown`] with a "lookup failed" note — never `Absent`,
1066/// which would call the domain spoofable on a transient failure — rather
1067/// than failing the whole report. Returns an error only if the domain itself
1068/// is invalid.
1069pub async fn lookup_email_posture(
1070    resolver: &DnsResolver,
1071    domain: &str,
1072    nameserver: Option<&str>,
1073) -> Result<EmailPosture> {
1074    let domain = normalize_domain(domain)?;
1075    let dns = Dns {
1076        resolver,
1077        nameserver,
1078    };
1079
1080    let dmarc_name = format!("_dmarc.{domain}");
1081    let mta_sts_name = format!("_mta-sts.{domain}");
1082    let bimi_name = format!("default._bimi.{domain}");
1083    let https_tlsa_name = format!("_443._tcp.{domain}");
1084
1085    // Resolve every independent record set concurrently. Futures are boxed so
1086    // the combined `join!` frame stays small (avoids the large-future debug
1087    // stack pressure seen in diff.rs).
1088    let (apex_txt, dmarc, mta_sts, bimi, mx, https_tlsa) = tokio::join!(
1089        Box::pin(dns.txt(&domain)),
1090        Box::pin(dns.txt(&dmarc_name)),
1091        Box::pin(dns.txt(&mta_sts_name)),
1092        Box::pin(dns.txt(&bimi_name)),
1093        Box::pin(dns.resolve(&domain, RecordType::MX)),
1094        Box::pin(dns.resolve(&https_tlsa_name, RecordType::TLSA)),
1095    );
1096
1097    // Second round: steps that depend on a first-round answer (SPF redirect
1098    // and lookup count, DMARC inheritance, TLSA at the MX hosts), again
1099    // concurrently.
1100    let (spf, dmarc_discovery, mut tlsa) = tokio::join!(
1101        Box::pin(assess_spf(dns, &apex_txt)),
1102        Box::pin(discover_dmarc(dns, &domain, dmarc)),
1103        Box::pin(smtp_tlsa_records(dns, &domain, mx)),
1104    );
1105    match https_tlsa {
1106        Ok(set) => tlsa.sets.push(("_443._tcp".to_string(), set)),
1107        Err(_) => tlsa.lookup_failed = true,
1108    }
1109
1110    let dmarc = build_dmarc(&domain, &dmarc_discovery);
1111    let mta_sts = build_mta_sts(&mta_sts);
1112    let bimi = build_bimi(&bimi);
1113    let dane = build_dane(&tlsa);
1114    let notes = build_notes(&spf, &dmarc, &mta_sts, &bimi, &dane);
1115
1116    Ok(EmailPosture {
1117        domain,
1118        spf: spf.policy,
1119        dmarc: dmarc.policy,
1120        mta_sts,
1121        bimi,
1122        dane,
1123        notes,
1124    })
1125}
1126
1127#[cfg(test)]
1128mod tests {
1129    use super::*;
1130
1131    fn strings(values: &[&str]) -> Vec<String> {
1132        values.iter().map(|v| v.to_string()).collect()
1133    }
1134
1135    fn own_dmarc(domain: &str, txts: &[&str]) -> DmarcDiscovery {
1136        DmarcDiscovery::Found(DmarcSource {
1137            txts: strings(txts),
1138            at: domain.to_string(),
1139            inherited: false,
1140        })
1141    }
1142
1143    #[test]
1144    fn spf_qualifier_parsing() {
1145        assert_eq!(
1146            parse_spf_all_qualifier("v=spf1 include:_spf.google.com -all"),
1147            Some("-".to_string())
1148        );
1149        assert_eq!(
1150            parse_spf_all_qualifier("v=spf1 mx ~all"),
1151            Some("~".to_string())
1152        );
1153        assert_eq!(
1154            parse_spf_all_qualifier("v=spf1 +all"),
1155            Some("+".to_string())
1156        );
1157        assert_eq!(parse_spf_all_qualifier("v=spf1 a mx"), None);
1158    }
1159
1160    #[test]
1161    fn spf_verdict_bands() {
1162        assert_eq!(spf_verdict(Some("-")), PostureVerdict::Strict);
1163        assert_eq!(spf_verdict(Some("~")), PostureVerdict::Moderate);
1164        assert_eq!(spf_verdict(Some("?")), PostureVerdict::Weak);
1165        assert_eq!(spf_verdict(Some("+")), PostureVerdict::Weak);
1166        assert_eq!(spf_verdict(None), PostureVerdict::Weak);
1167    }
1168
1169    #[test]
1170    fn verdict_as_str_matches_serde_label() {
1171        // Text/CSV renderers use as_str(); it must never disagree with JSON.
1172        for v in [
1173            PostureVerdict::Absent,
1174            PostureVerdict::Weak,
1175            PostureVerdict::Moderate,
1176            PostureVerdict::Strict,
1177            PostureVerdict::Present,
1178        ] {
1179            assert_eq!(serde_json::to_value(v).unwrap(), v.as_str());
1180        }
1181    }
1182
1183    #[test]
1184    fn version_tag_must_be_followed_by_a_delimiter() {
1185        // Regression: a prefix test accepted `v=spf10`.
1186        assert!(!has_version_tag("v=spf10 -all", "v=spf1", false));
1187        assert!(has_version_tag("v=spf1 -all", "v=spf1", false));
1188        assert!(has_version_tag("V=SPF1 -all", "v=spf1", false));
1189        assert!(has_version_tag("v=spf1", "v=spf1", false));
1190        assert!(!has_version_tag("v=spf1;-all", "v=spf1", false));
1191        assert!(has_version_tag("v=DMARC1; p=reject", "v=DMARC1", true));
1192        assert!(has_version_tag("v=DMARC1;p=reject", "v=DMARC1", true));
1193        assert!(!has_version_tag("v=DMARC10; p=reject", "v=DMARC1", true));
1194        assert!(
1195            !has_version_tag("v=é", "v=spf1", false),
1196            "no panic on short multibyte"
1197        );
1198        assert_eq!(select_spf(&strings(&["v=spf10 -all"])), Selection::None);
1199    }
1200
1201    #[test]
1202    fn multiple_spf_records_are_a_permerror() {
1203        // RFC 7208 §4.5: two SPF records → permerror, not "the first wins".
1204        let spf = build_spf(
1205            &strings(&["v=spf1 -all", "v=spf1 include:x.test ~all"]),
1206            &SpfFollowUp::default(),
1207        );
1208        assert!(spf.policy.present);
1209        assert_eq!(spf.policy.verdict, PostureVerdict::Weak);
1210        assert_eq!(spf.policy.all_qualifier, None);
1211        assert!(
1212            spf.notes.iter().any(|n| n.contains("permerror")),
1213            "{:?}",
1214            spf.notes
1215        );
1216    }
1217
1218    #[test]
1219    fn multiple_dmarc_records_disable_dmarc() {
1220        // RFC 7489 §6.6.3: more than one record → DMARC is not applied.
1221        let source = own_dmarc("x.test", &["v=DMARC1; p=reject", "v=DMARC1; p=none"]);
1222        let dmarc = build_dmarc("x.test", &source);
1223        assert!(dmarc.policy.present);
1224        assert_eq!(dmarc.policy.verdict, PostureVerdict::Absent);
1225        assert!(
1226            dmarc.notes[0].contains("2 DMARC records"),
1227            "{:?}",
1228            dmarc.notes
1229        );
1230    }
1231
1232    #[test]
1233    fn dmarc_parsing_and_verdict() {
1234        let record =
1235            "v=DMARC1; p=reject; sp=quarantine; rua=mailto:a@x.com,mailto:b@x.com; pct=100";
1236        let dmarc = build_dmarc("x.com", &own_dmarc("x.com", &[record])).policy;
1237        assert!(dmarc.present);
1238        assert_eq!(dmarc.policy.as_deref(), Some("reject"));
1239        assert_eq!(dmarc.subdomain_policy.as_deref(), Some("quarantine"));
1240        assert_eq!(dmarc.percent, Some(100));
1241        assert_eq!(dmarc.aggregate_reports.len(), 2);
1242        assert_eq!(dmarc.verdict, PostureVerdict::Strict);
1243    }
1244
1245    #[test]
1246    fn dmarc_none_is_weak_and_absent_is_absent() {
1247        let weak = build_dmarc("x.test", &own_dmarc("x.test", &["v=DMARC1; p=none"]));
1248        assert_eq!(weak.policy.verdict, PostureVerdict::Weak);
1249        assert!(weak.notes[0].contains("p=none (monitoring only)"));
1250        let absent = build_dmarc("x.test", &own_dmarc("x.test", &["v=spf1 -all"]));
1251        assert!(!absent.policy.present);
1252        assert_eq!(absent.policy.verdict, PostureVerdict::Absent);
1253    }
1254
1255    #[test]
1256    fn dmarc_partial_pct_downgrades_one_band() {
1257        // Regression: `pct=` was ignored, so p=reject;pct=0 graded Strict.
1258        let verdict = |record: &str| {
1259            let dmarc = build_dmarc("x.test", &own_dmarc("x.test", &[record]));
1260            (dmarc.policy.verdict, dmarc.notes)
1261        };
1262        let (v, notes) = verdict("v=DMARC1; p=reject; pct=0");
1263        assert_eq!(v, PostureVerdict::Moderate);
1264        assert!(notes.iter().any(|n| n.contains("pct=0")), "{notes:?}");
1265        assert_eq!(
1266            verdict("v=DMARC1; p=quarantine; pct=50").0,
1267            PostureVerdict::Weak
1268        );
1269        assert_eq!(
1270            verdict("v=DMARC1; p=reject; pct=100").0,
1271            PostureVerdict::Strict
1272        );
1273        assert_eq!(verdict("v=DMARC1; p=none; pct=10").0, PostureVerdict::Weak);
1274    }
1275
1276    #[test]
1277    fn inherited_dmarc_applies_sp_then_p() {
1278        let inherited = |txt: &str| {
1279            DmarcDiscovery::Found(DmarcSource {
1280                txts: strings(&[txt]),
1281                at: "org.test".to_string(),
1282                inherited: true,
1283            })
1284        };
1285        let dmarc = build_dmarc("mail.org.test", &inherited("v=DMARC1; p=none; sp=reject"));
1286        assert_eq!(dmarc.policy.verdict, PostureVerdict::Strict);
1287        assert!(dmarc.notes[0].contains("inherits"), "{:?}", dmarc.notes);
1288        assert!(dmarc.notes[0].contains("_dmarc.org.test"));
1289
1290        let dmarc = build_dmarc("mail.org.test", &inherited("v=DMARC1; p=quarantine"));
1291        assert_eq!(dmarc.policy.verdict, PostureVerdict::Moderate);
1292    }
1293
1294    #[test]
1295    fn spf_prefers_the_spf_record_among_txt() {
1296        // The apex TXT set carries a verification token AND the SPF record.
1297        let txts = strings(&[
1298            "google-site-verification=abc123",
1299            "v=spf1 include:_spf.google.com -all",
1300        ]);
1301        let spf = build_spf(&txts, &SpfFollowUp::default()).policy;
1302        assert!(spf.present);
1303        assert_eq!(spf.all_qualifier.as_deref(), Some("-"));
1304        assert_eq!(spf.verdict, PostureVerdict::Strict);
1305    }
1306
1307    #[test]
1308    fn spf_redirect_parsing_prefers_all() {
1309        assert_eq!(
1310            spf_terminal("v=spf1 redirect=_spf.x.test"),
1311            SpfTerminal::Redirect("_spf.x.test".to_string())
1312        );
1313        assert_eq!(
1314            spf_terminal("v=spf1 Redirect=_spf.x.test"),
1315            SpfTerminal::Redirect("_spf.x.test".to_string())
1316        );
1317        // RFC 7208 §6.1: redirect is ignored when an `all` is present.
1318        assert_eq!(
1319            spf_terminal("v=spf1 redirect=_spf.x.test ~all"),
1320            SpfTerminal::All("~".to_string())
1321        );
1322        assert_eq!(spf_terminal("v=spf1 mx"), SpfTerminal::Neither);
1323    }
1324
1325    #[test]
1326    fn bimi_and_mta_sts_parse_tags() {
1327        let bimi = build_bimi(&Ok(vec![
1328            "v=BIMI1; l=https://x.com/logo.svg; a=https://x.com/vmc.pem".into(),
1329        ]));
1330        assert!(bimi.present);
1331        assert_eq!(bimi.logo_url.as_deref(), Some("https://x.com/logo.svg"));
1332        assert_eq!(bimi.authority_url.as_deref(), Some("https://x.com/vmc.pem"));
1333
1334        let mta = build_mta_sts(&Ok(vec!["v=STSv1; id=20240101T000000".into()]));
1335        assert!(mta.present);
1336        assert_eq!(mta.id.as_deref(), Some("20240101T000000"));
1337        assert_eq!(mta.verdict, PostureVerdict::Present);
1338    }
1339
1340    #[test]
1341    fn notes_flag_a_spoofable_domain() {
1342        // No DMARC, no SPF, no MTA-STS, no DANE → several advisory notes.
1343        let spf = build_spf(&[], &SpfFollowUp::default());
1344        let dmarc = build_dmarc("x.test", &DmarcDiscovery::NotFound);
1345        let mta = build_mta_sts(&Ok(Vec::new()));
1346        let dane = build_dane(&TlsaSets::default());
1347        let notes = build_notes(&spf, &dmarc, &mta, &build_bimi(&Ok(Vec::new())), &dane);
1348        assert!(notes[0].contains("No DMARC"), "DMARC first: {notes:?}");
1349        assert!(notes.iter().any(|n| n.contains("No SPF")));
1350        assert!(notes.iter().any(|n| n.contains("MTA-STS")));
1351        assert!(notes.iter().any(|n| n.contains("DANE")));
1352    }
1353
1354    // --- End to end against the scripted mock DNS fixture ----------------
1355
1356    use hickory_resolver::proto::rr::rdata as wire;
1357    use hickory_resolver::proto::rr::{Name, RData, RecordType as WireType};
1358
1359    use crate::dns::test_support::{
1360        mock_dns_resolver, mock_dns_resolver_default, spawn_mock_dns_fn, MockReply,
1361    };
1362
1363    fn txt(value: &str) -> MockReply {
1364        MockReply::Answer(vec![RData::TXT(wire::TXT::new(vec![value.to_string()]))])
1365    }
1366
1367    fn mx(preference: u16, exchange: &str) -> RData {
1368        RData::MX(wire::MX::new(
1369            preference,
1370            Name::from_ascii(exchange).unwrap(),
1371        ))
1372    }
1373
1374    fn tlsa() -> MockReply {
1375        MockReply::Answer(vec![RData::TLSA(wire::TLSA::new(
1376            wire::tlsa::CertUsage::from(3),
1377            wire::tlsa::Selector::from(1),
1378            wire::tlsa::Matching::from(1),
1379            vec![0xAB, 0xCD],
1380        ))])
1381    }
1382
1383    /// Regression: SMTP DANE was looked up at `_25._tcp.<domain>`, but
1384    /// RFC 7672 publishes it at `_25._tcp.<MX host>` — a DANE-protected
1385    /// mail domain read as "no DANE".
1386    #[tokio::test]
1387    async fn dane_smtp_is_read_at_the_mx_hosts() {
1388        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1389            ("mail.test", WireType::MX) => {
1390                MockReply::Answer(vec![mx(20, "mx2.mail.test."), mx(10, "mx1.mail.test.")])
1391            }
1392            ("_25._tcp.mx2.mail.test", WireType::TLSA) => tlsa(),
1393            _ => MockReply::NxDomain,
1394        })
1395        .await;
1396        let posture = lookup_email_posture(&mock_dns_resolver_default(port), "mail.test", None)
1397            .await
1398            .unwrap();
1399        assert!(posture.dane.present, "{:?}", posture.dane);
1400        assert_eq!(posture.dane.records.len(), 1);
1401        assert_eq!(posture.dane.records[0].scope, "_25._tcp.mx2.mail.test");
1402    }
1403
1404    /// A domain with no MX is its own (implicit) MX: TLSA at the domain.
1405    #[tokio::test]
1406    async fn dane_smtp_falls_back_to_the_domain_without_mx() {
1407        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1408            ("_25._tcp.nomx.test", WireType::TLSA) => tlsa(),
1409            _ => MockReply::NoData,
1410        })
1411        .await;
1412        let posture = lookup_email_posture(&mock_dns_resolver_default(port), "nomx.test", None)
1413            .await
1414            .unwrap();
1415        assert_eq!(posture.dane.records.len(), 1, "{:?}", posture.dane);
1416        assert_eq!(posture.dane.records[0].scope, "_25._tcp");
1417    }
1418
1419    /// Regression: only `_dmarc.<domain>` was queried, so a subdomain
1420    /// covered by its organizational domain's record read as "No DMARC".
1421    #[tokio::test]
1422    async fn dmarc_is_inherited_from_a_parent() {
1423        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1424            ("_dmarc.org.test", WireType::TXT) => txt("v=DMARC1; p=none; sp=reject"),
1425            _ => MockReply::NxDomain,
1426        })
1427        .await;
1428        let posture =
1429            lookup_email_posture(&mock_dns_resolver_default(port), "news.mail.org.test", None)
1430                .await
1431                .unwrap();
1432        assert!(posture.dmarc.present);
1433        assert_eq!(posture.dmarc.verdict, PostureVerdict::Strict);
1434        assert!(
1435            posture
1436                .notes
1437                .iter()
1438                .any(|n| n.contains("inherits") && n.contains("_dmarc.org.test")),
1439            "{:?}",
1440            posture.notes
1441        );
1442    }
1443
1444    /// Regression: `v=spf1 redirect=…` (no `all`) was graded Weak /
1445    /// "spoofable"; per RFC 7208 §6.1 the redirected record governs. Two
1446    /// hops are followed.
1447    #[tokio::test]
1448    async fn spf_redirect_is_followed() {
1449        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1450            ("brand.test", WireType::TXT) => txt("v=spf1 redirect=_spf.brand.test"),
1451            ("_spf.brand.test", WireType::TXT) => txt("v=spf1 redirect=_spf.esp.test"),
1452            ("_spf.esp.test", WireType::TXT) => txt("v=spf1 ip4:192.0.2.0/24 -all"),
1453            ("broken.test", WireType::TXT) => txt("v=spf1 redirect=_spf.nowhere.test"),
1454            _ => MockReply::NxDomain,
1455        })
1456        .await;
1457        let resolver = mock_dns_resolver_default(port);
1458
1459        let posture = lookup_email_posture(&resolver, "brand.test", None)
1460            .await
1461            .unwrap();
1462        assert_eq!(
1463            posture.spf.verdict,
1464            PostureVerdict::Strict,
1465            "{:?}",
1466            posture.notes
1467        );
1468        assert_eq!(posture.spf.all_qualifier.as_deref(), Some("-"));
1469        assert!(posture.notes.iter().any(|n| n.contains("_spf.esp.test")));
1470
1471        // A redirect to a name with no SPF record is a permerror.
1472        let posture = lookup_email_posture(&resolver, "broken.test", None)
1473            .await
1474            .unwrap();
1475        assert_eq!(posture.spf.verdict, PostureVerdict::Weak);
1476        assert!(
1477            posture.notes.iter().any(|n| n.contains("permerror")),
1478            "{:?}",
1479            posture.notes
1480        );
1481    }
1482
1483    #[test]
1484    fn spf_lookup_terms_count_the_querying_mechanisms() {
1485        // include, a, mx, ptr, exists count (with any qualifier); ip4/all don't.
1486        let (count, targets) = spf_lookup_terms(
1487            "v=spf1 include:_spf.a.test ~include:_spf.b.test a a:x.test/24 MX:y.test \
1488             ptr exists:%{i}.z.test ip4:192.0.2.1 -all",
1489        );
1490        assert_eq!(count, 7);
1491        assert_eq!(targets, vec!["_spf.a.test", "_spf.b.test"]);
1492        // redirect= counts only when there is no `all`.
1493        assert_eq!(
1494            spf_lookup_terms("v=spf1 redirect=_spf.c.test"),
1495            (1, vec!["_spf.c.test".to_string()])
1496        );
1497        assert_eq!(spf_lookup_terms("v=spf1 redirect=_spf.c.test -all").0, 0);
1498    }
1499
1500    #[test]
1501    fn spf_over_the_lookup_limit_is_a_permerror() {
1502        let record = strings(&["v=spf1 include:a.test -all"]);
1503        let at_limit = SpfFollowUp {
1504            redirect: None,
1505            lookups: MAX_SPF_LOOKUPS,
1506        };
1507        assert_eq!(
1508            build_spf(&record, &at_limit).policy.verdict,
1509            PostureVerdict::Strict
1510        );
1511        let over = SpfFollowUp {
1512            redirect: None,
1513            lookups: MAX_SPF_LOOKUPS + 1,
1514        };
1515        let spf = build_spf(&record, &over);
1516        assert_eq!(spf.policy.verdict, PostureVerdict::Weak);
1517        assert!(
1518            spf.notes.iter().any(|n| n.contains("§4.6.4")),
1519            "{:?}",
1520            spf.notes
1521        );
1522    }
1523
1524    /// RFC 7208 §4.6.4: the 10-lookup limit counts through includes. A
1525    /// `-all` record whose includes need 11 lookups is a permerror, not
1526    /// Strict.
1527    #[tokio::test]
1528    async fn spf_lookups_are_counted_through_includes() {
1529        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1530            ("heavy.test", WireType::TXT) => {
1531                txt("v=spf1 include:_a.heavy.test include:_b.heavy.test -all")
1532            }
1533            ("_a.heavy.test", WireType::TXT) => txt("v=spf1 a mx ptr exists:x.test a:y.test ~all"),
1534            ("_b.heavy.test", WireType::TXT) => txt("v=spf1 mx:m1.test mx:m2.test mx:m3.test ~all"),
1535            _ => MockReply::NxDomain,
1536        })
1537        .await;
1538        let resolver = mock_dns_resolver_default(port);
1539
1540        // 2 includes + 5 terms in _a + 3 in _b = 10: at the limit, still valid.
1541        let posture = lookup_email_posture(&resolver, "heavy.test", None)
1542            .await
1543            .unwrap();
1544        assert_eq!(
1545            posture.spf.verdict,
1546            PostureVerdict::Strict,
1547            "{:?}",
1548            posture.notes
1549        );
1550
1551        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1552            ("heavy.test", WireType::TXT) => {
1553                txt("v=spf1 include:_a.heavy.test include:_b.heavy.test a -all")
1554            }
1555            ("_a.heavy.test", WireType::TXT) => txt("v=spf1 a mx ptr exists:x.test a:y.test ~all"),
1556            ("_b.heavy.test", WireType::TXT) => txt("v=spf1 mx:m1.test mx:m2.test mx:m3.test ~all"),
1557            _ => MockReply::NxDomain,
1558        })
1559        .await;
1560        let posture = lookup_email_posture(&mock_dns_resolver_default(port), "heavy.test", None)
1561            .await
1562            .unwrap();
1563        assert_eq!(posture.spf.verdict, PostureVerdict::Weak);
1564        assert!(
1565            posture.notes.iter().any(|n| n.contains("§4.6.4")),
1566            "{:?}",
1567            posture.notes
1568        );
1569    }
1570
1571    /// Regression: a SERVFAIL on `_dmarc` or the apex TXT read as "No
1572    /// DMARC/SPF — trivially spoofable". A failed lookup is Unknown.
1573    #[tokio::test]
1574    async fn failed_lookups_are_unknown_not_absent() {
1575        let port = spawn_mock_dns_fn(|_, _| {
1576            MockReply::Rcode(hickory_resolver::proto::op::ResponseCode::ServFail)
1577        })
1578        .await;
1579        let posture = lookup_email_posture(&mock_dns_resolver_default(port), "down.test", None)
1580            .await
1581            .unwrap();
1582        assert_eq!(posture.spf.verdict, PostureVerdict::Unknown);
1583        assert_eq!(posture.dmarc.verdict, PostureVerdict::Unknown);
1584        assert_eq!(posture.mta_sts.verdict, PostureVerdict::Unknown);
1585        assert_eq!(posture.bimi.verdict, PostureVerdict::Unknown);
1586        assert_eq!(posture.dane.verdict, PostureVerdict::Unknown);
1587        assert!(
1588            !posture.notes.iter().any(|n| n.contains("spoofable")
1589                || n.contains("No SPF")
1590                || n.contains("not configured")
1591                || n.contains("No DANE")),
1592            "{:?}",
1593            posture.notes
1594        );
1595        assert!(posture
1596            .notes
1597            .iter()
1598            .any(|n| n.contains("SPF lookup failed")));
1599        assert!(posture.notes.iter().any(|n| n.contains("DMARC lookup")));
1600    }
1601
1602    /// RFC 7489 §3.2: the DMARC fallback stops at the Organizational Domain
1603    /// (the registrable domain), never querying `_dmarc.co.uk`.
1604    #[tokio::test]
1605    async fn dmarc_walk_stops_at_the_organizational_domain() {
1606        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1607            ("_dmarc.co.uk", WireType::TXT) => txt("v=DMARC1; p=reject"),
1608            _ => MockReply::NxDomain,
1609        })
1610        .await;
1611        let posture =
1612            lookup_email_posture(&mock_dns_resolver_default(port), "mail.example.co.uk", None)
1613                .await
1614                .unwrap();
1615        assert_eq!(posture.dmarc.verdict, PostureVerdict::Absent);
1616
1617        // The organizational domain itself is still the fallback.
1618        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1619            ("_dmarc.example.co.uk", WireType::TXT) => txt("v=DMARC1; p=reject"),
1620            _ => MockReply::NxDomain,
1621        })
1622        .await;
1623        let posture = lookup_email_posture(
1624            &mock_dns_resolver_default(port),
1625            "a.mail.example.co.uk",
1626            None,
1627        )
1628        .await
1629        .unwrap();
1630        assert_eq!(posture.dmarc.verdict, PostureVerdict::Strict);
1631    }
1632
1633    /// The caller's nameserver is used for every posture query (it used to
1634    /// hardcode the default upstream, ignoring `config.nameserver`).
1635    #[tokio::test]
1636    async fn posture_queries_the_given_nameserver() {
1637        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1638            ("ns.test", WireType::TXT) => txt("v=spf1 -all"),
1639            ("_dmarc.ns.test", WireType::TXT) => txt("v=DMARC1; p=reject"),
1640            _ => MockReply::NxDomain,
1641        })
1642        .await;
1643        // No default-nameserver seam: only the explicit spec reaches the mock.
1644        let posture = lookup_email_posture(&mock_dns_resolver(port), "ns.test", Some("127.0.0.1"))
1645            .await
1646            .unwrap();
1647        assert_eq!(posture.spf.verdict, PostureVerdict::Strict);
1648        assert_eq!(posture.dmarc.verdict, PostureVerdict::Strict);
1649    }
1650}