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}
40
41impl PostureVerdict {
42    /// The kebab-case label serde emits, for text renderers and CSV cells.
43    pub fn as_str(self) -> &'static str {
44        match self {
45            PostureVerdict::Absent => "absent",
46            PostureVerdict::Weak => "weak",
47            PostureVerdict::Moderate => "moderate",
48            PostureVerdict::Strict => "strict",
49            PostureVerdict::Present => "present",
50        }
51    }
52}
53
54/// SPF (Sender Policy Framework) posture.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct SpfPolicy {
57    pub present: bool,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub record: Option<String>,
60    /// The qualifier on the terminal `all` mechanism: `-` (fail), `~`
61    /// (softfail), `?` (neutral), `+` (pass — permits anyone).
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub all_qualifier: Option<String>,
64    pub verdict: PostureVerdict,
65}
66
67/// DMARC posture.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct DmarcPolicy {
70    pub present: bool,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub record: Option<String>,
73    /// The `p=` policy: `none`, `quarantine`, or `reject`.
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub policy: Option<String>,
76    /// The `sp=` subdomain policy, if set.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub subdomain_policy: Option<String>,
79    /// Aggregate report (`rua=`) destinations.
80    #[serde(skip_serializing_if = "Vec::is_empty")]
81    pub aggregate_reports: Vec<String>,
82    /// The `pct=` percentage of mail the policy applies to (defaults to 100).
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub percent: Option<u8>,
85    pub verdict: PostureVerdict,
86}
87
88/// MTA-STS posture (DNS signal only; enforcement mode lives in the HTTPS
89/// policy file and is not fetched here to avoid an outbound SSRF surface).
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct MtaStsPolicy {
92    pub present: bool,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub record: Option<String>,
95    /// The policy `id=` from the TXT record (changes when the policy updates).
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub id: Option<String>,
98    pub verdict: PostureVerdict,
99}
100
101/// BIMI posture.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct BimiPolicy {
104    pub present: bool,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub record: Option<String>,
107    /// The `l=` logo URL.
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub logo_url: Option<String>,
110    /// The `a=` VMC (verified mark certificate) authority URL.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub authority_url: Option<String>,
113    pub verdict: PostureVerdict,
114}
115
116/// A single DANE TLSA record with the port/protocol scope it was found at.
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct TlsaRecord {
119    /// The owner scope: `_25._tcp.<mx-host>` for SMTP records at an MX host
120    /// (RFC 7672), `_25._tcp` for a domain without MX (its implicit MX is the
121    /// domain itself), or `_443._tcp` (HTTPS).
122    pub scope: String,
123    pub cert_usage: u8,
124    pub selector: u8,
125    pub matching: u8,
126    pub cert_data: String,
127}
128
129/// DANE (TLSA) posture across the common SMTP and HTTPS scopes.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct DanePolicy {
132    pub present: bool,
133    #[serde(skip_serializing_if = "Vec::is_empty")]
134    pub records: Vec<TlsaRecord>,
135    pub verdict: PostureVerdict,
136}
137
138/// The aggregate email/DNS security posture for a domain.
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct EmailPosture {
141    pub domain: String,
142    pub spf: SpfPolicy,
143    pub dmarc: DmarcPolicy,
144    pub mta_sts: MtaStsPolicy,
145    pub bimi: BimiPolicy,
146    pub dane: DanePolicy,
147    /// Human-readable advisory findings (e.g. "domain is spoofable").
148    pub notes: Vec<String>,
149}
150
151// --- Bounds -----------------------------------------------------------------
152
153/// Maximum SPF `redirect=` hops followed (RFC 7208 §6.1). Each hop is one
154/// DNS TXT query — no outbound connection, so no SSRF surface.
155const MAX_SPF_REDIRECTS: usize = 2;
156
157/// Maximum parent names tried when looking for an inherited DMARC record.
158const MAX_DMARC_WALK: usize = 7;
159
160/// Maximum MX hosts (in preference order) probed for SMTP DANE records.
161const MAX_DANE_MX_HOSTS: usize = 5;
162
163// --- Pure parsers -------------------------------------------------------
164
165/// True when `record` begins with the version tag `tag` (ASCII
166/// case-insensitive) followed by the end of the record, whitespace, or —
167/// for the `k=v;` policy records (`allow_semicolon`) — a `;`. A bare prefix
168/// test accepted `v=spf10 …` as SPF.
169fn has_version_tag(record: &str, tag: &str, allow_semicolon: bool) -> bool {
170    let record = record.trim_start();
171    let Some(head) = record.get(..tag.len()) else {
172        return false;
173    };
174    if !head.eq_ignore_ascii_case(tag) {
175        return false;
176    }
177    match record[tag.len()..].chars().next() {
178        None => true,
179        Some(c) => c.is_whitespace() || (allow_semicolon && c == ';'),
180    }
181}
182
183/// How many records in a TXT set carry a given version tag.
184#[derive(Debug, PartialEq, Eq)]
185enum Selection<'a> {
186    None,
187    One(&'a str),
188    /// More than one — SPF evaluation is a permerror (RFC 7208 §4.5) and
189    /// DMARC is not applied at all (RFC 7489 §6.6.3).
190    Multiple(usize),
191}
192
193fn select<'a>(records: &'a [String], tag: &str, allow_semicolon: bool) -> Selection<'a> {
194    let mut matching = records
195        .iter()
196        .filter(|r| has_version_tag(r, tag, allow_semicolon));
197    match (matching.next(), matching.count()) {
198        (None, _) => Selection::None,
199        (Some(record), 0) => Selection::One(record),
200        (Some(_), rest) => Selection::Multiple(rest + 1),
201    }
202}
203
204fn select_spf(records: &[String]) -> Selection<'_> {
205    select(records, "v=spf1", false)
206}
207
208fn select_dmarc(records: &[String]) -> Selection<'_> {
209    select(records, "v=DMARC1", true)
210}
211
212/// Returns the qualifier on the terminal `all` mechanism of an SPF record
213/// (`-`, `~`, `?`, or `+`), or `None` if the record has no `all` mechanism.
214fn parse_spf_all_qualifier(record: &str) -> Option<String> {
215    for token in record.split_whitespace() {
216        let t = token.to_ascii_lowercase();
217        if t == "all" {
218            return Some("+".to_string());
219        }
220        if let Some(qual) = t.strip_suffix("all") {
221            if matches!(qual, "-" | "~" | "?" | "+") {
222                return Some(qual.to_string());
223            }
224        }
225    }
226    None
227}
228
229/// The target of an SPF `redirect=` modifier, if any.
230fn parse_spf_redirect(record: &str) -> Option<String> {
231    record.split_whitespace().find_map(|token| {
232        let (name, value) = token.split_once('=')?;
233        name.eq_ignore_ascii_case("redirect")
234            .then(|| value.to_string())
235    })
236}
237
238/// What decides a single SPF record's verdict.
239#[derive(Debug, PartialEq, Eq)]
240enum SpfTerminal {
241    /// The `all` mechanism's qualifier.
242    All(String),
243    /// No `all`, but a `redirect=` modifier: the target record governs
244    /// (RFC 7208 §6.1 — `redirect` is ignored when `all` is present).
245    Redirect(String),
246    /// Neither: the default result is neutral, i.e. no protection.
247    Neither,
248}
249
250fn spf_terminal(record: &str) -> SpfTerminal {
251    match (parse_spf_all_qualifier(record), parse_spf_redirect(record)) {
252        (Some(qualifier), _) => SpfTerminal::All(qualifier),
253        (None, Some(target)) => SpfTerminal::Redirect(target),
254        (None, None) => SpfTerminal::Neither,
255    }
256}
257
258/// Maps an SPF `all` qualifier to a verdict.
259fn spf_verdict(all_qualifier: Option<&str>) -> PostureVerdict {
260    match all_qualifier {
261        Some("-") => PostureVerdict::Strict,
262        Some("~") => PostureVerdict::Moderate,
263        // "?all" (neutral), "+all" (pass-all), or a record with no `all` term
264        // provides effectively no protection.
265        Some(_) | None => PostureVerdict::Weak,
266    }
267}
268
269/// Splits a `k=v; k=v` policy record (DMARC/BIMI/MTA-STS) into a tag map with
270/// lowercased keys.
271fn parse_tag_value(record: &str) -> HashMap<String, String> {
272    record
273        .split(';')
274        .filter_map(|pair| {
275            let (k, v) = pair.split_once('=')?;
276            Some((k.trim().to_ascii_lowercase(), v.trim().to_string()))
277        })
278        .collect()
279}
280
281/// Maps a DMARC `p=`/`sp=` policy to a verdict.
282fn dmarc_verdict(policy: Option<&str>) -> PostureVerdict {
283    match policy.map(|p| p.to_ascii_lowercase()) {
284        Some(ref p) if p == "reject" => PostureVerdict::Strict,
285        Some(ref p) if p == "quarantine" => PostureVerdict::Moderate,
286        Some(ref p) if p == "none" => PostureVerdict::Weak,
287        _ => PostureVerdict::Weak,
288    }
289}
290
291/// One enforcement band weaker (a `pct=` below 100 applies the policy to only
292/// part of the failing mail; RFC 7489 §6.6.4 gives the rest the next-lower
293/// policy).
294fn downgrade(verdict: PostureVerdict) -> PostureVerdict {
295    match verdict {
296        PostureVerdict::Strict => PostureVerdict::Moderate,
297        PostureVerdict::Moderate => PostureVerdict::Weak,
298        other => other,
299    }
300}
301
302/// Extracts the TXT record strings from a resolved record set.
303fn txt_strings(records: &[crate::dns::DnsRecord]) -> Vec<String> {
304    records
305        .iter()
306        .filter_map(|r| match &r.data {
307            RecordData::TXT { text } => Some(text.clone()),
308            _ => None,
309        })
310        .collect()
311}
312
313// --- Assemblers (pure given the fetched records) -----------------------
314
315/// A mechanism's policy plus the advisory notes it contributes.
316#[derive(Debug)]
317struct Assessed<T> {
318    policy: T,
319    notes: Vec<String>,
320}
321
322/// The result of following an SPF `redirect=` chain.
323#[derive(Debug)]
324enum RedirectOutcome {
325    /// The chain ended at `target`, whose record's `all` qualifier (if any)
326    /// is the effective one.
327    Resolved {
328        target: String,
329        qualifier: Option<String>,
330    },
331    /// The chain could not be evaluated (permerror/temperror).
332    Failed { target: String, reason: String },
333}
334
335fn spf_verdict_note(verdict: PostureVerdict) -> Option<String> {
336    match verdict {
337        PostureVerdict::Weak => Some(
338            "SPF does not end in -all/~all — it permits unlisted senders (spoofable).".to_string(),
339        ),
340        PostureVerdict::Moderate => {
341            Some("SPF uses ~all (softfail); -all provides stricter enforcement.".to_string())
342        }
343        _ => None,
344    }
345}
346
347/// Assembles the SPF policy from the apex TXT set. `redirect` is the outcome
348/// of following the record's `redirect=` modifier, when it has one and no
349/// `all` (see [`assess_spf`]).
350fn build_spf(apex_txt: &[String], redirect: Option<&RedirectOutcome>) -> Assessed<SpfPolicy> {
351    let record = match select_spf(apex_txt) {
352        Selection::None => {
353            return Assessed {
354                policy: SpfPolicy {
355                    present: false,
356                    record: None,
357                    all_qualifier: None,
358                    verdict: PostureVerdict::Absent,
359                },
360                notes: vec!["No SPF record — sending sources are undeclared.".to_string()],
361            };
362        }
363        Selection::Multiple(count) => {
364            return Assessed {
365                policy: SpfPolicy {
366                    present: true,
367                    record: None,
368                    all_qualifier: None,
369                    verdict: PostureVerdict::Weak,
370                },
371                notes: vec![format!(
372                    "{count} SPF records are published — SPF evaluation fails with permerror \
373                     (RFC 7208 §4.5), so receivers get no SPF result. Merge them into one record."
374                )],
375            };
376        }
377        Selection::One(record) => record,
378    };
379
380    let mut notes = Vec::new();
381    let all_qualifier = match (spf_terminal(record), redirect) {
382        (SpfTerminal::All(qualifier), _) => Some(qualifier),
383        (SpfTerminal::Redirect(_), Some(RedirectOutcome::Resolved { target, qualifier })) => {
384            notes.push(format!(
385                "SPF delegates to {target} via redirect= — the verdict reflects that record."
386            ));
387            qualifier.clone()
388        }
389        (SpfTerminal::Redirect(_), Some(RedirectOutcome::Failed { target, reason })) => {
390            return Assessed {
391                policy: SpfPolicy {
392                    present: true,
393                    record: Some(record.to_string()),
394                    all_qualifier: None,
395                    verdict: PostureVerdict::Weak,
396                },
397                notes: vec![format!(
398                    "SPF redirect={target} could not be evaluated ({reason}) — receivers get \
399                     no usable SPF result."
400                )],
401            };
402        }
403        (SpfTerminal::Redirect(_), None) | (SpfTerminal::Neither, _) => None,
404    };
405    let verdict = spf_verdict(all_qualifier.as_deref());
406    notes.extend(spf_verdict_note(verdict));
407    Assessed {
408        policy: SpfPolicy {
409            present: true,
410            verdict,
411            all_qualifier,
412            record: Some(record.to_string()),
413        },
414        notes,
415    }
416}
417
418/// Where the applicable DMARC record set was found.
419#[derive(Debug)]
420struct DmarcSource {
421    /// The TXT strings at `_dmarc.<at>`.
422    txts: Vec<String>,
423    /// The domain whose `_dmarc` name held the records.
424    at: String,
425    /// True when `at` is a parent of the checked domain.
426    inherited: bool,
427}
428
429fn absent_dmarc() -> DmarcPolicy {
430    DmarcPolicy {
431        present: false,
432        record: None,
433        policy: None,
434        subdomain_policy: None,
435        aggregate_reports: Vec::new(),
436        percent: None,
437        verdict: PostureVerdict::Absent,
438    }
439}
440
441/// Assembles the DMARC policy for `domain` from the discovered record set.
442fn build_dmarc(domain: &str, source: Option<&DmarcSource>) -> Assessed<DmarcPolicy> {
443    let spoofable =
444        || vec!["No DMARC record — the domain is trivially spoofable in phishing.".to_string()];
445    let Some(source) = source else {
446        return Assessed {
447            policy: absent_dmarc(),
448            notes: spoofable(),
449        };
450    };
451    let record = match select_dmarc(&source.txts) {
452        Selection::None => {
453            return Assessed {
454                policy: absent_dmarc(),
455                notes: spoofable(),
456            };
457        }
458        Selection::Multiple(count) => {
459            return Assessed {
460                policy: DmarcPolicy {
461                    present: true,
462                    ..absent_dmarc()
463                },
464                notes: vec![format!(
465                    "{count} DMARC records at _dmarc.{} — receivers do not apply DMARC at all \
466                     when more than one is published (RFC 7489 §6.6.3). Publish exactly one.",
467                    source.at
468                )],
469            };
470        }
471        Selection::One(record) => record,
472    };
473
474    let tags = parse_tag_value(record);
475    let policy = tags.get("p").cloned();
476    let subdomain_policy = tags.get("sp").cloned();
477    let percent: Option<u8> = tags.get("pct").and_then(|p| p.parse().ok());
478    let aggregate_reports = tags
479        .get("rua")
480        .map(|rua| {
481            rua.split(',')
482                .map(|s| s.trim().to_string())
483                .filter(|s| !s.is_empty())
484                .collect()
485        })
486        .unwrap_or_default();
487
488    // A subdomain governed by its organizational domain's record gets that
489    // record's `sp=` policy, falling back to `p=` (RFC 7489 §6.6.3 / §6.3).
490    let (label, effective) = match (&subdomain_policy, source.inherited) {
491        (Some(sp), true) => ("sp", Some(sp.as_str())),
492        _ => ("p", policy.as_deref()),
493    };
494    let base = dmarc_verdict(effective);
495    let partial_pct = percent.filter(|pct| *pct < 100);
496    let verdict = if partial_pct.is_some() {
497        downgrade(base)
498    } else {
499        base
500    };
501
502    let mut notes = Vec::new();
503    if source.inherited {
504        notes.push(format!(
505            "No DMARC record at _dmarc.{domain} — it inherits the organizational record at \
506             _dmarc.{} (RFC 7489 §6.6.3), whose {label}= policy applies.",
507            source.at
508        ));
509    }
510    let effective_lc = effective.map(str::to_ascii_lowercase);
511    match (base, effective_lc.as_deref()) {
512        (PostureVerdict::Moderate, _) => notes.push(format!(
513            "DMARC policy is {label}=quarantine — spoofed mail is filtered but not rejected."
514        )),
515        (PostureVerdict::Weak, Some("none")) => notes.push(format!(
516            "DMARC policy is {label}=none (monitoring only) — receivers will not reject spoofed mail."
517        )),
518        (PostureVerdict::Weak, _) => notes.push(format!(
519            "DMARC record has no valid {label}= policy — receivers treat it as monitoring only (p=none)."
520        )),
521        _ => {}
522    }
523    if let (Some(pct), Some(value)) = (partial_pct, effective_lc.as_deref()) {
524        if base != PostureVerdict::Weak {
525            notes.push(format!(
526                "DMARC pct={pct} — the {label}={value} policy is applied to only {pct}% of \
527                 failing mail (the rest gets the next-weaker treatment), so enforcement is partial."
528            ));
529        }
530    }
531
532    Assessed {
533        policy: DmarcPolicy {
534            present: true,
535            verdict,
536            subdomain_policy,
537            percent,
538            aggregate_reports,
539            policy,
540            record: Some(record.to_string()),
541        },
542        notes,
543    }
544}
545
546fn build_mta_sts(txt: &[String]) -> MtaStsPolicy {
547    match txt.iter().find(|t| has_version_tag(t, "v=STSv1", true)) {
548        Some(record) => {
549            let tags = parse_tag_value(record);
550            MtaStsPolicy {
551                present: true,
552                id: tags.get("id").cloned(),
553                record: Some(record.clone()),
554                verdict: PostureVerdict::Present,
555            }
556        }
557        None => MtaStsPolicy {
558            present: false,
559            record: None,
560            id: None,
561            verdict: PostureVerdict::Absent,
562        },
563    }
564}
565
566fn build_bimi(txt: &[String]) -> BimiPolicy {
567    match txt.iter().find(|t| has_version_tag(t, "v=BIMI1", true)) {
568        Some(record) => {
569            let tags = parse_tag_value(record);
570            BimiPolicy {
571                present: true,
572                logo_url: tags.get("l").filter(|s| !s.is_empty()).cloned(),
573                authority_url: tags.get("a").filter(|s| !s.is_empty()).cloned(),
574                record: Some(record.clone()),
575                verdict: PostureVerdict::Present,
576            }
577        }
578        None => BimiPolicy {
579            present: false,
580            record: None,
581            logo_url: None,
582            authority_url: None,
583            verdict: PostureVerdict::Absent,
584        },
585    }
586}
587
588/// Assembles the DANE policy from the SMTP TLSA sets (each with its owner
589/// scope) and the HTTPS set.
590fn build_dane(
591    smtp: &[(String, Vec<crate::dns::DnsRecord>)],
592    https: &[crate::dns::DnsRecord],
593) -> DanePolicy {
594    let mut records = Vec::new();
595    let mut collect = |set: &[crate::dns::DnsRecord], scope: &str| {
596        for r in set {
597            if let RecordData::TLSA {
598                cert_usage,
599                selector,
600                matching,
601                cert_data,
602            } = &r.data
603            {
604                records.push(TlsaRecord {
605                    scope: scope.to_string(),
606                    cert_usage: *cert_usage,
607                    selector: *selector,
608                    matching: *matching,
609                    cert_data: cert_data.clone(),
610                });
611            }
612        }
613    };
614    for (scope, set) in smtp {
615        collect(set, scope);
616    }
617    collect(https, "_443._tcp");
618    DanePolicy {
619        present: !records.is_empty(),
620        verdict: if records.is_empty() {
621            PostureVerdict::Absent
622        } else {
623            PostureVerdict::Present
624        },
625        records,
626    }
627}
628
629/// Orders the advisory notes: DMARC, SPF, then the transport mechanisms.
630fn build_notes(
631    spf: &Assessed<SpfPolicy>,
632    dmarc: &Assessed<DmarcPolicy>,
633    mta_sts: &MtaStsPolicy,
634    dane: &DanePolicy,
635) -> Vec<String> {
636    let mut notes = dmarc.notes.clone();
637    notes.extend(spf.notes.iter().cloned());
638    if !mta_sts.present {
639        notes.push("MTA-STS is not configured — SMTP is vulnerable to downgrade.".to_string());
640    }
641    if !dane.present {
642        notes.push("No DANE (TLSA) records — no DNS-based TLS pinning for mail/HTTPS.".to_string());
643    }
644    notes
645}
646
647// --- Network steps (each bounded; DNS only) ------------------------------
648
649/// Follows an SPF `redirect=` chain for up to [`MAX_SPF_REDIRECTS`] hops.
650async fn follow_spf_redirect(resolver: &DnsResolver, first_target: String) -> RedirectOutcome {
651    let mut target = first_target;
652    for _ in 0..MAX_SPF_REDIRECTS {
653        if target.contains('%') {
654            return RedirectOutcome::Failed {
655                target,
656                reason: "it uses SPF macros, which seer does not expand".to_string(),
657            };
658        }
659        let Ok(name) = normalize_host(&target) else {
660            return RedirectOutcome::Failed {
661                target,
662                reason: "not a valid domain name — permerror".to_string(),
663            };
664        };
665        let txts = match resolver.resolve(&name, RecordType::TXT, None).await {
666            Ok(records) => txt_strings(&records),
667            Err(e) => {
668                return RedirectOutcome::Failed {
669                    target: name,
670                    reason: format!("DNS lookup failed: {}", e.sanitized_message()),
671                }
672            }
673        };
674        match select_spf(&txts) {
675            Selection::None => {
676                return RedirectOutcome::Failed {
677                    target: name,
678                    reason: "it publishes no SPF record — permerror".to_string(),
679                }
680            }
681            Selection::Multiple(count) => {
682                return RedirectOutcome::Failed {
683                    target: name,
684                    reason: format!("it publishes {count} SPF records — permerror"),
685                }
686            }
687            Selection::One(record) => match spf_terminal(record) {
688                SpfTerminal::All(qualifier) => {
689                    return RedirectOutcome::Resolved {
690                        target: name,
691                        qualifier: Some(qualifier),
692                    }
693                }
694                SpfTerminal::Neither => {
695                    return RedirectOutcome::Resolved {
696                        target: name,
697                        qualifier: None,
698                    }
699                }
700                SpfTerminal::Redirect(next) => target = next,
701            },
702        }
703    }
704    RedirectOutcome::Failed {
705        target,
706        reason: format!("the redirect chain is longer than {MAX_SPF_REDIRECTS} hops"),
707    }
708}
709
710/// SPF for the apex TXT set, following `redirect=` when the record has no
711/// `all` term (RFC 7208 §6.1: the redirected record then governs). Grading
712/// such a record on its own as "no -all/~all" called well-run delegated
713/// setups spoofable.
714async fn assess_spf(resolver: &DnsResolver, apex_txt: &[String]) -> Assessed<SpfPolicy> {
715    let redirect = match select_spf(apex_txt) {
716        Selection::One(record) => match spf_terminal(record) {
717            SpfTerminal::Redirect(target) => Some(follow_spf_redirect(resolver, target).await),
718            _ => None,
719        },
720        _ => None,
721    };
722    build_spf(apex_txt, redirect.as_ref())
723}
724
725/// Finds the DMARC record set that applies to `domain`: its own
726/// `_dmarc.<domain>`, or — when that has no DMARC record — the first parent
727/// (one label at a time, at least two labels, at most [`MAX_DMARC_WALK`])
728/// that has one. RFC 7489 §6.6.3 falls back to the Organizational Domain;
729/// without a public-suffix list, the nearest publishing ancestor is the
730/// practical stand-in (and what the DMARCbis tree walk does). A query error
731/// stops the walk rather than skipping past a record we could not see.
732async fn discover_dmarc(
733    resolver: &DnsResolver,
734    domain: &str,
735    own: Result<Vec<crate::dns::DnsRecord>>,
736) -> Option<DmarcSource> {
737    let own = txt_strings(&own.ok()?);
738    if select_dmarc(&own) != Selection::None {
739        return Some(DmarcSource {
740            txts: own,
741            at: domain.to_string(),
742            inherited: false,
743        });
744    }
745    let mut current = domain;
746    for _ in 0..MAX_DMARC_WALK {
747        let parent = match current.split_once('.') {
748            Some((_, parent)) if parent.contains('.') => parent,
749            _ => return None,
750        };
751        let records = resolver
752            .resolve(&format!("_dmarc.{parent}"), RecordType::TXT, None)
753            .await
754            .ok()?;
755        let txts = txt_strings(&records);
756        if select_dmarc(&txts) != Selection::None {
757            return Some(DmarcSource {
758                txts,
759                at: parent.to_string(),
760                inherited: true,
761            });
762        }
763        current = parent;
764    }
765    None
766}
767
768/// SMTP DANE records per RFC 7672 §2.2: TLSA lives at `_25._tcp.<MX host>`,
769/// not at the mail domain. Probes the first [`MAX_DANE_MX_HOSTS`] exchanges
770/// (preference order) concurrently; a domain with no MX falls back to its
771/// implicit MX, the domain itself (RFC 5321 §5.1). A null MX (RFC 7505)
772/// accepts no mail, so there is nothing to pin.
773async fn smtp_tlsa_records(
774    resolver: &DnsResolver,
775    domain: &str,
776    mx: Result<Vec<crate::dns::DnsRecord>>,
777) -> Vec<(String, Vec<crate::dns::DnsRecord>)> {
778    let mut hosts: Vec<String> = Vec::new();
779    for record in mx.as_deref().unwrap_or_default() {
780        if let RecordData::MX { exchange, .. } = &record.data {
781            let host = exchange.trim_end_matches('.').to_ascii_lowercase();
782            if !hosts.contains(&host) {
783                hosts.push(host);
784            }
785        }
786    }
787    if hosts.iter().any(|h| h.is_empty()) {
788        return Vec::new();
789    }
790
791    let owners: Vec<(String, String)> = if hosts.is_empty() {
792        vec![("_25._tcp".to_string(), format!("_25._tcp.{domain}"))]
793    } else {
794        hosts
795            .into_iter()
796            .take(MAX_DANE_MX_HOSTS)
797            .map(|host| {
798                let owner = format!("_25._tcp.{host}");
799                (owner.clone(), owner)
800            })
801            .collect()
802    };
803    let results = futures::future::join_all(
804        owners
805            .iter()
806            .map(|(_, owner)| Box::pin(resolver.resolve(owner, RecordType::TLSA, None))),
807    )
808    .await;
809    owners
810        .into_iter()
811        .zip(results)
812        .map(|((scope, _), result)| (scope, result.unwrap_or_default()))
813        .collect()
814}
815
816/// Resolves and interprets the email/DNS security posture for `domain`.
817///
818/// Each mechanism degrades independently: a resolver error for one record set
819/// simply yields that mechanism's `Absent`/empty state rather than failing the
820/// whole report. Returns an error only if the domain itself is invalid.
821pub async fn lookup_email_posture(resolver: &DnsResolver, domain: &str) -> Result<EmailPosture> {
822    let domain = normalize_domain(domain)?;
823
824    let dmarc_name = format!("_dmarc.{domain}");
825    let mta_sts_name = format!("_mta-sts.{domain}");
826    let bimi_name = format!("default._bimi.{domain}");
827    let https_tlsa_name = format!("_443._tcp.{domain}");
828
829    // Resolve every independent record set concurrently. Futures are boxed so
830    // the combined `join!` frame stays small (avoids the large-future debug
831    // stack pressure seen in diff.rs). Each resolve error degrades to an
832    // empty record set.
833    let (apex, dmarc, mta_sts, bimi, mx, https_tlsa) = tokio::join!(
834        Box::pin(resolver.resolve(&domain, RecordType::TXT, None)),
835        Box::pin(resolver.resolve(&dmarc_name, RecordType::TXT, None)),
836        Box::pin(resolver.resolve(&mta_sts_name, RecordType::TXT, None)),
837        Box::pin(resolver.resolve(&bimi_name, RecordType::TXT, None)),
838        Box::pin(resolver.resolve(&domain, RecordType::MX, None)),
839        Box::pin(resolver.resolve(&https_tlsa_name, RecordType::TLSA, None)),
840    );
841    let apex_txt = txt_strings(&apex.unwrap_or_default());
842
843    // Second round: steps that depend on a first-round answer (SPF redirect,
844    // DMARC inheritance, TLSA at the MX hosts), again concurrently.
845    let (spf, dmarc_source, smtp_tlsa) = tokio::join!(
846        Box::pin(assess_spf(resolver, &apex_txt)),
847        Box::pin(discover_dmarc(resolver, &domain, dmarc)),
848        Box::pin(smtp_tlsa_records(resolver, &domain, mx)),
849    );
850
851    let dmarc = build_dmarc(&domain, dmarc_source.as_ref());
852    let mta_sts = build_mta_sts(&txt_strings(&mta_sts.unwrap_or_default()));
853    let bimi = build_bimi(&txt_strings(&bimi.unwrap_or_default()));
854    let dane = build_dane(&smtp_tlsa, &https_tlsa.unwrap_or_default());
855    let notes = build_notes(&spf, &dmarc, &mta_sts, &dane);
856
857    Ok(EmailPosture {
858        domain,
859        spf: spf.policy,
860        dmarc: dmarc.policy,
861        mta_sts,
862        bimi,
863        dane,
864        notes,
865    })
866}
867
868#[cfg(test)]
869mod tests {
870    use super::*;
871
872    fn strings(values: &[&str]) -> Vec<String> {
873        values.iter().map(|v| v.to_string()).collect()
874    }
875
876    fn own_dmarc(domain: &str, txts: &[&str]) -> DmarcSource {
877        DmarcSource {
878            txts: strings(txts),
879            at: domain.to_string(),
880            inherited: false,
881        }
882    }
883
884    #[test]
885    fn spf_qualifier_parsing() {
886        assert_eq!(
887            parse_spf_all_qualifier("v=spf1 include:_spf.google.com -all"),
888            Some("-".to_string())
889        );
890        assert_eq!(
891            parse_spf_all_qualifier("v=spf1 mx ~all"),
892            Some("~".to_string())
893        );
894        assert_eq!(
895            parse_spf_all_qualifier("v=spf1 +all"),
896            Some("+".to_string())
897        );
898        assert_eq!(parse_spf_all_qualifier("v=spf1 a mx"), None);
899    }
900
901    #[test]
902    fn spf_verdict_bands() {
903        assert_eq!(spf_verdict(Some("-")), PostureVerdict::Strict);
904        assert_eq!(spf_verdict(Some("~")), PostureVerdict::Moderate);
905        assert_eq!(spf_verdict(Some("?")), PostureVerdict::Weak);
906        assert_eq!(spf_verdict(Some("+")), PostureVerdict::Weak);
907        assert_eq!(spf_verdict(None), PostureVerdict::Weak);
908    }
909
910    #[test]
911    fn verdict_as_str_matches_serde_label() {
912        // Text/CSV renderers use as_str(); it must never disagree with JSON.
913        for v in [
914            PostureVerdict::Absent,
915            PostureVerdict::Weak,
916            PostureVerdict::Moderate,
917            PostureVerdict::Strict,
918            PostureVerdict::Present,
919        ] {
920            assert_eq!(serde_json::to_value(v).unwrap(), v.as_str());
921        }
922    }
923
924    #[test]
925    fn version_tag_must_be_followed_by_a_delimiter() {
926        // Regression: a prefix test accepted `v=spf10`.
927        assert!(!has_version_tag("v=spf10 -all", "v=spf1", false));
928        assert!(has_version_tag("v=spf1 -all", "v=spf1", false));
929        assert!(has_version_tag("V=SPF1 -all", "v=spf1", false));
930        assert!(has_version_tag("v=spf1", "v=spf1", false));
931        assert!(!has_version_tag("v=spf1;-all", "v=spf1", false));
932        assert!(has_version_tag("v=DMARC1; p=reject", "v=DMARC1", true));
933        assert!(has_version_tag("v=DMARC1;p=reject", "v=DMARC1", true));
934        assert!(!has_version_tag("v=DMARC10; p=reject", "v=DMARC1", true));
935        assert!(
936            !has_version_tag("v=é", "v=spf1", false),
937            "no panic on short multibyte"
938        );
939        assert_eq!(select_spf(&strings(&["v=spf10 -all"])), Selection::None);
940    }
941
942    #[test]
943    fn multiple_spf_records_are_a_permerror() {
944        // RFC 7208 §4.5: two SPF records → permerror, not "the first wins".
945        let spf = build_spf(
946            &strings(&["v=spf1 -all", "v=spf1 include:x.test ~all"]),
947            None,
948        );
949        assert!(spf.policy.present);
950        assert_eq!(spf.policy.verdict, PostureVerdict::Weak);
951        assert_eq!(spf.policy.all_qualifier, None);
952        assert!(
953            spf.notes.iter().any(|n| n.contains("permerror")),
954            "{:?}",
955            spf.notes
956        );
957    }
958
959    #[test]
960    fn multiple_dmarc_records_disable_dmarc() {
961        // RFC 7489 §6.6.3: more than one record → DMARC is not applied.
962        let source = own_dmarc("x.test", &["v=DMARC1; p=reject", "v=DMARC1; p=none"]);
963        let dmarc = build_dmarc("x.test", Some(&source));
964        assert!(dmarc.policy.present);
965        assert_eq!(dmarc.policy.verdict, PostureVerdict::Absent);
966        assert!(
967            dmarc.notes[0].contains("2 DMARC records"),
968            "{:?}",
969            dmarc.notes
970        );
971    }
972
973    #[test]
974    fn dmarc_parsing_and_verdict() {
975        let record =
976            "v=DMARC1; p=reject; sp=quarantine; rua=mailto:a@x.com,mailto:b@x.com; pct=100";
977        let dmarc = build_dmarc("x.com", Some(&own_dmarc("x.com", &[record]))).policy;
978        assert!(dmarc.present);
979        assert_eq!(dmarc.policy.as_deref(), Some("reject"));
980        assert_eq!(dmarc.subdomain_policy.as_deref(), Some("quarantine"));
981        assert_eq!(dmarc.percent, Some(100));
982        assert_eq!(dmarc.aggregate_reports.len(), 2);
983        assert_eq!(dmarc.verdict, PostureVerdict::Strict);
984    }
985
986    #[test]
987    fn dmarc_none_is_weak_and_absent_is_absent() {
988        let weak = build_dmarc("x.test", Some(&own_dmarc("x.test", &["v=DMARC1; p=none"])));
989        assert_eq!(weak.policy.verdict, PostureVerdict::Weak);
990        assert!(weak.notes[0].contains("p=none (monitoring only)"));
991        let absent = build_dmarc("x.test", Some(&own_dmarc("x.test", &["v=spf1 -all"])));
992        assert!(!absent.policy.present);
993        assert_eq!(absent.policy.verdict, PostureVerdict::Absent);
994    }
995
996    #[test]
997    fn dmarc_partial_pct_downgrades_one_band() {
998        // Regression: `pct=` was ignored, so p=reject;pct=0 graded Strict.
999        let verdict = |record: &str| {
1000            let dmarc = build_dmarc("x.test", Some(&own_dmarc("x.test", &[record])));
1001            (dmarc.policy.verdict, dmarc.notes)
1002        };
1003        let (v, notes) = verdict("v=DMARC1; p=reject; pct=0");
1004        assert_eq!(v, PostureVerdict::Moderate);
1005        assert!(notes.iter().any(|n| n.contains("pct=0")), "{notes:?}");
1006        assert_eq!(
1007            verdict("v=DMARC1; p=quarantine; pct=50").0,
1008            PostureVerdict::Weak
1009        );
1010        assert_eq!(
1011            verdict("v=DMARC1; p=reject; pct=100").0,
1012            PostureVerdict::Strict
1013        );
1014        assert_eq!(verdict("v=DMARC1; p=none; pct=10").0, PostureVerdict::Weak);
1015    }
1016
1017    #[test]
1018    fn inherited_dmarc_applies_sp_then_p() {
1019        let inherited = |txt: &str| DmarcSource {
1020            txts: strings(&[txt]),
1021            at: "org.test".to_string(),
1022            inherited: true,
1023        };
1024        let dmarc = build_dmarc(
1025            "mail.org.test",
1026            Some(&inherited("v=DMARC1; p=none; sp=reject")),
1027        );
1028        assert_eq!(dmarc.policy.verdict, PostureVerdict::Strict);
1029        assert!(dmarc.notes[0].contains("inherits"), "{:?}", dmarc.notes);
1030        assert!(dmarc.notes[0].contains("_dmarc.org.test"));
1031
1032        let dmarc = build_dmarc("mail.org.test", Some(&inherited("v=DMARC1; p=quarantine")));
1033        assert_eq!(dmarc.policy.verdict, PostureVerdict::Moderate);
1034    }
1035
1036    #[test]
1037    fn spf_prefers_the_spf_record_among_txt() {
1038        // The apex TXT set carries a verification token AND the SPF record.
1039        let txts = strings(&[
1040            "google-site-verification=abc123",
1041            "v=spf1 include:_spf.google.com -all",
1042        ]);
1043        let spf = build_spf(&txts, None).policy;
1044        assert!(spf.present);
1045        assert_eq!(spf.all_qualifier.as_deref(), Some("-"));
1046        assert_eq!(spf.verdict, PostureVerdict::Strict);
1047    }
1048
1049    #[test]
1050    fn spf_redirect_parsing_prefers_all() {
1051        assert_eq!(
1052            spf_terminal("v=spf1 redirect=_spf.x.test"),
1053            SpfTerminal::Redirect("_spf.x.test".to_string())
1054        );
1055        assert_eq!(
1056            spf_terminal("v=spf1 Redirect=_spf.x.test"),
1057            SpfTerminal::Redirect("_spf.x.test".to_string())
1058        );
1059        // RFC 7208 §6.1: redirect is ignored when an `all` is present.
1060        assert_eq!(
1061            spf_terminal("v=spf1 redirect=_spf.x.test ~all"),
1062            SpfTerminal::All("~".to_string())
1063        );
1064        assert_eq!(spf_terminal("v=spf1 mx"), SpfTerminal::Neither);
1065    }
1066
1067    #[test]
1068    fn bimi_and_mta_sts_parse_tags() {
1069        let bimi =
1070            build_bimi(&["v=BIMI1; l=https://x.com/logo.svg; a=https://x.com/vmc.pem".into()]);
1071        assert!(bimi.present);
1072        assert_eq!(bimi.logo_url.as_deref(), Some("https://x.com/logo.svg"));
1073        assert_eq!(bimi.authority_url.as_deref(), Some("https://x.com/vmc.pem"));
1074
1075        let mta = build_mta_sts(&["v=STSv1; id=20240101T000000".into()]);
1076        assert!(mta.present);
1077        assert_eq!(mta.id.as_deref(), Some("20240101T000000"));
1078        assert_eq!(mta.verdict, PostureVerdict::Present);
1079    }
1080
1081    #[test]
1082    fn notes_flag_a_spoofable_domain() {
1083        // No DMARC, no SPF, no MTA-STS, no DANE → several advisory notes.
1084        let spf = build_spf(&[], None);
1085        let dmarc = build_dmarc("x.test", None);
1086        let mta = build_mta_sts(&[]);
1087        let dane = build_dane(&[], &[]);
1088        let notes = build_notes(&spf, &dmarc, &mta, &dane);
1089        assert!(notes[0].contains("No DMARC"), "DMARC first: {notes:?}");
1090        assert!(notes.iter().any(|n| n.contains("No SPF")));
1091        assert!(notes.iter().any(|n| n.contains("MTA-STS")));
1092        assert!(notes.iter().any(|n| n.contains("DANE")));
1093    }
1094
1095    // --- End to end against the scripted mock DNS fixture ----------------
1096
1097    use hickory_resolver::proto::rr::rdata as wire;
1098    use hickory_resolver::proto::rr::{Name, RData, RecordType as WireType};
1099
1100    use crate::dns::test_support::{mock_dns_resolver_default, spawn_mock_dns_fn, MockReply};
1101
1102    fn txt(value: &str) -> MockReply {
1103        MockReply::Answer(vec![RData::TXT(wire::TXT::new(vec![value.to_string()]))])
1104    }
1105
1106    fn mx(preference: u16, exchange: &str) -> RData {
1107        RData::MX(wire::MX::new(
1108            preference,
1109            Name::from_ascii(exchange).unwrap(),
1110        ))
1111    }
1112
1113    fn tlsa() -> MockReply {
1114        MockReply::Answer(vec![RData::TLSA(wire::TLSA::new(
1115            wire::tlsa::CertUsage::from(3),
1116            wire::tlsa::Selector::from(1),
1117            wire::tlsa::Matching::from(1),
1118            vec![0xAB, 0xCD],
1119        ))])
1120    }
1121
1122    /// Regression: SMTP DANE was looked up at `_25._tcp.<domain>`, but
1123    /// RFC 7672 publishes it at `_25._tcp.<MX host>` — a DANE-protected
1124    /// mail domain read as "no DANE".
1125    #[tokio::test]
1126    async fn dane_smtp_is_read_at_the_mx_hosts() {
1127        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1128            ("mail.test", WireType::MX) => {
1129                MockReply::Answer(vec![mx(20, "mx2.mail.test."), mx(10, "mx1.mail.test.")])
1130            }
1131            ("_25._tcp.mx2.mail.test", WireType::TLSA) => tlsa(),
1132            _ => MockReply::NxDomain,
1133        })
1134        .await;
1135        let posture = lookup_email_posture(&mock_dns_resolver_default(port), "mail.test")
1136            .await
1137            .unwrap();
1138        assert!(posture.dane.present, "{:?}", posture.dane);
1139        assert_eq!(posture.dane.records.len(), 1);
1140        assert_eq!(posture.dane.records[0].scope, "_25._tcp.mx2.mail.test");
1141    }
1142
1143    /// A domain with no MX is its own (implicit) MX: TLSA at the domain.
1144    #[tokio::test]
1145    async fn dane_smtp_falls_back_to_the_domain_without_mx() {
1146        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1147            ("_25._tcp.nomx.test", WireType::TLSA) => tlsa(),
1148            _ => MockReply::NoData,
1149        })
1150        .await;
1151        let posture = lookup_email_posture(&mock_dns_resolver_default(port), "nomx.test")
1152            .await
1153            .unwrap();
1154        assert_eq!(posture.dane.records.len(), 1, "{:?}", posture.dane);
1155        assert_eq!(posture.dane.records[0].scope, "_25._tcp");
1156    }
1157
1158    /// Regression: only `_dmarc.<domain>` was queried, so a subdomain
1159    /// covered by its organizational domain's record read as "No DMARC".
1160    #[tokio::test]
1161    async fn dmarc_is_inherited_from_a_parent() {
1162        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1163            ("_dmarc.org.test", WireType::TXT) => txt("v=DMARC1; p=none; sp=reject"),
1164            _ => MockReply::NxDomain,
1165        })
1166        .await;
1167        let posture = lookup_email_posture(&mock_dns_resolver_default(port), "news.mail.org.test")
1168            .await
1169            .unwrap();
1170        assert!(posture.dmarc.present);
1171        assert_eq!(posture.dmarc.verdict, PostureVerdict::Strict);
1172        assert!(
1173            posture
1174                .notes
1175                .iter()
1176                .any(|n| n.contains("inherits") && n.contains("_dmarc.org.test")),
1177            "{:?}",
1178            posture.notes
1179        );
1180    }
1181
1182    /// Regression: `v=spf1 redirect=…` (no `all`) was graded Weak /
1183    /// "spoofable"; per RFC 7208 §6.1 the redirected record governs. Two
1184    /// hops are followed.
1185    #[tokio::test]
1186    async fn spf_redirect_is_followed() {
1187        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1188            ("brand.test", WireType::TXT) => txt("v=spf1 redirect=_spf.brand.test"),
1189            ("_spf.brand.test", WireType::TXT) => txt("v=spf1 redirect=_spf.esp.test"),
1190            ("_spf.esp.test", WireType::TXT) => txt("v=spf1 ip4:192.0.2.0/24 -all"),
1191            ("broken.test", WireType::TXT) => txt("v=spf1 redirect=_spf.nowhere.test"),
1192            _ => MockReply::NxDomain,
1193        })
1194        .await;
1195        let resolver = mock_dns_resolver_default(port);
1196
1197        let posture = lookup_email_posture(&resolver, "brand.test").await.unwrap();
1198        assert_eq!(
1199            posture.spf.verdict,
1200            PostureVerdict::Strict,
1201            "{:?}",
1202            posture.notes
1203        );
1204        assert_eq!(posture.spf.all_qualifier.as_deref(), Some("-"));
1205        assert!(posture.notes.iter().any(|n| n.contains("_spf.esp.test")));
1206
1207        // A redirect to a name with no SPF record is a permerror.
1208        let posture = lookup_email_posture(&resolver, "broken.test")
1209            .await
1210            .unwrap();
1211        assert_eq!(posture.spf.verdict, PostureVerdict::Weak);
1212        assert!(
1213            posture.notes.iter().any(|n| n.contains("permerror")),
1214            "{:?}",
1215            posture.notes
1216        );
1217    }
1218}