Skip to main content

seer_core/subdomains/
baseline.rs

1//! Subdomain baseline persistence and diffing.
2//!
3//! Stores per-domain snapshots of a CT-log subdomain enumeration under
4//! `~/.seer/subdomain_baselines.json` and diffs a fresh enumeration against
5//! the stored set, surfacing the change that matters for security monitoring:
6//! **newly appeared names**. A certificate issued for a subdomain you didn't
7//! create is a classic early indicator of phishing infrastructure or a
8//! compromised DNS zone.
9//!
10//! # Why only additions are "material"
11//!
12//! CT logs are append-mostly: certificates keep showing up in aggregator
13//! results long after they expire, so a name genuinely leaving the result set
14//! is rare. In practice a "removed" name usually means the aggregator
15//! truncated or flaked (crt.sh 429s, certspotter pagination limits), not that
16//! anything changed on the domain. Removals are therefore reported for
17//! visibility but do NOT count as a material change — only additions drive
18//! [`SubdomainBaselineDiff::has_new_names`], which callers (CLI/cron) use for
19//! a non-zero exit code, mirroring [`crate::drift::DriftReport::has_drift`].
20//!
21//! Persistence mirrors [`crate::history`]: owner-only permissions on Unix,
22//! atomic write-and-rename saves, corrupt files backed up to `.corrupt`
23//! instead of silently overwritten, and bounded growth (one baseline per
24//! domain, oldest-evicted domain cap).
25
26use std::collections::{BTreeMap, BTreeSet};
27use std::path::PathBuf;
28
29use chrono::{DateTime, Utc};
30use serde::{Deserialize, Serialize};
31
32use crate::error::{Result, SeerError};
33
34/// Maximum number of distinct domains retained. When exceeded, the domain
35/// with the oldest baseline is evicted (same bounded-growth rationale as
36/// `history.rs` — issue #59).
37const MAX_DOMAINS: usize = 1000;
38
39/// A stored subdomain baseline for one domain: the name set from a single
40/// enumeration run, plus when and from which CT source it was recorded.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct SubdomainBaseline {
43    /// When this baseline was recorded.
44    pub recorded_at: DateTime<Utc>,
45    /// Which CT source produced the enumeration (e.g. "crt.sh").
46    #[serde(default)]
47    pub source: String,
48    /// The recorded subdomain names (sorted, deduplicated).
49    #[serde(default)]
50    pub names: BTreeSet<String>,
51}
52
53/// On-disk store of subdomain baselines, keyed by (lowercased) domain.
54#[derive(Debug, Clone, Serialize, Deserialize, Default)]
55pub struct SubdomainBaselines {
56    /// One baseline per domain — recording replaces the previous baseline.
57    #[serde(default)]
58    pub domains: BTreeMap<String, SubdomainBaseline>,
59}
60
61/// A report of how a fresh enumeration differs from the stored baseline.
62///
63/// Produced by [`SubdomainBaselines::diff`]; rendered by the human/markdown
64/// formatters and serialized directly for JSON/YAML output.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct SubdomainBaselineDiff {
67    /// The domain compared.
68    pub domain: String,
69    /// When the baseline was recorded (`None` when no baseline exists).
70    pub baseline_recorded_at: Option<DateTime<Utc>>,
71    /// Names present now but absent from the baseline (the material change).
72    pub added: Vec<String>,
73    /// Names in the baseline but absent from the fresh enumeration.
74    /// Informational only — see the module docs for why removals don't count
75    /// as material.
76    pub removed: Vec<String>,
77    /// Number of names present in both the baseline and the fresh set.
78    pub unchanged_count: usize,
79    /// True when no baseline was stored for this domain (first run). The
80    /// `added`/`removed` lists are empty in that case so callers keying the
81    /// exit code off [`Self::has_new_names`] exit 0 on a first run.
82    pub baseline_missing: bool,
83}
84
85impl SubdomainBaselineDiff {
86    /// True when at least one new name appeared since the baseline. Callers
87    /// (CLI/cron) use this to drive a non-zero exit code. Removals never make
88    /// this true — see the module docs.
89    pub fn has_new_names(&self) -> bool {
90        !self.added.is_empty()
91    }
92
93    /// Diffs a fresh enumeration against an optional stored baseline.
94    ///
95    /// With no baseline, returns an empty diff flagged `baseline_missing` —
96    /// a first run has nothing to compare against and must not alert.
97    pub fn between(domain: &str, baseline: Option<&SubdomainBaseline>, fresh: &[String]) -> Self {
98        let Some(baseline) = baseline else {
99            return Self {
100                domain: domain.to_string(),
101                baseline_recorded_at: None,
102                added: Vec::new(),
103                removed: Vec::new(),
104                unchanged_count: 0,
105                baseline_missing: true,
106            };
107        };
108
109        let fresh_set: BTreeSet<String> = fresh.iter().map(|n| normalize_name(n)).collect();
110
111        let added: Vec<String> = fresh_set.difference(&baseline.names).cloned().collect();
112        let removed: Vec<String> = baseline.names.difference(&fresh_set).cloned().collect();
113        let unchanged_count = fresh_set.intersection(&baseline.names).count();
114
115        Self {
116            domain: domain.to_string(),
117            baseline_recorded_at: Some(baseline.recorded_at),
118            added,
119            removed,
120            unchanged_count,
121            baseline_missing: false,
122        }
123    }
124}
125
126/// Normalizes a subdomain name for set comparison. Enumeration output is
127/// already trimmed/lowercased; this keeps injected test data and any future
128/// callers on the same footing so case churn is never reported as a change.
129fn normalize_name(name: &str) -> String {
130    name.trim().to_lowercase()
131}
132
133impl SubdomainBaselines {
134    /// Returns the path to the baselines file
135    /// (`~/.seer/subdomain_baselines.json`).
136    pub fn path() -> Option<PathBuf> {
137        dirs::home_dir().map(|h| h.join(".seer").join("subdomain_baselines.json"))
138    }
139
140    /// Loads baselines from disk, returning an empty store on any failure.
141    ///
142    /// When the file exists but fails to parse, it is renamed to
143    /// `<path>.corrupt` (preserving the user's data for recovery) and a
144    /// warning is logged — matching the corrupt-file convention in
145    /// [`crate::history`].
146    pub fn load() -> Self {
147        let Some(path) = Self::path() else {
148            return Self::default();
149        };
150        Self::load_from_path(&path)
151    }
152
153    /// Like [`Self::load`] but reads from an explicit path. Split out so
154    /// tests can exercise corrupt-file handling without touching the real
155    /// `~/.seer/subdomain_baselines.json`.
156    pub(crate) fn load_from_path(path: &std::path::Path) -> Self {
157        if !path.exists() {
158            return Self::default();
159        }
160        match std::fs::read_to_string(path) {
161            Ok(content) => match serde_json::from_str::<SubdomainBaselines>(&content) {
162                Ok(b) => b,
163                Err(e) => {
164                    let backup = path.with_extension("corrupt");
165                    if let Err(rename_err) = std::fs::rename(path, &backup) {
166                        tracing::error!(
167                            path = %path.display(),
168                            error = %rename_err,
169                            "failed to back up corrupt subdomain baselines",
170                        );
171                    } else {
172                        tracing::warn!(
173                            path = %path.display(),
174                            backup = %backup.display(),
175                            error = %e,
176                            "subdomain baselines file corrupt; moved to backup",
177                        );
178                    }
179                    SubdomainBaselines::default()
180                }
181            },
182            Err(_) => Self::default(),
183        }
184    }
185
186    /// Persists baselines to `~/.seer/subdomain_baselines.json`.
187    ///
188    /// Same durability properties as [`crate::history::LookupHistory::save`]:
189    /// write to a per-PID sibling temp file, then `rename` over the target
190    /// (atomic on POSIX), with owner-only permissions applied before the
191    /// rename so the published file is never briefly world-readable. The
192    /// load → mutate → save cycle is last-writer-wins across processes, which
193    /// for a single-baseline-per-domain store means at worst one concurrent
194    /// recording is superseded — never corruption.
195    pub fn save(&self) -> Result<()> {
196        let path = Self::path()
197            .ok_or_else(|| SeerError::ConfigError("Cannot determine home directory".to_string()))?;
198        self.save_to_path(&path)
199    }
200
201    /// Like [`Self::save`] but writes to an explicit path (test seam — same
202    /// split as `load_from_path`).
203    pub(crate) fn save_to_path(&self, path: &std::path::Path) -> Result<()> {
204        let content = serde_json::to_string_pretty(self)
205            .map_err(|e| SeerError::ConfigError(e.to_string()))?;
206        crate::fsutil::write_atomic_owner_only(path, &content, "json")
207    }
208
209    /// Records `names` as the new baseline for `domain`, replacing any
210    /// previous baseline. Evicts the oldest-recorded domain when the store
211    /// exceeds [`MAX_DOMAINS`].
212    pub fn record(&mut self, domain: &str, names: &[String], source: &str) {
213        self.domains.insert(
214            domain.to_lowercase(),
215            SubdomainBaseline {
216                recorded_at: Utc::now(),
217                source: source.to_string(),
218                names: names.iter().map(|n| normalize_name(n)).collect(),
219            },
220        );
221        while self.domains.len() > MAX_DOMAINS {
222            let Some(victim) = self
223                .domains
224                .iter()
225                .min_by_key(|(_, b)| b.recorded_at)
226                .map(|(k, _)| k.clone())
227            else {
228                break;
229            };
230            self.domains.remove(&victim);
231        }
232    }
233
234    /// Returns the stored baseline for a domain, if any.
235    pub fn get(&self, domain: &str) -> Option<&SubdomainBaseline> {
236        self.domains.get(&domain.to_lowercase())
237    }
238
239    /// Diffs a fresh enumeration for `domain` against its stored baseline.
240    /// See [`SubdomainBaselineDiff::between`] for the no-baseline semantics.
241    pub fn diff(&self, domain: &str, fresh: &[String]) -> SubdomainBaselineDiff {
242        SubdomainBaselineDiff::between(domain, self.get(domain), fresh)
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    fn names(items: &[&str]) -> Vec<String> {
251        items.iter().map(|s| s.to_string()).collect()
252    }
253
254    #[test]
255    fn diff_reports_added_removed_and_unchanged() {
256        let mut store = SubdomainBaselines::default();
257        store.record(
258            "example.com",
259            &names(&["api.example.com", "mail.example.com", "old.example.com"]),
260            "crt.sh",
261        );
262        let report = store.diff(
263            "example.com",
264            &names(&["api.example.com", "mail.example.com", "new.example.com"]),
265        );
266
267        assert!(!report.baseline_missing);
268        assert!(report.baseline_recorded_at.is_some());
269        assert_eq!(report.added, vec!["new.example.com"]);
270        assert_eq!(report.removed, vec!["old.example.com"]);
271        assert_eq!(report.unchanged_count, 2);
272        assert!(report.has_new_names());
273    }
274
275    #[test]
276    fn removals_alone_are_not_material() {
277        // CT logs are append-mostly; a shrinking result set usually means
278        // source flakiness, so removals must not drive the exit code.
279        let mut store = SubdomainBaselines::default();
280        store.record(
281            "example.com",
282            &names(&["a.example.com", "b.example.com"]),
283            "crt.sh",
284        );
285        let report = store.diff("example.com", &names(&["a.example.com"]));
286        assert_eq!(report.removed, vec!["b.example.com"]);
287        assert!(!report.has_new_names(), "removals must be non-fatal");
288    }
289
290    #[test]
291    fn identical_sets_show_no_changes() {
292        let mut store = SubdomainBaselines::default();
293        store.record("example.com", &names(&["a.example.com"]), "crt.sh");
294        let report = store.diff("example.com", &names(&["a.example.com"]));
295        assert!(report.added.is_empty());
296        assert!(report.removed.is_empty());
297        assert_eq!(report.unchanged_count, 1);
298        assert!(!report.has_new_names());
299    }
300
301    #[test]
302    fn missing_baseline_yields_empty_non_material_diff() {
303        // First run: nothing to compare against — must not alert (exit 0).
304        let store = SubdomainBaselines::default();
305        let report = store.diff("example.com", &names(&["a.example.com"]));
306        assert!(report.baseline_missing);
307        assert!(report.baseline_recorded_at.is_none());
308        assert!(report.added.is_empty());
309        assert!(report.removed.is_empty());
310        assert_eq!(report.unchanged_count, 0);
311        assert!(!report.has_new_names());
312    }
313
314    #[test]
315    fn empty_fresh_set_reports_all_baseline_names_removed() {
316        let mut store = SubdomainBaselines::default();
317        store.record(
318            "example.com",
319            &names(&["a.example.com", "b.example.com"]),
320            "crt.sh",
321        );
322        let report = store.diff("example.com", &[]);
323        assert!(report.added.is_empty());
324        assert_eq!(report.removed, vec!["a.example.com", "b.example.com"]);
325        assert_eq!(report.unchanged_count, 0);
326        assert!(!report.has_new_names(), "a wiped result set must not alert");
327    }
328
329    #[test]
330    fn case_and_whitespace_churn_is_not_a_change() {
331        let mut store = SubdomainBaselines::default();
332        store.record("Example.COM", &names(&["API.example.com "]), "crt.sh");
333        let report = store.diff("example.com", &names(&["api.example.com"]));
334        assert!(report.added.is_empty(), "case churn: {:?}", report.added);
335        assert!(report.removed.is_empty());
336        assert_eq!(report.unchanged_count, 1);
337    }
338
339    #[test]
340    fn record_replaces_previous_baseline() {
341        let mut store = SubdomainBaselines::default();
342        store.record("example.com", &names(&["a.example.com"]), "crt.sh");
343        store.record("example.com", &names(&["b.example.com"]), "certspotter");
344        let baseline = store.get("example.com").expect("baseline");
345        assert_eq!(baseline.source, "certspotter");
346        assert!(baseline.names.contains("b.example.com"));
347        assert!(!baseline.names.contains("a.example.com"));
348    }
349
350    #[test]
351    fn domain_cap_evicts_oldest_baseline() {
352        let mut store = SubdomainBaselines::default();
353        for i in 0..(MAX_DOMAINS + 5) {
354            store.record(&format!("d{i}.example"), &names(&["x.d.example"]), "t");
355            // Backdate earlier entries so eviction order is deterministic.
356            if let Some(b) = store.domains.get_mut(&format!("d{i}.example")) {
357                b.recorded_at = Utc::now() + chrono::Duration::seconds(i as i64);
358            }
359        }
360        assert!(
361            store.domains.len() <= MAX_DOMAINS,
362            "distinct domains must be capped at {MAX_DOMAINS}, got {}",
363            store.domains.len()
364        );
365        assert!(
366            store.get("d0.example").is_none(),
367            "oldest baseline should be evicted first"
368        );
369    }
370
371    /// Creates a unique temp path for load/save round-trip tests. The parent
372    /// dir is created; the caller cleans up.
373    fn unique_temp_path(tag: &str) -> PathBuf {
374        let mut dir = std::env::temp_dir();
375        dir.push(format!(
376            "seer-subdomain-baseline-test-{}-{}",
377            tag,
378            std::process::id()
379        ));
380        let _ = std::fs::create_dir_all(&dir);
381        dir.push("subdomain_baselines.json");
382        dir
383    }
384
385    #[test]
386    fn record_save_load_round_trip() {
387        let path = unique_temp_path("roundtrip");
388        let _ = std::fs::remove_file(&path);
389
390        let mut store = SubdomainBaselines::default();
391        store.record(
392            "example.com",
393            &names(&["api.example.com", "mail.example.com"]),
394            "crt.sh",
395        );
396        store.save_to_path(&path).expect("save");
397
398        let loaded = SubdomainBaselines::load_from_path(&path);
399        let baseline = loaded.get("example.com").expect("baseline survives");
400        assert_eq!(baseline.source, "crt.sh");
401        assert_eq!(baseline.names.len(), 2);
402        assert!(baseline.names.contains("api.example.com"));
403
404        if let Some(parent) = path.parent() {
405            let _ = std::fs::remove_dir_all(parent);
406        }
407    }
408
409    #[cfg(unix)]
410    #[test]
411    fn save_applies_owner_only_permissions() {
412        use std::os::unix::fs::PermissionsExt;
413        let path = unique_temp_path("perms");
414        let _ = std::fs::remove_file(&path);
415
416        let mut store = SubdomainBaselines::default();
417        store.record("example.com", &names(&["a.example.com"]), "crt.sh");
418        store.save_to_path(&path).expect("save");
419
420        let mode = std::fs::metadata(&path).expect("metadata").permissions();
421        assert_eq!(
422            mode.mode() & 0o777,
423            0o600,
424            "baseline file must be owner-only"
425        );
426
427        if let Some(parent) = path.parent() {
428            let _ = std::fs::remove_dir_all(parent);
429        }
430    }
431
432    #[test]
433    fn load_from_path_backs_up_corrupt_file_and_returns_default() {
434        let path = unique_temp_path("corrupt");
435        let backup = path.with_extension("corrupt");
436        let _ = std::fs::remove_file(&path);
437        let _ = std::fs::remove_file(&backup);
438
439        std::fs::write(&path, b"{ not valid json ").expect("seed file");
440
441        let loaded = SubdomainBaselines::load_from_path(&path);
442        assert!(
443            loaded.domains.is_empty(),
444            "corrupt load must return default"
445        );
446        assert!(!path.exists(), "corrupt file should be renamed away");
447        assert!(
448            backup.exists(),
449            "backup .corrupt file should exist at {}",
450            backup.display()
451        );
452
453        let _ = std::fs::remove_file(&backup);
454        if let Some(parent) = path.parent() {
455            let _ = std::fs::remove_dir_all(parent);
456        }
457    }
458
459    #[test]
460    fn load_from_path_returns_default_when_missing() {
461        let path = unique_temp_path("missing");
462        let _ = std::fs::remove_file(&path);
463        let loaded = SubdomainBaselines::load_from_path(&path);
464        assert!(loaded.domains.is_empty());
465        if let Some(parent) = path.parent() {
466            let _ = std::fs::remove_dir_all(parent);
467        }
468    }
469
470    #[test]
471    fn baseline_json_missing_optional_fields_still_parses() {
472        // Forward-compat: `source`/`names` carry #[serde(default)] so an
473        // older or hand-edited file missing them still loads instead of
474        // being kicked to `.corrupt` (the history.rs raw_response lesson).
475        let json = r#"{"domains":{"example.com":{"recorded_at":"2026-06-01T00:00:00Z"}}}"#;
476        let store: SubdomainBaselines = serde_json::from_str(json).expect("parses");
477        let baseline = store.get("example.com").expect("baseline");
478        assert!(baseline.names.is_empty());
479        assert!(baseline.source.is_empty());
480    }
481}