pub(crate) mod checks;
pub mod finding;
pub mod score;
use std::time::Duration;
use serde::Serialize;
pub use finding::{Category, CheckRun, CheckStatus, Finding, Severity};
pub use score::{CategoryGrade, CategoryState, OverallGrade};
use crate::config::SeerConfig;
use crate::dns::{DelegationChecker, DnsResolver, DnssecChecker};
use crate::error::Result;
use crate::grade::Letter;
use crate::http::GuardedFetcher;
use crate::lookup::SmartLookup;
use crate::ssl::SslChecker;
use crate::validation::{normalize_domain, normalize_host};
#[derive(Debug, Clone, Copy, Default)]
pub struct AuditOptions {
pub fail_under: Option<Letter>,
}
#[derive(Debug, Clone, Serialize)]
pub struct AuditReport {
pub domain: String,
pub host: String,
pub overall: Option<OverallGrade>,
pub categories: Vec<CategoryGrade>,
pub findings: Vec<Finding>,
pub runs: Vec<CheckRun>,
pub complete: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub fail_under: Option<Letter>,
pub passed: bool,
}
impl AuditReport {
pub fn count(&self, severity: Severity) -> usize {
self.findings
.iter()
.filter(|f| f.severity == severity)
.count()
}
fn assemble(
domain: String,
host: String,
mut output: checks::CheckOutput,
fail_under: Option<Letter>,
) -> Self {
output.findings.sort_by(|a, b| {
b.severity
.cmp(&a.severity)
.then(a.category.cmp(&b.category))
.then(a.id.cmp(b.id))
.then(a.host.cmp(&b.host))
});
let categories = score::grade_categories(&output.runs, &output.findings);
let overall = score::overall(&categories, &output.findings);
let complete = !output
.runs
.iter()
.any(|r| matches!(r.status, CheckStatus::Incomplete { .. }));
let passed = score::passed(overall.as_ref(), &output.findings, fail_under);
Self {
domain,
host,
overall,
categories,
findings: output.findings,
runs: output.runs,
complete,
fail_under,
passed,
}
}
}
pub struct Auditor {
pub(crate) lookup: SmartLookup,
pub(crate) dns: DnsResolver,
pub(crate) ssl: SslChecker,
pub(crate) dnssec: DnssecChecker,
pub(crate) delegation: DelegationChecker,
pub(crate) http_timeout: Duration,
pub(crate) deadline: Duration,
}
impl Default for Auditor {
fn default() -> Self {
Self::new()
}
}
impl Auditor {
pub fn new() -> Self {
Self::from_config(&SeerConfig::default())
}
pub fn from_config(config: &SeerConfig) -> Self {
Self {
lookup: SmartLookup::from_config(config),
dns: DnsResolver::from_config(config),
ssl: SslChecker::from_config(config),
dnssec: DnssecChecker::from_config(config),
delegation: DelegationChecker::from_config(config),
http_timeout: config.http_timeout(),
deadline: config.audit_timeout(),
}
}
pub(crate) fn fetcher(&self) -> GuardedFetcher {
GuardedFetcher::new().with_timeout(self.http_timeout)
}
pub async fn audit(&self, input: &str, options: AuditOptions) -> Result<AuditReport> {
let domain = normalize_domain(input)?;
let host = normalize_host(input)?;
let output = {
let ctx = checks::AuditContext {
auditor: self,
domain: &domain,
host: &host,
};
checks::run_specs(checks::CHECKS, &ctx, self.deadline).await
};
Ok(AuditReport::assemble(
domain,
host,
output,
options.fail_under,
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn invalid_input_is_the_only_error() {
assert!(Auditor::new()
.audit("not a domain!!", AuditOptions::default())
.await
.is_err());
}
#[test]
fn offline_like_run_is_ungraded_and_fails_the_gate() {
let mut output = checks::CheckOutput::default();
for category in Category::ALL {
output.incomplete("x", category, "DNS resolution failed");
}
let report =
AuditReport::assemble("example.com".into(), "example.com".into(), output, None);
assert_eq!(report.overall, None);
assert!(!report.complete);
assert!(!report.passed);
}
#[test]
fn findings_sort_by_severity_then_category_then_id() {
let mut output = checks::CheckOutput::default();
output.ran(
"w",
Category::Web,
vec![
Finding::new("web.b.low", Category::Web, Severity::Low, "h", "t"),
Finding::new("web.a.high", Category::Web, Severity::High, "h", "t"),
],
);
output.ran(
"e",
Category::Email,
vec![Finding::new(
"email.a.high",
Category::Email,
Severity::High,
"h",
"t",
)],
);
let report = AuditReport::assemble("h".into(), "h".into(), output, None);
let ids: Vec<_> = report.findings.iter().map(|f| f.id).collect();
assert_eq!(ids, vec!["email.a.high", "web.a.high", "web.b.low"]);
assert!(report.complete);
}
#[test]
fn json_shape_is_the_contract() {
let mut output = checks::CheckOutput::default();
output.ran(
"web.headers",
Category::Web,
vec![Finding::new(
"web.hsts.missing",
Category::Web,
Severity::Medium,
"example.com",
"No HSTS header",
)],
);
output.incomplete("tls.caa", Category::Tls, "CAA lookup failed");
output.not_applicable("email.bimi", Category::Email, "domain receives no mail");
let report = AuditReport::assemble(
"example.com".into(),
"example.com".into(),
output,
Some(Letter::APlus),
);
let json = serde_json::to_value(&report).unwrap();
assert_eq!(
json["runs"],
serde_json::json!([
{"check": "web.headers", "category": "web", "status": "ran"},
{"check": "tls.caa", "category": "tls", "status": "incomplete",
"reason": "CAA lookup failed"},
{"check": "email.bimi", "category": "email", "status": "not-applicable",
"reason": "domain receives no mail"},
])
);
let finding = &json["findings"][0];
assert_eq!(finding["id"], "web.hsts.missing");
assert_eq!(finding["category"], "web");
assert_eq!(finding["severity"], "medium");
assert_eq!(json["fail_under"], "A+");
for category in Category::ALL {
assert_eq!(serde_json::json!(category), category.as_str());
}
for severity in [
Severity::Info,
Severity::Low,
Severity::Medium,
Severity::High,
Severity::Critical,
] {
assert_eq!(serde_json::json!(severity), severity.as_str());
}
assert_eq!(Category::ALL.iter().map(|c| c.weight()).sum::<u32>(), 100);
}
#[tokio::test]
#[ignore = "live network"]
async fn live_audit_of_a_real_domain() {
let report = Auditor::new()
.audit("example.com", AuditOptions::default())
.await
.unwrap();
assert_eq!(report.categories.len(), 6);
assert!(report.runs.len() >= 16, "{:?}", report.runs);
assert!(report.overall.is_some(), "{report:#?}");
eprintln!("{}", serde_json::to_string_pretty(&report).unwrap());
}
}