1pub(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#[derive(Debug, Clone, Copy, Default)]
42pub struct AuditOptions {
43 pub fail_under: Option<Letter>,
46}
47
48#[derive(Debug, Clone, Serialize)]
50pub struct AuditReport {
51 pub domain: String,
53 pub host: String,
55 pub overall: Option<OverallGrade>,
57 pub categories: Vec<CategoryGrade>,
59 pub findings: Vec<Finding>,
61 pub runs: Vec<CheckRun>,
63 pub complete: bool,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 pub fail_under: Option<Letter>,
67 pub passed: bool,
69}
70
71impl AuditReport {
72 pub fn count(&self, severity: Severity) -> usize {
74 self.findings
75 .iter()
76 .filter(|f| f.severity == severity)
77 .count()
78 }
79
80 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
115pub 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 pub fn new() -> Self {
135 Self::from_config(&SeerConfig::default())
136 }
137
138 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 pub(crate) fn fetcher(&self) -> GuardedFetcher {
153 GuardedFetcher::new().with_timeout(self.http_timeout)
154 }
155
156 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 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}