Skip to main content

seer_core/audit/
mod.rs

1//! Graded, multi-layer security assessment of one domain (`seer audit`).
2//!
3//! Seer's single-purpose checks (email posture, HTTP headers, TLS, DNSSEC,
4//! delegation, takeover, registration, plus DKIM, security.txt and the
5//! HTTP→HTTPS upgrade) each report in their own shape. This module runs them
6//! together and normalises their results into [`Finding`]s — one model the
7//! later phases (profiling, estate, impersonator hunting) build on.
8//!
9//! - [`finding`] — the model. Ids are a stable contract.
10//! - [`score`] — category grades, overall grade, severity caps, pass gate.
11//! - `checks` — the adapter table; every adapter maps its source's typed
12//!   report through a pure function, reading structured fields only.
13//!
14//! Unknown is never a fail: a source error marks its check incomplete and
15//! produces no finding. [`Auditor::audit`] errors only for invalid input.
16//! Every check runs concurrently under one deadline
17//! ([`crate::config::SeerConfig::audit_timeout`]). Retry, SSRF and IPv4-first
18//! rules are the sources' own, unchanged.
19
20pub(crate) mod checks;
21pub mod finding;
22pub mod score;
23
24use std::time::Duration;
25
26use serde::Serialize;
27
28pub use finding::{Category, CheckRun, CheckStatus, Finding, Severity};
29pub use score::{CategoryGrade, CategoryState, OverallGrade};
30
31use crate::config::SeerConfig;
32use crate::dns::{DelegationChecker, DnsResolver, DnssecChecker};
33use crate::error::Result;
34use crate::grade::Letter;
35use crate::http::GuardedFetcher;
36use crate::lookup::SmartLookup;
37use crate::ssl::SslChecker;
38use crate::validation::{normalize_domain, normalize_host};
39
40/// Per-run options.
41#[derive(Debug, Clone, Copy, Default)]
42pub struct AuditOptions {
43    /// Fail the gate ([`AuditReport::passed`]) when the overall letter is
44    /// below this.
45    pub fail_under: Option<Letter>,
46}
47
48/// The graded audit of one domain.
49#[derive(Debug, Clone, Serialize)]
50pub struct AuditReport {
51    /// Registration-level name (`normalize_domain`).
52    pub domain: String,
53    /// The host probed by web/TLS checks (`normalize_host`).
54    pub host: String,
55    /// `None` when no category could be graded.
56    pub overall: Option<OverallGrade>,
57    /// Every category, in display order.
58    pub categories: Vec<CategoryGrade>,
59    /// Severity descending, then category, then id.
60    pub findings: Vec<Finding>,
61    /// Every check's outcome, including incomplete and not-applicable ones.
62    pub runs: Vec<CheckRun>,
63    /// False when any check was incomplete.
64    pub complete: bool,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub fail_under: Option<Letter>,
67    /// The gate: graded, no Critical finding, and at least `fail_under`.
68    pub passed: bool,
69}
70
71impl AuditReport {
72    /// Number of findings at `severity`.
73    pub fn count(&self, severity: Severity) -> usize {
74        self.findings
75            .iter()
76            .filter(|f| f.severity == severity)
77            .count()
78    }
79
80    /// Sorts, grades and gates the raw check output.
81    fn assemble(
82        domain: String,
83        host: String,
84        mut output: checks::CheckOutput,
85        fail_under: Option<Letter>,
86    ) -> Self {
87        output.findings.sort_by(|a, b| {
88            b.severity
89                .cmp(&a.severity)
90                .then(a.category.cmp(&b.category))
91                .then(a.id.cmp(b.id))
92                .then(a.host.cmp(&b.host))
93        });
94        let categories = score::grade_categories(&output.runs, &output.findings);
95        let overall = score::overall(&categories, &output.findings);
96        let complete = !output
97            .runs
98            .iter()
99            .any(|r| matches!(r.status, CheckStatus::Incomplete { .. }));
100        let passed = score::passed(overall.as_ref(), &output.findings, fail_under);
101        Self {
102            domain,
103            host,
104            overall,
105            categories,
106            findings: output.findings,
107            runs: output.runs,
108            complete,
109            fail_under,
110            passed,
111        }
112    }
113}
114
115/// Runs the audit. Build once and reuse: it owns the clients.
116pub struct Auditor {
117    pub(crate) lookup: SmartLookup,
118    pub(crate) dns: DnsResolver,
119    pub(crate) ssl: SslChecker,
120    pub(crate) dnssec: DnssecChecker,
121    pub(crate) delegation: DelegationChecker,
122    pub(crate) http_timeout: Duration,
123    pub(crate) deadline: Duration,
124}
125
126impl Default for Auditor {
127    fn default() -> Self {
128        Self::new()
129    }
130}
131
132impl Auditor {
133    /// An auditor with default settings.
134    pub fn new() -> Self {
135        Self::from_config(&SeerConfig::default())
136    }
137
138    /// An auditor using the configured timeouts and resolver.
139    pub fn from_config(config: &SeerConfig) -> Self {
140        Self {
141            lookup: SmartLookup::from_config(config),
142            dns: DnsResolver::from_config(config),
143            ssl: SslChecker::from_config(config),
144            dnssec: DnssecChecker::from_config(config),
145            delegation: DelegationChecker::from_config(config),
146            http_timeout: config.http_timeout(),
147            deadline: config.audit_timeout(),
148        }
149    }
150
151    /// A guarded HTTP fetcher with the configured timeout.
152    pub(crate) fn fetcher(&self) -> GuardedFetcher {
153        GuardedFetcher::new().with_timeout(self.http_timeout)
154    }
155
156    /// Audits `input` (a domain or host).
157    ///
158    /// # Errors
159    /// Only for input that fails normalization; every check failure is
160    /// reported inside the [`AuditReport`] as an incomplete check.
161    pub async fn audit(&self, input: &str, options: AuditOptions) -> Result<AuditReport> {
162        let domain = normalize_domain(input)?;
163        let host = normalize_host(input)?;
164        let output = {
165            let ctx = checks::AuditContext {
166                auditor: self,
167                domain: &domain,
168                host: &host,
169            };
170            checks::run_specs(checks::CHECKS, &ctx, self.deadline).await
171        };
172        Ok(AuditReport::assemble(
173            domain,
174            host,
175            output,
176            options.fail_under,
177        ))
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[tokio::test]
186    async fn invalid_input_is_the_only_error() {
187        assert!(Auditor::new()
188            .audit("not a domain!!", AuditOptions::default())
189            .await
190            .is_err());
191    }
192
193    #[test]
194    fn offline_like_run_is_ungraded_and_fails_the_gate() {
195        let mut output = checks::CheckOutput::default();
196        for category in Category::ALL {
197            output.incomplete("x", category, "DNS resolution failed");
198        }
199        let report =
200            AuditReport::assemble("example.com".into(), "example.com".into(), output, None);
201        assert_eq!(report.overall, None);
202        assert!(!report.complete);
203        assert!(!report.passed);
204    }
205
206    #[test]
207    fn findings_sort_by_severity_then_category_then_id() {
208        let mut output = checks::CheckOutput::default();
209        output.ran(
210            "w",
211            Category::Web,
212            vec![
213                Finding::new("web.b.low", Category::Web, Severity::Low, "h", "t"),
214                Finding::new("web.a.high", Category::Web, Severity::High, "h", "t"),
215            ],
216        );
217        output.ran(
218            "e",
219            Category::Email,
220            vec![Finding::new(
221                "email.a.high",
222                Category::Email,
223                Severity::High,
224                "h",
225                "t",
226            )],
227        );
228        let report = AuditReport::assemble("h".into(), "h".into(), output, None);
229        let ids: Vec<_> = report.findings.iter().map(|f| f.id).collect();
230        assert_eq!(ids, vec!["email.a.high", "web.a.high", "web.b.low"]);
231        assert!(report.complete);
232    }
233
234    #[test]
235    fn json_shape_is_the_contract() {
236        let mut output = checks::CheckOutput::default();
237        output.ran(
238            "web.headers",
239            Category::Web,
240            vec![Finding::new(
241                "web.hsts.missing",
242                Category::Web,
243                Severity::Medium,
244                "example.com",
245                "No HSTS header",
246            )],
247        );
248        output.incomplete("tls.caa", Category::Tls, "CAA lookup failed");
249        output.not_applicable("email.bimi", Category::Email, "domain receives no mail");
250        let report = AuditReport::assemble(
251            "example.com".into(),
252            "example.com".into(),
253            output,
254            Some(Letter::APlus),
255        );
256        let json = serde_json::to_value(&report).unwrap();
257
258        // `status` is kebab-case and `reason` is flattened beside it.
259        assert_eq!(
260            json["runs"],
261            serde_json::json!([
262                {"check": "web.headers", "category": "web", "status": "ran"},
263                {"check": "tls.caa", "category": "tls", "status": "incomplete",
264                 "reason": "CAA lookup failed"},
265                {"check": "email.bimi", "category": "email", "status": "not-applicable",
266                 "reason": "domain receives no mail"},
267            ])
268        );
269        let finding = &json["findings"][0];
270        assert_eq!(finding["id"], "web.hsts.missing");
271        assert_eq!(finding["category"], "web");
272        assert_eq!(finding["severity"], "medium");
273        assert_eq!(json["fail_under"], "A+");
274
275        for category in Category::ALL {
276            assert_eq!(serde_json::json!(category), category.as_str());
277        }
278        for severity in [
279            Severity::Info,
280            Severity::Low,
281            Severity::Medium,
282            Severity::High,
283            Severity::Critical,
284        ] {
285            assert_eq!(serde_json::json!(severity), severity.as_str());
286        }
287        assert_eq!(Category::ALL.iter().map(|c| c.weight()).sum::<u32>(), 100);
288    }
289
290    #[tokio::test]
291    #[ignore = "live network"]
292    async fn live_audit_of_a_real_domain() {
293        let report = Auditor::new()
294            .audit("example.com", AuditOptions::default())
295            .await
296            .unwrap();
297        assert_eq!(report.categories.len(), 6);
298        assert!(report.runs.len() >= 16, "{:?}", report.runs);
299        assert!(report.overall.is_some(), "{report:#?}");
300        eprintln!("{}", serde_json::to_string_pretty(&report).unwrap());
301    }
302}