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//! The same truncation cuts the other way when it is the *baseline* that was
21//! incomplete, so a baseline records whether it only ever saw truncated runs,
22//! and additions against such a baseline are listed but not material.
23//!
24//! Persistence mirrors [`crate::history`]: owner-only permissions on Unix,
25//! atomic write-and-rename saves, corrupt files backed up to `.corrupt`
26//! instead of silently overwritten, and bounded growth (one baseline per
27//! domain, oldest-evicted domain cap).
28
29use std::collections::{BTreeMap, BTreeSet};
30
31use chrono::{DateTime, Utc};
32use serde::{Deserialize, Serialize};
33
34use super::SubdomainResult;
35
36/// Maximum number of distinct domains retained. When exceeded, the domain
37/// with the oldest baseline is evicted (same bounded-growth rationale as
38/// `history.rs` — issue #59). The public docs of
39/// [`SubdomainBaselines::record`] state the value; keep them in step.
40const MAX_DOMAINS: usize = 1000;
41
42/// A stored subdomain baseline for one domain: the name set from a single
43/// enumeration run, plus when and from which CT source it was recorded.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct SubdomainBaseline {
46    /// When this baseline was recorded.
47    pub recorded_at: DateTime<Utc>,
48    /// Which CT source produced the enumeration (e.g. "crt.sh").
49    #[serde(default)]
50    pub source: String,
51    /// The recorded subdomain names (sorted, deduplicated).
52    #[serde(default)]
53    pub names: BTreeSet<String>,
54    /// True when every run merged into this baseline was truncated (see
55    /// [`SubdomainResult::truncated`]): the set is known to be incomplete,
56    /// so names a later complete run finds are not evidence of anything new.
57    /// Files written before this field existed load as complete.
58    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
59    pub truncated: bool,
60}
61
62/// On-disk store of subdomain baselines, keyed by (lowercased) domain.
63#[derive(Debug, Clone, Serialize, Deserialize, Default)]
64pub struct SubdomainBaselines {
65    /// One baseline per domain — recording merges into the previous one.
66    #[serde(default)]
67    pub domains: BTreeMap<String, SubdomainBaseline>,
68}
69
70/// A report of how a fresh enumeration differs from the stored baseline.
71///
72/// Produced by [`SubdomainBaselines::diff`]; rendered by the human/markdown
73/// formatters and serialized directly for JSON/YAML output.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct SubdomainBaselineDiff {
76    /// The domain compared.
77    pub domain: String,
78    /// When the baseline was recorded (`None` when no baseline exists).
79    pub baseline_recorded_at: Option<DateTime<Utc>>,
80    /// Names present now but absent from the baseline (the material change).
81    pub added: Vec<String>,
82    /// Names in the baseline but absent from the fresh enumeration.
83    /// Informational only — see the module docs for why removals don't count
84    /// as material.
85    pub removed: Vec<String>,
86    /// Number of names present in both the baseline and the fresh set.
87    pub unchanged_count: usize,
88    /// True when no baseline was stored for this domain (first run). The
89    /// `added`/`removed` lists are empty in that case so callers keying the
90    /// exit code off [`Self::has_new_names`] exit 0 on a first run.
91    pub baseline_missing: bool,
92    /// True when the baseline was recorded only from truncated enumerations.
93    /// `added` is still listed, but those names may simply be ones the
94    /// truncated run never saw, so they are not material until a complete
95    /// run has been recorded.
96    #[serde(default)]
97    pub baseline_truncated: bool,
98}
99
100impl SubdomainBaselineDiff {
101    /// True when at least one new name appeared since a complete baseline.
102    /// Callers (CLI/cron) use this to drive a non-zero exit code. Removals
103    /// never make this true — see the module docs — and neither do names
104    /// "added" relative to a truncated baseline (a certspotter fallback that
105    /// hit its page cap), which the next complete run would otherwise report
106    /// wholesale as new.
107    pub fn has_new_names(&self) -> bool {
108        !self.added.is_empty() && !self.baseline_truncated
109    }
110
111    /// Diffs a fresh enumeration against an optional stored baseline.
112    ///
113    /// With no baseline, returns an empty diff flagged `baseline_missing` —
114    /// a first run has nothing to compare against and must not alert.
115    pub fn between(domain: &str, baseline: Option<&SubdomainBaseline>, fresh: &[String]) -> Self {
116        let Some(baseline) = baseline else {
117            return Self {
118                domain: domain.to_string(),
119                baseline_recorded_at: None,
120                added: Vec::new(),
121                removed: Vec::new(),
122                unchanged_count: 0,
123                baseline_missing: true,
124                baseline_truncated: false,
125            };
126        };
127
128        let fresh_set: BTreeSet<String> = fresh.iter().map(|n| normalize_name(n)).collect();
129
130        let added: Vec<String> = fresh_set.difference(&baseline.names).cloned().collect();
131        let removed: Vec<String> = baseline.names.difference(&fresh_set).cloned().collect();
132        let unchanged_count = fresh_set.intersection(&baseline.names).count();
133
134        Self {
135            domain: domain.to_string(),
136            baseline_recorded_at: Some(baseline.recorded_at),
137            added,
138            removed,
139            unchanged_count,
140            baseline_missing: false,
141            baseline_truncated: baseline.truncated,
142        }
143    }
144}
145
146/// Normalizes a subdomain name for set comparison. Enumeration output is
147/// already trimmed/lowercased; this keeps injected test data and any future
148/// callers on the same footing so case churn is never reported as a change.
149fn normalize_name(name: &str) -> String {
150    name.trim().to_lowercase()
151}
152
153crate::fsutil::persisted_store!(
154    SubdomainBaselines,
155    "subdomain_baselines.json",
156    json,
157    "subdomain baselines"
158);
159
160impl SubdomainBaselines {
161    /// Records an enumeration into the baseline for its domain, merging its
162    /// names with any previously recorded ones. Evicts the oldest-recorded
163    /// domain when the store exceeds 1000 domains.
164    ///
165    /// Merging rather than replacing follows from the module's premise: a
166    /// name missing from a run is almost always aggregator truncation, not a
167    /// real removal. Replacing would let one flaky run (crt.sh throttled, a
168    /// capped certspotter fallback) shrink the baseline, and every
169    /// long-standing name it dropped would then resurface as "new" — a false
170    /// alert — on the next healthy run. The merged baseline is truncated only
171    /// while every run merged into it was: one complete run completes it.
172    pub fn record(&mut self, result: &SubdomainResult) {
173        let key = result.domain.to_lowercase();
174        let previous = self.domains.remove(&key);
175        let truncated = result.truncated && previous.as_ref().is_none_or(|p| p.truncated);
176        let mut merged: BTreeSet<String> = previous.map(|p| p.names).unwrap_or_default();
177        merged.extend(result.subdomains.iter().map(|n| normalize_name(n)));
178        self.domains.insert(
179            key,
180            SubdomainBaseline {
181                recorded_at: Utc::now(),
182                source: result.source.clone(),
183                names: merged,
184                truncated,
185            },
186        );
187        while self.domains.len() > MAX_DOMAINS {
188            let Some(victim) = self
189                .domains
190                .iter()
191                .min_by_key(|(_, b)| b.recorded_at)
192                .map(|(k, _)| k.clone())
193            else {
194                break;
195            };
196            self.domains.remove(&victim);
197        }
198    }
199
200    /// Returns the stored baseline for a domain, if any.
201    pub fn get(&self, domain: &str) -> Option<&SubdomainBaseline> {
202        self.domains.get(&domain.to_lowercase())
203    }
204
205    /// Diffs a fresh enumeration for `domain` against its stored baseline.
206    /// See [`SubdomainBaselineDiff::between`] for the no-baseline semantics.
207    pub fn diff(&self, domain: &str, fresh: &[String]) -> SubdomainBaselineDiff {
208        SubdomainBaselineDiff::between(domain, self.get(domain), fresh)
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use std::path::PathBuf;
216
217    fn names(items: &[&str]) -> Vec<String> {
218        items.iter().map(|s| s.to_string()).collect()
219    }
220
221    fn run(domain: &str, names: &[String], source: &str, truncated: bool) -> SubdomainResult {
222        SubdomainResult {
223            domain: domain.to_string(),
224            subdomains: names.to_vec(),
225            source: source.to_string(),
226            count: names.len(),
227            truncated,
228        }
229    }
230
231    /// Records a complete run.
232    fn record(store: &mut SubdomainBaselines, domain: &str, names: &[String], source: &str) {
233        store.record(&run(domain, names, source, false));
234    }
235
236    /// Regression: a first baseline recorded from a truncated certspotter
237    /// fallback made the next full crt.sh run report every name it had
238    /// missed as "added" (exit 1).
239    #[test]
240    fn names_added_to_a_truncated_baseline_are_not_material() {
241        let mut store = SubdomainBaselines::default();
242        store.record(&run(
243            "example.com",
244            &names(&["a.example.com"]),
245            "certspotter",
246            true,
247        ));
248        assert!(store.get("example.com").expect("baseline").truncated);
249
250        let full = names(&["a.example.com", "b.example.com", "c.example.com"]);
251        let report = store.diff("example.com", &full);
252        assert_eq!(report.added, vec!["b.example.com", "c.example.com"]);
253        assert!(report.baseline_truncated);
254        assert!(!report.has_new_names(), "{report:?}");
255
256        // Recording a complete run completes the baseline: real alerts resume.
257        record(&mut store, "example.com", &full, "crt.sh");
258        assert!(!store.get("example.com").expect("baseline").truncated);
259        let report = store.diff(
260            "example.com",
261            &names(&[
262                "a.example.com",
263                "b.example.com",
264                "c.example.com",
265                "d.example.com",
266            ]),
267        );
268        assert!(!report.baseline_truncated);
269        assert!(report.has_new_names());
270
271        // A truncated run merged into a complete baseline keeps it complete.
272        store.record(&run(
273            "example.com",
274            &names(&["a.example.com"]),
275            "certspotter",
276            true,
277        ));
278        assert!(!store.get("example.com").expect("baseline").truncated);
279    }
280
281    #[test]
282    fn baselines_without_the_truncated_field_load_as_complete() {
283        let json = r#"{"domains":{"example.com":{"recorded_at":"2026-06-01T00:00:00Z","source":"crt.sh","names":["a.example.com"]}}}"#;
284        let store: SubdomainBaselines = serde_json::from_str(json).expect("parses");
285        assert!(!store.get("example.com").expect("baseline").truncated);
286        let report = store.diff("example.com", &names(&["a.example.com", "b.example.com"]));
287        assert!(report.has_new_names());
288    }
289
290    #[test]
291    fn diff_reports_added_removed_and_unchanged() {
292        let mut store = SubdomainBaselines::default();
293        record(
294            &mut store,
295            "example.com",
296            &names(&["api.example.com", "mail.example.com", "old.example.com"]),
297            "crt.sh",
298        );
299        let report = store.diff(
300            "example.com",
301            &names(&["api.example.com", "mail.example.com", "new.example.com"]),
302        );
303
304        assert!(!report.baseline_missing);
305        assert!(report.baseline_recorded_at.is_some());
306        assert_eq!(report.added, vec!["new.example.com"]);
307        assert_eq!(report.removed, vec!["old.example.com"]);
308        assert_eq!(report.unchanged_count, 2);
309        assert!(report.has_new_names());
310    }
311
312    #[test]
313    fn removals_alone_are_not_material() {
314        // CT logs are append-mostly; a shrinking result set usually means
315        // source flakiness, so removals must not drive the exit code.
316        let mut store = SubdomainBaselines::default();
317        record(
318            &mut store,
319            "example.com",
320            &names(&["a.example.com", "b.example.com"]),
321            "crt.sh",
322        );
323        let report = store.diff("example.com", &names(&["a.example.com"]));
324        assert_eq!(report.removed, vec!["b.example.com"]);
325        assert!(!report.has_new_names(), "removals must be non-fatal");
326    }
327
328    #[test]
329    fn identical_sets_show_no_changes() {
330        let mut store = SubdomainBaselines::default();
331        record(
332            &mut store,
333            "example.com",
334            &names(&["a.example.com"]),
335            "crt.sh",
336        );
337        let report = store.diff("example.com", &names(&["a.example.com"]));
338        assert!(report.added.is_empty());
339        assert!(report.removed.is_empty());
340        assert_eq!(report.unchanged_count, 1);
341        assert!(!report.has_new_names());
342    }
343
344    #[test]
345    fn missing_baseline_yields_empty_non_material_diff() {
346        // First run: nothing to compare against — must not alert (exit 0).
347        let store = SubdomainBaselines::default();
348        let report = store.diff("example.com", &names(&["a.example.com"]));
349        assert!(report.baseline_missing);
350        assert!(report.baseline_recorded_at.is_none());
351        assert!(report.added.is_empty());
352        assert!(report.removed.is_empty());
353        assert_eq!(report.unchanged_count, 0);
354        assert!(!report.has_new_names());
355    }
356
357    #[test]
358    fn empty_fresh_set_reports_all_baseline_names_removed() {
359        let mut store = SubdomainBaselines::default();
360        record(
361            &mut store,
362            "example.com",
363            &names(&["a.example.com", "b.example.com"]),
364            "crt.sh",
365        );
366        let report = store.diff("example.com", &[]);
367        assert!(report.added.is_empty());
368        assert_eq!(report.removed, vec!["a.example.com", "b.example.com"]);
369        assert_eq!(report.unchanged_count, 0);
370        assert!(!report.has_new_names(), "a wiped result set must not alert");
371    }
372
373    #[test]
374    fn case_and_whitespace_churn_is_not_a_change() {
375        let mut store = SubdomainBaselines::default();
376        record(
377            &mut store,
378            "Example.COM",
379            &names(&["API.example.com "]),
380            "crt.sh",
381        );
382        let report = store.diff("example.com", &names(&["api.example.com"]));
383        assert!(report.added.is_empty(), "case churn: {:?}", report.added);
384        assert!(report.removed.is_empty());
385        assert_eq!(report.unchanged_count, 1);
386    }
387
388    #[test]
389    fn record_merges_into_previous_baseline() {
390        let mut store = SubdomainBaselines::default();
391        record(
392            &mut store,
393            "example.com",
394            &names(&["a.example.com", "b.example.com"]),
395            "crt.sh",
396        );
397        // A truncated fallback run must not shrink the baseline...
398        record(
399            &mut store,
400            "example.com",
401            &names(&["b.example.com"]),
402            "certspotter",
403        );
404        let baseline = store.get("example.com").expect("baseline");
405        assert_eq!(baseline.source, "certspotter");
406        assert!(baseline.names.contains("a.example.com"));
407        assert!(baseline.names.contains("b.example.com"));
408        // ...or the next healthy run would re-report `a` as a new name.
409        let diff = store.diff("example.com", &names(&["a.example.com", "b.example.com"]));
410        assert!(!diff.has_new_names(), "{diff:?}");
411    }
412
413    #[test]
414    fn domain_cap_evicts_oldest_baseline() {
415        let mut store = SubdomainBaselines::default();
416        for i in 0..(MAX_DOMAINS + 5) {
417            record(
418                &mut store,
419                &format!("d{i}.example"),
420                &names(&["x.d.example"]),
421                "t",
422            );
423            // Backdate earlier entries so eviction order is deterministic.
424            if let Some(b) = store.domains.get_mut(&format!("d{i}.example")) {
425                b.recorded_at = Utc::now() + chrono::Duration::seconds(i as i64);
426            }
427        }
428        assert!(
429            store.domains.len() <= MAX_DOMAINS,
430            "distinct domains must be capped at {MAX_DOMAINS}, got {}",
431            store.domains.len()
432        );
433        assert!(
434            store.get("d0.example").is_none(),
435            "oldest baseline should be evicted first"
436        );
437    }
438
439    /// Creates a unique temp path for load/save round-trip tests. The parent
440    /// dir is created; the caller cleans up.
441    fn unique_temp_path(tag: &str) -> PathBuf {
442        let mut dir = std::env::temp_dir();
443        dir.push(format!(
444            "seer-subdomain-baseline-test-{}-{}",
445            tag,
446            std::process::id()
447        ));
448        let _ = std::fs::create_dir_all(&dir);
449        dir.push("subdomain_baselines.json");
450        dir
451    }
452
453    #[test]
454    fn record_save_load_round_trip() {
455        let path = unique_temp_path("roundtrip");
456        let _ = std::fs::remove_file(&path);
457
458        let mut store = SubdomainBaselines::default();
459        record(
460            &mut store,
461            "example.com",
462            &names(&["api.example.com", "mail.example.com"]),
463            "crt.sh",
464        );
465        store.save_to_path(&path).expect("save");
466
467        let loaded = SubdomainBaselines::load_from_path(&path).expect("load");
468        let baseline = loaded.get("example.com").expect("baseline survives");
469        assert_eq!(baseline.source, "crt.sh");
470        assert_eq!(baseline.names.len(), 2);
471        assert!(baseline.names.contains("api.example.com"));
472
473        if let Some(parent) = path.parent() {
474            let _ = std::fs::remove_dir_all(parent);
475        }
476    }
477
478    #[cfg(unix)]
479    #[test]
480    fn save_applies_owner_only_permissions() {
481        use std::os::unix::fs::PermissionsExt;
482        let path = unique_temp_path("perms");
483        let _ = std::fs::remove_file(&path);
484
485        let mut store = SubdomainBaselines::default();
486        record(
487            &mut store,
488            "example.com",
489            &names(&["a.example.com"]),
490            "crt.sh",
491        );
492        store.save_to_path(&path).expect("save");
493
494        let mode = std::fs::metadata(&path).expect("metadata").permissions();
495        assert_eq!(
496            mode.mode() & 0o777,
497            0o600,
498            "baseline file must be owner-only"
499        );
500
501        if let Some(parent) = path.parent() {
502            let _ = std::fs::remove_dir_all(parent);
503        }
504    }
505
506    #[test]
507    fn load_from_path_backs_up_corrupt_file_and_returns_default() {
508        let path = unique_temp_path("corrupt");
509        let backup = path.with_extension("corrupt");
510        let _ = std::fs::remove_file(&path);
511        let _ = std::fs::remove_file(&backup);
512
513        std::fs::write(&path, b"{ not valid json ").expect("seed file");
514
515        let loaded = SubdomainBaselines::load_from_path(&path).expect("load");
516        assert!(
517            loaded.domains.is_empty(),
518            "corrupt load must return default"
519        );
520        assert!(!path.exists(), "corrupt file should be renamed away");
521        assert!(
522            backup.exists(),
523            "backup .corrupt file should exist at {}",
524            backup.display()
525        );
526
527        let _ = std::fs::remove_file(&backup);
528        if let Some(parent) = path.parent() {
529            let _ = std::fs::remove_dir_all(parent);
530        }
531    }
532
533    #[test]
534    fn load_from_path_returns_default_when_missing() {
535        let path = unique_temp_path("missing");
536        let _ = std::fs::remove_file(&path);
537        let loaded = SubdomainBaselines::load_from_path(&path).expect("load");
538        assert!(loaded.domains.is_empty());
539        if let Some(parent) = path.parent() {
540            let _ = std::fs::remove_dir_all(parent);
541        }
542    }
543
544    #[test]
545    fn baseline_json_missing_optional_fields_still_parses() {
546        // Forward-compat: `source`/`names` carry #[serde(default)] so an
547        // older or hand-edited file missing them still loads instead of
548        // being kicked to `.corrupt` (the history.rs raw_response lesson).
549        let json = r#"{"domains":{"example.com":{"recorded_at":"2026-06-01T00:00:00Z"}}}"#;
550        let store: SubdomainBaselines = serde_json::from_str(json).expect("parses");
551        let baseline = store.get("example.com").expect("baseline");
552        assert!(baseline.names.is_empty());
553        assert!(baseline.source.is_empty());
554    }
555}