Skip to main content

seer_core/
drift.rs

1//! Domain drift detection.
2//!
3//! Diffs a domain's current registration/DNS state against a prior snapshot to
4//! surface the changes that matter for security monitoring: nameserver swaps,
5//! registrar transfers, expiry shifts, DNSSEC toggles, and registrant changes.
6//! Silent nameserver swaps and DNSSEC removal are classic hijack indicators.
7//!
8//! This is pure post-processing over data seer already merges into
9//! [`DomainInfo`] and persists in [`crate::history`] — no new network protocol.
10
11use serde::{Deserialize, Serialize};
12
13use crate::domain_info::DomainInfo;
14use crate::history::LookupHistory;
15use crate::lookup::LookupResult;
16
17/// A single field that differs between two snapshots of a domain.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct FieldChange {
20    /// The name of the field that changed (e.g. `"nameservers"`).
21    pub field: String,
22    /// The previous value (`None` when the field was previously unset/empty).
23    pub old: Option<String>,
24    /// The current value (`None` when the field is now unset/empty).
25    pub new: Option<String>,
26}
27
28/// A report of what changed between two snapshots of a domain.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct DriftReport {
31    /// The domain compared.
32    pub domain: String,
33    /// The set of changed fields (empty when nothing material changed).
34    pub changes: Vec<FieldChange>,
35}
36
37impl DriftReport {
38    /// True when at least one material field changed. Callers (CLI/cron) use
39    /// this to drive a non-zero exit code.
40    pub fn has_drift(&self) -> bool {
41        !self.changes.is_empty()
42    }
43
44    /// Diffs two already-merged [`DomainInfo`] snapshots, comparing only the
45    /// fields that matter for change monitoring.
46    pub fn between(domain: &str, old: &DomainInfo, new: &DomainInfo) -> Self {
47        let mut changes = Vec::new();
48
49        let mut push = |field: &str, old: Option<String>, new: Option<String>| {
50            if old != new {
51                changes.push(FieldChange {
52                    field: field.to_string(),
53                    old,
54                    new,
55                });
56            }
57        };
58
59        push("registrar", old.registrar.clone(), new.registrar.clone());
60        push(
61            "organization",
62            old.organization.clone(),
63            new.organization.clone(),
64        );
65        push("registrant", old.registrant.clone(), new.registrant.clone());
66        push(
67            "nameservers",
68            joined_set(&old.nameservers),
69            joined_set(&new.nameservers),
70        );
71        push("status", joined_set(&old.status), joined_set(&new.status));
72        push(
73            "expiration_date",
74            old.expiration_date.map(|d| d.to_rfc3339()),
75            new.expiration_date.map(|d| d.to_rfc3339()),
76        );
77        push("dnssec", old.dnssec.clone(), new.dnssec.clone());
78
79        DriftReport {
80            domain: domain.to_string(),
81            changes,
82        }
83    }
84
85    /// Diffs two [`LookupResult`]s by merging each into a [`DomainInfo`] first.
86    pub fn from_lookups(domain: &str, previous: &LookupResult, current: &LookupResult) -> Self {
87        let old = DomainInfo::from_lookup_result(previous);
88        let new = DomainInfo::from_lookup_result(current);
89        Self::between(domain, &old, &new)
90    }
91}
92
93/// Normalizes a list field (nameservers/status) to a case-insensitive, sorted,
94/// deduplicated, comma-joined string so ordering churn isn't reported as drift.
95/// Returns `None` for an empty list so an absent field compares equal across
96/// snapshots.
97fn joined_set(values: &[String]) -> Option<String> {
98    let mut normalized: Vec<String> = values
99        .iter()
100        .map(|v| v.trim().to_lowercase())
101        .filter(|v| !v.is_empty())
102        .collect();
103    normalized.sort();
104    normalized.dedup();
105    if normalized.is_empty() {
106        None
107    } else {
108        Some(normalized.join(", "))
109    }
110}
111
112/// Computes drift between the two most recent history entries for a domain.
113///
114/// Returns `None` when the domain has fewer than two stored snapshots (nothing
115/// to compare against yet).
116pub fn drift_from_history(history: &LookupHistory, domain: &str) -> Option<DriftReport> {
117    let entries = history.get(domain);
118    if entries.len() < 2 {
119        return None;
120    }
121    let previous = &entries[entries.len() - 2].result;
122    let current = &entries[entries.len() - 1].result;
123    Some(DriftReport::from_lookups(domain, previous, current))
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use chrono::{TimeZone, Utc};
130
131    /// Builds a WHOIS-backed snapshot with the given nameservers/registrar so we
132    /// can diff two merged `DomainInfo`s without fabricating full lookups.
133    fn snapshot(registrar: &str, nameservers: &[&str], dnssec: &str) -> DomainInfo {
134        let whois = crate::whois::WhoisResponse {
135            domain: "example.com".to_string(),
136            registrar: Some(registrar.to_string()),
137            registrant: None,
138            organization: None,
139            registrant_email: None,
140            registrant_phone: None,
141            registrant_address: None,
142            registrant_country: None,
143            admin_name: None,
144            admin_organization: None,
145            admin_email: None,
146            admin_phone: None,
147            tech_name: None,
148            tech_organization: None,
149            tech_email: None,
150            tech_phone: None,
151            creation_date: Some(Utc.with_ymd_and_hms(2019, 6, 1, 0, 0, 0).unwrap()),
152            expiration_date: Some(Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap()),
153            updated_date: None,
154            nameservers: nameservers.iter().map(|s| s.to_string()).collect(),
155            status: vec![],
156            dnssec: Some(dnssec.to_string()),
157            whois_server: "whois.example.com".to_string(),
158            raw_response: String::new(),
159        };
160        DomainInfo::from_sources("example.com", None, Some(&whois))
161    }
162
163    #[test]
164    fn between_detects_nameserver_swap_and_registrar_transfer() {
165        let old = snapshot("Old Registrar", &["ns1.old.net", "ns2.old.net"], "signed");
166        let new = snapshot("New Registrar", &["ns1.new.net", "ns2.new.net"], "signed");
167        let report = DriftReport::between("example.com", &old, &new);
168
169        assert!(report.has_drift());
170        let fields: Vec<&str> = report.changes.iter().map(|c| c.field.as_str()).collect();
171        assert!(fields.contains(&"registrar"));
172        assert!(fields.contains(&"nameservers"));
173        assert!(!fields.contains(&"dnssec"), "dnssec unchanged");
174    }
175
176    #[test]
177    fn between_detects_dnssec_removal() {
178        let old = snapshot("R", &["ns1.example.com"], "signed");
179        let new = snapshot("R", &["ns1.example.com"], "unsigned");
180        let report = DriftReport::between("example.com", &old, &new);
181        let dnssec = report.changes.iter().find(|c| c.field == "dnssec").unwrap();
182        assert_eq!(dnssec.old.as_deref(), Some("signed"));
183        assert_eq!(dnssec.new.as_deref(), Some("unsigned"));
184    }
185
186    #[test]
187    fn nameserver_reordering_is_not_drift() {
188        // Same set, different order/case → no change reported.
189        let old = snapshot("R", &["NS1.example.com", "ns2.example.com"], "signed");
190        let new = snapshot("R", &["ns2.example.com", "ns1.EXAMPLE.com"], "signed");
191        let report = DriftReport::between("example.com", &old, &new);
192        assert!(
193            !report.has_drift(),
194            "reordering/case must not count as drift: {:?}",
195            report.changes
196        );
197    }
198
199    #[test]
200    fn identical_snapshots_have_no_drift() {
201        let a = snapshot("R", &["ns1.example.com"], "signed");
202        let b = snapshot("R", &["ns1.example.com"], "signed");
203        assert!(!DriftReport::between("example.com", &a, &b).has_drift());
204    }
205
206    #[test]
207    fn drift_from_history_needs_two_snapshots() {
208        let mut history = LookupHistory::default();
209        let result = LookupResult::Whois {
210            data: crate::whois::WhoisResponse::parse(
211                "example.com",
212                "whois.example.com",
213                "Domain Name: example.com\nName Server: ns1.example.com\n",
214            ),
215            rdap_error: None,
216            rdap_fallback: None,
217        };
218        history.record("example.com", result.clone());
219        // Only one snapshot → nothing to compare.
220        assert!(drift_from_history(&history, "example.com").is_none());
221        history.record("example.com", result);
222        // Two identical snapshots → a report exists but shows no drift.
223        let report = drift_from_history(&history, "example.com").expect("report");
224        assert!(!report.has_drift());
225    }
226}