Skip to main content

seer_core/audit/
finding.rs

1//! The audit's unit of output: one problem, where it is, how bad, how to fix.
2//!
3//! A passing check produces no finding — only a [`CheckRun`] saying it ran —
4//! so the scorer can tell "passed" from "never ran". `id` is a stable
5//! contract (`<category>.<check>.<condition>`): drift and suppression key on
6//! it, so renaming one is a breaking change.
7
8use serde::Serialize;
9
10/// The layer a finding belongs to. Declaration order is display order.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
12#[serde(rename_all = "lowercase")]
13pub enum Category {
14    Email,
15    Web,
16    Tls,
17    Dns,
18    Takeover,
19    Registration,
20}
21
22impl Category {
23    /// Every category, in display order.
24    pub const ALL: [Category; 6] = [
25        Category::Email,
26        Category::Web,
27        Category::Tls,
28        Category::Dns,
29        Category::Takeover,
30        Category::Registration,
31    ];
32
33    /// Share of the overall score (the weights sum to 100).
34    pub fn weight(self) -> u32 {
35        match self {
36            Category::Email | Category::Web | Category::Tls => 20,
37            Category::Dns | Category::Takeover => 15,
38            Category::Registration => 10,
39        }
40    }
41
42    /// The serde name.
43    pub fn as_str(self) -> &'static str {
44        match self {
45            Category::Email => "email",
46            Category::Web => "web",
47            Category::Tls => "tls",
48            Category::Dns => "dns",
49            Category::Takeover => "takeover",
50            Category::Registration => "registration",
51        }
52    }
53
54    /// Display label.
55    pub fn label(self) -> &'static str {
56        match self {
57            Category::Email => "Email",
58            Category::Web => "Web",
59            Category::Tls => "TLS",
60            Category::Dns => "DNS",
61            Category::Takeover => "Takeover",
62            Category::Registration => "Registration",
63        }
64    }
65}
66
67/// How bad a finding is. Declared least to most severe, so `Ord` ranks
68/// `Critical` highest.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
70#[serde(rename_all = "lowercase")]
71pub enum Severity {
72    Info,
73    Low,
74    Medium,
75    High,
76    Critical,
77}
78
79impl Severity {
80    /// Points this finding takes off its category's 100.
81    pub fn deduction(self) -> u32 {
82        match self {
83            Severity::Info => 0,
84            Severity::Low => 3,
85            Severity::Medium => 10,
86            Severity::High => 25,
87            Severity::Critical => 60,
88        }
89    }
90
91    /// The serde name.
92    pub fn as_str(self) -> &'static str {
93        match self {
94            Severity::Info => "info",
95            Severity::Low => "low",
96            Severity::Medium => "medium",
97            Severity::High => "high",
98            Severity::Critical => "critical",
99        }
100    }
101}
102
103/// One problem found by the audit.
104#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
105pub struct Finding {
106    /// Stable slug, `<category>.<check>.<condition>`.
107    pub id: &'static str,
108    pub category: Category,
109    pub severity: Severity,
110    /// The host or domain it applies to.
111    pub host: String,
112    /// Seer's own wording.
113    pub title: String,
114    /// What was observed — may be remote text; sanitize before display.
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub evidence: Option<String>,
117    /// How to fix it (Seer's own wording).
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub remediation: Option<String>,
120}
121
122impl Finding {
123    pub(crate) fn new(
124        id: &'static str,
125        category: Category,
126        severity: Severity,
127        host: &str,
128        title: impl Into<String>,
129    ) -> Self {
130        Self {
131            id,
132            category,
133            severity,
134            host: host.to_string(),
135            title: title.into(),
136            evidence: None,
137            remediation: None,
138        }
139    }
140
141    pub(crate) fn evidence(mut self, evidence: impl Into<String>) -> Self {
142        self.evidence = Some(evidence.into());
143        self
144    }
145
146    pub(crate) fn fix(mut self, remediation: impl Into<String>) -> Self {
147        self.remediation = Some(remediation.into());
148        self
149    }
150}
151
152/// Whether a check produced a result.
153#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
154#[serde(tag = "status", rename_all = "kebab-case")]
155pub enum CheckStatus {
156    /// The check ran; its findings (if any) are in the report.
157    Ran,
158    /// The check could not finish (lookup failed, deadline) — unknown, not a fail.
159    Incomplete { reason: String },
160    /// The check does not apply (e.g. no MX, so no MTA-STS).
161    NotApplicable { reason: String },
162}
163
164/// One check's outcome.
165#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
166pub struct CheckRun {
167    pub check: &'static str,
168    pub category: Category,
169    #[serde(flatten)]
170    pub status: CheckStatus,
171}