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        if let Some(parent) = path.parent() {
205            std::fs::create_dir_all(parent).map_err(|e| SeerError::ConfigError(e.to_string()))?;
206            // Subdomain inventories are sensitive reconnaissance metadata;
207            // keep the data dir owner-only on Unix (best-effort defense in
208            // depth, matching history.rs).
209            #[cfg(unix)]
210            {
211                use std::os::unix::fs::PermissionsExt;
212                let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
213            }
214        }
215        let content = serde_json::to_string_pretty(self)
216            .map_err(|e| SeerError::ConfigError(e.to_string()))?;
217        let tmp_path = path.with_extension(format!("json.{}.tmp", std::process::id()));
218        std::fs::write(&tmp_path, content).map_err(|e| SeerError::ConfigError(e.to_string()))?;
219        #[cfg(unix)]
220        {
221            use std::os::unix::fs::PermissionsExt;
222            let _ = std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o600));
223        }
224        std::fs::rename(&tmp_path, path).map_err(|e| {
225            // Best-effort temp cleanup; surface the original rename error.
226            let _ = std::fs::remove_file(&tmp_path);
227            SeerError::ConfigError(e.to_string())
228        })?;
229        Ok(())
230    }
231
232    /// Records `names` as the new baseline for `domain`, replacing any
233    /// previous baseline. Evicts the oldest-recorded domain when the store
234    /// exceeds [`MAX_DOMAINS`].
235    pub fn record(&mut self, domain: &str, names: &[String], source: &str) {
236        self.domains.insert(
237            domain.to_lowercase(),
238            SubdomainBaseline {
239                recorded_at: Utc::now(),
240                source: source.to_string(),
241                names: names.iter().map(|n| normalize_name(n)).collect(),
242            },
243        );
244        while self.domains.len() > MAX_DOMAINS {
245            let Some(victim) = self
246                .domains
247                .iter()
248                .min_by_key(|(_, b)| b.recorded_at)
249                .map(|(k, _)| k.clone())
250            else {
251                break;
252            };
253            self.domains.remove(&victim);
254        }
255    }
256
257    /// Returns the stored baseline for a domain, if any.
258    pub fn get(&self, domain: &str) -> Option<&SubdomainBaseline> {
259        self.domains.get(&domain.to_lowercase())
260    }
261
262    /// Diffs a fresh enumeration for `domain` against its stored baseline.
263    /// See [`SubdomainBaselineDiff::between`] for the no-baseline semantics.
264    pub fn diff(&self, domain: &str, fresh: &[String]) -> SubdomainBaselineDiff {
265        SubdomainBaselineDiff::between(domain, self.get(domain), fresh)
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    fn names(items: &[&str]) -> Vec<String> {
274        items.iter().map(|s| s.to_string()).collect()
275    }
276
277    #[test]
278    fn diff_reports_added_removed_and_unchanged() {
279        let mut store = SubdomainBaselines::default();
280        store.record(
281            "example.com",
282            &names(&["api.example.com", "mail.example.com", "old.example.com"]),
283            "crt.sh",
284        );
285        let report = store.diff(
286            "example.com",
287            &names(&["api.example.com", "mail.example.com", "new.example.com"]),
288        );
289
290        assert!(!report.baseline_missing);
291        assert!(report.baseline_recorded_at.is_some());
292        assert_eq!(report.added, vec!["new.example.com"]);
293        assert_eq!(report.removed, vec!["old.example.com"]);
294        assert_eq!(report.unchanged_count, 2);
295        assert!(report.has_new_names());
296    }
297
298    #[test]
299    fn removals_alone_are_not_material() {
300        // CT logs are append-mostly; a shrinking result set usually means
301        // source flakiness, so removals must not drive the exit code.
302        let mut store = SubdomainBaselines::default();
303        store.record(
304            "example.com",
305            &names(&["a.example.com", "b.example.com"]),
306            "crt.sh",
307        );
308        let report = store.diff("example.com", &names(&["a.example.com"]));
309        assert_eq!(report.removed, vec!["b.example.com"]);
310        assert!(!report.has_new_names(), "removals must be non-fatal");
311    }
312
313    #[test]
314    fn identical_sets_show_no_changes() {
315        let mut store = SubdomainBaselines::default();
316        store.record("example.com", &names(&["a.example.com"]), "crt.sh");
317        let report = store.diff("example.com", &names(&["a.example.com"]));
318        assert!(report.added.is_empty());
319        assert!(report.removed.is_empty());
320        assert_eq!(report.unchanged_count, 1);
321        assert!(!report.has_new_names());
322    }
323
324    #[test]
325    fn missing_baseline_yields_empty_non_material_diff() {
326        // First run: nothing to compare against — must not alert (exit 0).
327        let store = SubdomainBaselines::default();
328        let report = store.diff("example.com", &names(&["a.example.com"]));
329        assert!(report.baseline_missing);
330        assert!(report.baseline_recorded_at.is_none());
331        assert!(report.added.is_empty());
332        assert!(report.removed.is_empty());
333        assert_eq!(report.unchanged_count, 0);
334        assert!(!report.has_new_names());
335    }
336
337    #[test]
338    fn empty_fresh_set_reports_all_baseline_names_removed() {
339        let mut store = SubdomainBaselines::default();
340        store.record(
341            "example.com",
342            &names(&["a.example.com", "b.example.com"]),
343            "crt.sh",
344        );
345        let report = store.diff("example.com", &[]);
346        assert!(report.added.is_empty());
347        assert_eq!(report.removed, vec!["a.example.com", "b.example.com"]);
348        assert_eq!(report.unchanged_count, 0);
349        assert!(!report.has_new_names(), "a wiped result set must not alert");
350    }
351
352    #[test]
353    fn case_and_whitespace_churn_is_not_a_change() {
354        let mut store = SubdomainBaselines::default();
355        store.record("Example.COM", &names(&["API.example.com "]), "crt.sh");
356        let report = store.diff("example.com", &names(&["api.example.com"]));
357        assert!(report.added.is_empty(), "case churn: {:?}", report.added);
358        assert!(report.removed.is_empty());
359        assert_eq!(report.unchanged_count, 1);
360    }
361
362    #[test]
363    fn record_replaces_previous_baseline() {
364        let mut store = SubdomainBaselines::default();
365        store.record("example.com", &names(&["a.example.com"]), "crt.sh");
366        store.record("example.com", &names(&["b.example.com"]), "certspotter");
367        let baseline = store.get("example.com").expect("baseline");
368        assert_eq!(baseline.source, "certspotter");
369        assert!(baseline.names.contains("b.example.com"));
370        assert!(!baseline.names.contains("a.example.com"));
371    }
372
373    #[test]
374    fn domain_cap_evicts_oldest_baseline() {
375        let mut store = SubdomainBaselines::default();
376        for i in 0..(MAX_DOMAINS + 5) {
377            store.record(&format!("d{i}.example"), &names(&["x.d.example"]), "t");
378            // Backdate earlier entries so eviction order is deterministic.
379            if let Some(b) = store.domains.get_mut(&format!("d{i}.example")) {
380                b.recorded_at = Utc::now() + chrono::Duration::seconds(i as i64);
381            }
382        }
383        assert!(
384            store.domains.len() <= MAX_DOMAINS,
385            "distinct domains must be capped at {MAX_DOMAINS}, got {}",
386            store.domains.len()
387        );
388        assert!(
389            store.get("d0.example").is_none(),
390            "oldest baseline should be evicted first"
391        );
392    }
393
394    /// Creates a unique temp path for load/save round-trip tests. The parent
395    /// dir is created; the caller cleans up.
396    fn unique_temp_path(tag: &str) -> PathBuf {
397        let mut dir = std::env::temp_dir();
398        dir.push(format!(
399            "seer-subdomain-baseline-test-{}-{}",
400            tag,
401            std::process::id()
402        ));
403        let _ = std::fs::create_dir_all(&dir);
404        dir.push("subdomain_baselines.json");
405        dir
406    }
407
408    #[test]
409    fn record_save_load_round_trip() {
410        let path = unique_temp_path("roundtrip");
411        let _ = std::fs::remove_file(&path);
412
413        let mut store = SubdomainBaselines::default();
414        store.record(
415            "example.com",
416            &names(&["api.example.com", "mail.example.com"]),
417            "crt.sh",
418        );
419        store.save_to_path(&path).expect("save");
420
421        let loaded = SubdomainBaselines::load_from_path(&path);
422        let baseline = loaded.get("example.com").expect("baseline survives");
423        assert_eq!(baseline.source, "crt.sh");
424        assert_eq!(baseline.names.len(), 2);
425        assert!(baseline.names.contains("api.example.com"));
426
427        if let Some(parent) = path.parent() {
428            let _ = std::fs::remove_dir_all(parent);
429        }
430    }
431
432    #[cfg(unix)]
433    #[test]
434    fn save_applies_owner_only_permissions() {
435        use std::os::unix::fs::PermissionsExt;
436        let path = unique_temp_path("perms");
437        let _ = std::fs::remove_file(&path);
438
439        let mut store = SubdomainBaselines::default();
440        store.record("example.com", &names(&["a.example.com"]), "crt.sh");
441        store.save_to_path(&path).expect("save");
442
443        let mode = std::fs::metadata(&path).expect("metadata").permissions();
444        assert_eq!(
445            mode.mode() & 0o777,
446            0o600,
447            "baseline file must be owner-only"
448        );
449
450        if let Some(parent) = path.parent() {
451            let _ = std::fs::remove_dir_all(parent);
452        }
453    }
454
455    #[test]
456    fn load_from_path_backs_up_corrupt_file_and_returns_default() {
457        let path = unique_temp_path("corrupt");
458        let backup = path.with_extension("corrupt");
459        let _ = std::fs::remove_file(&path);
460        let _ = std::fs::remove_file(&backup);
461
462        std::fs::write(&path, b"{ not valid json ").expect("seed file");
463
464        let loaded = SubdomainBaselines::load_from_path(&path);
465        assert!(
466            loaded.domains.is_empty(),
467            "corrupt load must return default"
468        );
469        assert!(!path.exists(), "corrupt file should be renamed away");
470        assert!(
471            backup.exists(),
472            "backup .corrupt file should exist at {}",
473            backup.display()
474        );
475
476        let _ = std::fs::remove_file(&backup);
477        if let Some(parent) = path.parent() {
478            let _ = std::fs::remove_dir_all(parent);
479        }
480    }
481
482    #[test]
483    fn load_from_path_returns_default_when_missing() {
484        let path = unique_temp_path("missing");
485        let _ = std::fs::remove_file(&path);
486        let loaded = SubdomainBaselines::load_from_path(&path);
487        assert!(loaded.domains.is_empty());
488        if let Some(parent) = path.parent() {
489            let _ = std::fs::remove_dir_all(parent);
490        }
491    }
492
493    #[test]
494    fn baseline_json_missing_optional_fields_still_parses() {
495        // Forward-compat: `source`/`names` carry #[serde(default)] so an
496        // older or hand-edited file missing them still loads instead of
497        // being kicked to `.corrupt` (the history.rs raw_response lesson).
498        let json = r#"{"domains":{"example.com":{"recorded_at":"2026-06-01T00:00:00Z"}}}"#;
499        let store: SubdomainBaselines = serde_json::from_str(json).expect("parses");
500        let baseline = store.get("example.com").expect("baseline");
501        assert!(baseline.names.is_empty());
502        assert!(baseline.source.is_empty());
503    }
504}