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/TLSA path — no new network protocol. All parsing is
11//! pure and unit-tested; only the record fetch is async.
12
13use std::collections::HashMap;
14
15use serde::{Deserialize, Serialize};
16
17use crate::dns::{DnsResolver, RecordData, RecordType};
18use crate::error::Result;
19use crate::validation::normalize_domain;
20
21/// A coarse enforcement verdict for one posture mechanism.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "kebab-case")]
24pub enum PostureVerdict {
25    /// The mechanism is not configured at all.
26    Absent,
27    /// Configured, but permissive / monitoring-only (offers little protection).
28    Weak,
29    /// Configured with partial enforcement.
30    Moderate,
31    /// Configured with full enforcement.
32    Strict,
33    /// Configured; the mechanism has no weak/strict axis (presence is the signal).
34    Present,
35}
36
37/// SPF (Sender Policy Framework) posture.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct SpfPolicy {
40    pub present: bool,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub record: Option<String>,
43    /// The qualifier on the terminal `all` mechanism: `-` (fail), `~`
44    /// (softfail), `?` (neutral), `+` (pass — permits anyone).
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub all_qualifier: Option<String>,
47    pub verdict: PostureVerdict,
48}
49
50/// DMARC posture.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct DmarcPolicy {
53    pub present: bool,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub record: Option<String>,
56    /// The `p=` policy: `none`, `quarantine`, or `reject`.
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub policy: Option<String>,
59    /// The `sp=` subdomain policy, if set.
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub subdomain_policy: Option<String>,
62    /// Aggregate report (`rua=`) destinations.
63    #[serde(skip_serializing_if = "Vec::is_empty")]
64    pub aggregate_reports: Vec<String>,
65    /// The `pct=` percentage of mail the policy applies to (defaults to 100).
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub percent: Option<u8>,
68    pub verdict: PostureVerdict,
69}
70
71/// MTA-STS posture (DNS signal only; enforcement mode lives in the HTTPS
72/// policy file and is not fetched here to avoid an outbound SSRF surface).
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct MtaStsPolicy {
75    pub present: bool,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub record: Option<String>,
78    /// The policy `id=` from the TXT record (changes when the policy updates).
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub id: Option<String>,
81    pub verdict: PostureVerdict,
82}
83
84/// BIMI posture.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct BimiPolicy {
87    pub present: bool,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub record: Option<String>,
90    /// The `l=` logo URL.
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub logo_url: Option<String>,
93    /// The `a=` VMC (verified mark certificate) authority URL.
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub authority_url: Option<String>,
96    pub verdict: PostureVerdict,
97}
98
99/// A single DANE TLSA record with the port/protocol scope it was found at.
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct TlsaRecord {
102    /// The owner scope, e.g. `_25._tcp` (SMTP) or `_443._tcp` (HTTPS).
103    pub scope: String,
104    pub cert_usage: u8,
105    pub selector: u8,
106    pub matching: u8,
107    pub cert_data: String,
108}
109
110/// DANE (TLSA) posture across the common SMTP and HTTPS scopes.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct DanePolicy {
113    pub present: bool,
114    #[serde(skip_serializing_if = "Vec::is_empty")]
115    pub records: Vec<TlsaRecord>,
116    pub verdict: PostureVerdict,
117}
118
119/// The aggregate email/DNS security posture for a domain.
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct EmailPosture {
122    pub domain: String,
123    pub spf: SpfPolicy,
124    pub dmarc: DmarcPolicy,
125    pub mta_sts: MtaStsPolicy,
126    pub bimi: BimiPolicy,
127    pub dane: DanePolicy,
128    /// Human-readable advisory findings (e.g. "domain is spoofable").
129    pub notes: Vec<String>,
130}
131
132// --- Pure parsers -------------------------------------------------------
133
134/// Returns the qualifier on the terminal `all` mechanism of an SPF record
135/// (`-`, `~`, `?`, or `+`), or `None` if the record has no `all` mechanism.
136fn parse_spf_all_qualifier(record: &str) -> Option<String> {
137    for token in record.split_whitespace() {
138        let t = token.to_ascii_lowercase();
139        if t == "all" {
140            return Some("+".to_string());
141        }
142        if let Some(qual) = t.strip_suffix("all") {
143            if matches!(qual, "-" | "~" | "?" | "+") {
144                return Some(qual.to_string());
145            }
146        }
147    }
148    None
149}
150
151/// Maps an SPF `all` qualifier to a verdict.
152fn spf_verdict(all_qualifier: Option<&str>) -> PostureVerdict {
153    match all_qualifier {
154        Some("-") => PostureVerdict::Strict,
155        Some("~") => PostureVerdict::Moderate,
156        // "?all" (neutral), "+all" (pass-all), or a record with no `all` term
157        // provides effectively no protection.
158        Some(_) | None => PostureVerdict::Weak,
159    }
160}
161
162/// Splits a `k=v; k=v` policy record (DMARC/BIMI/MTA-STS) into a tag map with
163/// lowercased keys.
164fn parse_tag_value(record: &str) -> HashMap<String, String> {
165    record
166        .split(';')
167        .filter_map(|pair| {
168            let (k, v) = pair.split_once('=')?;
169            Some((k.trim().to_ascii_lowercase(), v.trim().to_string()))
170        })
171        .collect()
172}
173
174/// Maps a DMARC `p=` policy to a verdict.
175fn dmarc_verdict(policy: Option<&str>) -> PostureVerdict {
176    match policy.map(|p| p.to_ascii_lowercase()) {
177        Some(ref p) if p == "reject" => PostureVerdict::Strict,
178        Some(ref p) if p == "quarantine" => PostureVerdict::Moderate,
179        Some(ref p) if p == "none" => PostureVerdict::Weak,
180        _ => PostureVerdict::Weak,
181    }
182}
183
184/// Case-insensitively finds the first TXT record whose text begins with
185/// `version_prefix` (e.g. `v=spf1`, `v=DMARC1`).
186fn find_txt_with_prefix<'a>(records: &'a [String], version_prefix: &str) -> Option<&'a String> {
187    let prefix = version_prefix.to_ascii_lowercase();
188    records
189        .iter()
190        .find(|t| t.trim().to_ascii_lowercase().starts_with(&prefix))
191}
192
193/// Extracts the TXT record strings from a resolved record set.
194fn txt_strings(records: &[crate::dns::DnsRecord]) -> Vec<String> {
195    records
196        .iter()
197        .filter_map(|r| match &r.data {
198            RecordData::TXT { text } => Some(text.clone()),
199            _ => None,
200        })
201        .collect()
202}
203
204// --- Assemblers (pure given the fetched records) -----------------------
205
206fn build_spf(apex_txt: &[String]) -> SpfPolicy {
207    match find_txt_with_prefix(apex_txt, "v=spf1") {
208        Some(record) => {
209            let all_qualifier = parse_spf_all_qualifier(record);
210            SpfPolicy {
211                present: true,
212                verdict: spf_verdict(all_qualifier.as_deref()),
213                all_qualifier,
214                record: Some(record.clone()),
215            }
216        }
217        None => SpfPolicy {
218            present: false,
219            record: None,
220            all_qualifier: None,
221            verdict: PostureVerdict::Absent,
222        },
223    }
224}
225
226fn build_dmarc(dmarc_txt: &[String]) -> DmarcPolicy {
227    match find_txt_with_prefix(dmarc_txt, "v=dmarc1") {
228        Some(record) => {
229            let tags = parse_tag_value(record);
230            let policy = tags.get("p").cloned();
231            let aggregate_reports = tags
232                .get("rua")
233                .map(|rua| {
234                    rua.split(',')
235                        .map(|s| s.trim().to_string())
236                        .filter(|s| !s.is_empty())
237                        .collect()
238                })
239                .unwrap_or_default();
240            DmarcPolicy {
241                present: true,
242                verdict: dmarc_verdict(policy.as_deref()),
243                subdomain_policy: tags.get("sp").cloned(),
244                percent: tags.get("pct").and_then(|p| p.parse().ok()),
245                aggregate_reports,
246                policy,
247                record: Some(record.clone()),
248            }
249        }
250        None => DmarcPolicy {
251            present: false,
252            record: None,
253            policy: None,
254            subdomain_policy: None,
255            aggregate_reports: Vec::new(),
256            percent: None,
257            verdict: PostureVerdict::Absent,
258        },
259    }
260}
261
262fn build_mta_sts(txt: &[String]) -> MtaStsPolicy {
263    match find_txt_with_prefix(txt, "v=stsv1") {
264        Some(record) => {
265            let tags = parse_tag_value(record);
266            MtaStsPolicy {
267                present: true,
268                id: tags.get("id").cloned(),
269                record: Some(record.clone()),
270                verdict: PostureVerdict::Present,
271            }
272        }
273        None => MtaStsPolicy {
274            present: false,
275            record: None,
276            id: None,
277            verdict: PostureVerdict::Absent,
278        },
279    }
280}
281
282fn build_bimi(txt: &[String]) -> BimiPolicy {
283    match find_txt_with_prefix(txt, "v=bimi1") {
284        Some(record) => {
285            let tags = parse_tag_value(record);
286            BimiPolicy {
287                present: true,
288                logo_url: tags.get("l").filter(|s| !s.is_empty()).cloned(),
289                authority_url: tags.get("a").filter(|s| !s.is_empty()).cloned(),
290                record: Some(record.clone()),
291                verdict: PostureVerdict::Present,
292            }
293        }
294        None => BimiPolicy {
295            present: false,
296            record: None,
297            logo_url: None,
298            authority_url: None,
299            verdict: PostureVerdict::Absent,
300        },
301    }
302}
303
304fn build_dane(smtp: &[crate::dns::DnsRecord], https: &[crate::dns::DnsRecord]) -> DanePolicy {
305    let mut records = Vec::new();
306    let mut collect = |set: &[crate::dns::DnsRecord], scope: &str| {
307        for r in set {
308            if let RecordData::TLSA {
309                cert_usage,
310                selector,
311                matching,
312                cert_data,
313            } = &r.data
314            {
315                records.push(TlsaRecord {
316                    scope: scope.to_string(),
317                    cert_usage: *cert_usage,
318                    selector: *selector,
319                    matching: *matching,
320                    cert_data: cert_data.clone(),
321                });
322            }
323        }
324    };
325    collect(smtp, "_25._tcp");
326    collect(https, "_443._tcp");
327    DanePolicy {
328        present: !records.is_empty(),
329        verdict: if records.is_empty() {
330            PostureVerdict::Absent
331        } else {
332            PostureVerdict::Present
333        },
334        records,
335    }
336}
337
338/// Builds the advisory notes from the assembled per-mechanism policies.
339fn build_notes(
340    spf: &SpfPolicy,
341    dmarc: &DmarcPolicy,
342    mta_sts: &MtaStsPolicy,
343    dane: &DanePolicy,
344) -> Vec<String> {
345    let mut notes = Vec::new();
346
347    match dmarc.verdict {
348        PostureVerdict::Absent => notes
349            .push("No DMARC record — the domain is trivially spoofable in phishing.".to_string()),
350        PostureVerdict::Weak => notes.push(
351            "DMARC policy is p=none (monitoring only) — receivers will not reject spoofed mail."
352                .to_string(),
353        ),
354        PostureVerdict::Moderate => notes.push(
355            "DMARC policy is p=quarantine — spoofed mail is filtered but not rejected.".to_string(),
356        ),
357        _ => {}
358    }
359
360    match spf.verdict {
361        PostureVerdict::Absent => {
362            notes.push("No SPF record — sending sources are undeclared.".to_string());
363        }
364        PostureVerdict::Weak => notes.push(
365            "SPF does not end in -all/~all — it permits unlisted senders (spoofable).".to_string(),
366        ),
367        PostureVerdict::Moderate => {
368            notes.push("SPF uses ~all (softfail); -all provides stricter enforcement.".to_string());
369        }
370        _ => {}
371    }
372
373    if !mta_sts.present {
374        notes.push("MTA-STS is not configured — SMTP is vulnerable to downgrade.".to_string());
375    }
376    if !dane.present {
377        notes.push("No DANE (TLSA) records — no DNS-based TLS pinning for mail/HTTPS.".to_string());
378    }
379
380    notes
381}
382
383/// Resolves and interprets the email/DNS security posture for `domain`.
384///
385/// Each mechanism degrades independently: a resolver error for one record set
386/// simply yields that mechanism's `Absent`/empty state rather than failing the
387/// whole report. Returns an error only if the domain itself is invalid.
388pub async fn lookup_email_posture(resolver: &DnsResolver, domain: &str) -> Result<EmailPosture> {
389    let domain = normalize_domain(domain)?;
390
391    let dmarc_name = format!("_dmarc.{domain}");
392    let mta_sts_name = format!("_mta-sts.{domain}");
393    let bimi_name = format!("default._bimi.{domain}");
394    let smtp_tlsa_name = format!("_25._tcp.{domain}");
395    let https_tlsa_name = format!("_443._tcp.{domain}");
396
397    // Resolve every record set concurrently. Futures are boxed so the combined
398    // `join!` frame stays small (avoids the large-future debug stack pressure
399    // seen in diff.rs). Each resolve error degrades to an empty record set.
400    let (apex, dmarc, mta_sts, bimi, smtp_tlsa, https_tlsa) = tokio::join!(
401        Box::pin(resolver.resolve(&domain, RecordType::TXT, None)),
402        Box::pin(resolver.resolve(&dmarc_name, RecordType::TXT, None)),
403        Box::pin(resolver.resolve(&mta_sts_name, RecordType::TXT, None)),
404        Box::pin(resolver.resolve(&bimi_name, RecordType::TXT, None)),
405        Box::pin(resolver.resolve(&smtp_tlsa_name, RecordType::TLSA, None)),
406        Box::pin(resolver.resolve(&https_tlsa_name, RecordType::TLSA, None)),
407    );
408
409    let apex = apex.unwrap_or_default();
410    let dmarc = dmarc.unwrap_or_default();
411    let mta_sts = mta_sts.unwrap_or_default();
412    let bimi = bimi.unwrap_or_default();
413    let smtp_tlsa = smtp_tlsa.unwrap_or_default();
414    let https_tlsa = https_tlsa.unwrap_or_default();
415
416    let spf = build_spf(&txt_strings(&apex));
417    let dmarc = build_dmarc(&txt_strings(&dmarc));
418    let mta_sts = build_mta_sts(&txt_strings(&mta_sts));
419    let bimi = build_bimi(&txt_strings(&bimi));
420    let dane = build_dane(&smtp_tlsa, &https_tlsa);
421    let notes = build_notes(&spf, &dmarc, &mta_sts, &dane);
422
423    Ok(EmailPosture {
424        domain,
425        spf,
426        dmarc,
427        mta_sts,
428        bimi,
429        dane,
430        notes,
431    })
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    #[test]
439    fn spf_qualifier_parsing() {
440        assert_eq!(
441            parse_spf_all_qualifier("v=spf1 include:_spf.google.com -all"),
442            Some("-".to_string())
443        );
444        assert_eq!(
445            parse_spf_all_qualifier("v=spf1 mx ~all"),
446            Some("~".to_string())
447        );
448        assert_eq!(
449            parse_spf_all_qualifier("v=spf1 +all"),
450            Some("+".to_string())
451        );
452        assert_eq!(parse_spf_all_qualifier("v=spf1 a mx"), None);
453    }
454
455    #[test]
456    fn spf_verdict_bands() {
457        assert_eq!(spf_verdict(Some("-")), PostureVerdict::Strict);
458        assert_eq!(spf_verdict(Some("~")), PostureVerdict::Moderate);
459        assert_eq!(spf_verdict(Some("?")), PostureVerdict::Weak);
460        assert_eq!(spf_verdict(Some("+")), PostureVerdict::Weak);
461        assert_eq!(spf_verdict(None), PostureVerdict::Weak);
462    }
463
464    #[test]
465    fn dmarc_parsing_and_verdict() {
466        let record =
467            "v=DMARC1; p=reject; sp=quarantine; rua=mailto:a@x.com,mailto:b@x.com; pct=100";
468        let dmarc = build_dmarc(&[record.to_string()]);
469        assert!(dmarc.present);
470        assert_eq!(dmarc.policy.as_deref(), Some("reject"));
471        assert_eq!(dmarc.subdomain_policy.as_deref(), Some("quarantine"));
472        assert_eq!(dmarc.percent, Some(100));
473        assert_eq!(dmarc.aggregate_reports.len(), 2);
474        assert_eq!(dmarc.verdict, PostureVerdict::Strict);
475    }
476
477    #[test]
478    fn dmarc_none_is_weak_and_absent_is_absent() {
479        let weak = build_dmarc(&["v=DMARC1; p=none".to_string()]);
480        assert_eq!(weak.verdict, PostureVerdict::Weak);
481        let absent = build_dmarc(&["v=spf1 -all".to_string()]);
482        assert!(!absent.present);
483        assert_eq!(absent.verdict, PostureVerdict::Absent);
484    }
485
486    #[test]
487    fn spf_prefers_the_spf_record_among_txt() {
488        // The apex TXT set carries a verification token AND the SPF record.
489        let txts = vec![
490            "google-site-verification=abc123".to_string(),
491            "v=spf1 include:_spf.google.com -all".to_string(),
492        ];
493        let spf = build_spf(&txts);
494        assert!(spf.present);
495        assert_eq!(spf.all_qualifier.as_deref(), Some("-"));
496        assert_eq!(spf.verdict, PostureVerdict::Strict);
497    }
498
499    #[test]
500    fn bimi_and_mta_sts_parse_tags() {
501        let bimi =
502            build_bimi(&["v=BIMI1; l=https://x.com/logo.svg; a=https://x.com/vmc.pem".into()]);
503        assert!(bimi.present);
504        assert_eq!(bimi.logo_url.as_deref(), Some("https://x.com/logo.svg"));
505        assert_eq!(bimi.authority_url.as_deref(), Some("https://x.com/vmc.pem"));
506
507        let mta = build_mta_sts(&["v=STSv1; id=20240101T000000".into()]);
508        assert!(mta.present);
509        assert_eq!(mta.id.as_deref(), Some("20240101T000000"));
510        assert_eq!(mta.verdict, PostureVerdict::Present);
511    }
512
513    #[test]
514    fn notes_flag_a_spoofable_domain() {
515        // No DMARC, no SPF, no MTA-STS, no DANE → several advisory notes.
516        let spf = build_spf(&[]);
517        let dmarc = build_dmarc(&[]);
518        let mta = build_mta_sts(&[]);
519        let dane = build_dane(&[], &[]);
520        let notes = build_notes(&spf, &dmarc, &mta, &dane);
521        assert!(notes.iter().any(|n| n.contains("No DMARC")));
522        assert!(notes.iter().any(|n| n.contains("No SPF")));
523        assert!(notes.iter().any(|n| n.contains("MTA-STS")));
524        assert!(notes.iter().any(|n| n.contains("DANE")));
525    }
526}