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, DomainInfoSource};
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    /// Why the snapshots could not be compared, when one of them carries no
36    /// registration data (a throttled or inconclusive lookup). An absent
37    /// answer is not a changed answer, so nothing is diffed and
38    /// [`DriftReport::has_drift`] stays false.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub inconclusive: Option<String>,
41}
42
43impl DriftReport {
44    /// True when at least one material field changed. Callers (CLI/cron) use
45    /// this to drive a non-zero exit code.
46    pub fn has_drift(&self) -> bool {
47        !self.changes.is_empty()
48    }
49
50    /// A report with nothing compared yet (no previous snapshot).
51    pub fn empty(domain: &str) -> Self {
52        DriftReport {
53            domain: domain.to_string(),
54            changes: Vec::new(),
55            inconclusive: None,
56        }
57    }
58
59    /// Diffs two already-merged [`DomainInfo`] snapshots, comparing only the
60    /// fields that matter for change monitoring.
61    ///
62    /// Values are compared in a source-independent form: RDAP and WHOIS spell
63    /// the same EPP status (`client transfer prohibited` vs
64    /// `clientTransferProhibited`) and DNSSEC state (`signed` vs
65    /// `signedDelegation`) differently, and smart lookup legitimately flips
66    /// between the two when RDAP is throttled — that must not read as a
67    /// hijack indicator.
68    pub fn between(domain: &str, old: &DomainInfo, new: &DomainInfo) -> Self {
69        // A domain that is now genuinely available has lapsed or been
70        // deleted: that is the one change worth reporting on its own.
71        let lapsed = |info: &DomainInfo| {
72            matches!(
73                info.availability_verdict.as_deref(),
74                Some("available" | "likely_available")
75            )
76        };
77        if has_registration_data(old) && !has_registration_data(new) && lapsed(new) {
78            return DriftReport {
79                domain: domain.to_string(),
80                changes: vec![FieldChange {
81                    field: "registration".to_string(),
82                    old: Some("registered".to_string()),
83                    new: new.availability_verdict.clone(),
84                }],
85                inconclusive: None,
86            };
87        }
88        for (label, info) in [("previous", old), ("current", new)] {
89            if !has_registration_data(info) {
90                return DriftReport {
91                    domain: domain.to_string(),
92                    changes: Vec::new(),
93                    inconclusive: Some(format!(
94                        "the {label} lookup returned no registration data ({}), so the \
95                         snapshots were not compared",
96                        info.availability_verdict
97                            .as_deref()
98                            .unwrap_or("no RDAP/WHOIS data")
99                    )),
100                };
101            }
102        }
103
104        let mut changes = Vec::new();
105
106        let mut push = |field: &str, old: Option<String>, new: Option<String>| {
107            if old != new {
108                changes.push(FieldChange {
109                    field: field.to_string(),
110                    old,
111                    new,
112                });
113            }
114        };
115
116        push("registrar", old.registrar.clone(), new.registrar.clone());
117        push(
118            "organization",
119            old.organization.clone(),
120            new.organization.clone(),
121        );
122        push("registrant", old.registrant.clone(), new.registrant.clone());
123        push(
124            "nameservers",
125            joined_set(&old.nameservers),
126            joined_set(&new.nameservers),
127        );
128        push(
129            "status",
130            joined_keys(&old.status, status_key),
131            joined_keys(&new.status, status_key),
132        );
133        push(
134            "expiration_date",
135            old.expiration_date.map(|d| d.to_rfc3339()),
136            new.expiration_date.map(|d| d.to_rfc3339()),
137        );
138        // Only a definite signed/unsigned on BOTH sides is comparable: one
139        // side simply not reporting DNSSEC (thin WHOIS) is not a toggle.
140        if let (Some(o), Some(n)) = (dnssec_state(old), dnssec_state(new)) {
141            if o != n {
142                push("dnssec", old.dnssec.clone(), new.dnssec.clone());
143            }
144        }
145
146        DriftReport {
147            domain: domain.to_string(),
148            changes,
149            inconclusive: None,
150        }
151    }
152
153    /// Diffs two [`LookupResult`]s by merging each into a [`DomainInfo`] first.
154    pub fn from_lookups(domain: &str, previous: &LookupResult, current: &LookupResult) -> Self {
155        let old = DomainInfo::from_lookup_result(previous);
156        let new = DomainInfo::from_lookup_result(current);
157        Self::between(domain, &old, &new)
158    }
159}
160
161/// True when a snapshot carries any registration data at all. A lookup that
162/// was throttled or inconclusive produces a snapshot with none of these, and
163/// diffing it would report every field as "removed".
164fn has_registration_data(info: &DomainInfo) -> bool {
165    info.source != DomainInfoSource::Available
166        || info.registrar.is_some()
167        || !info.nameservers.is_empty()
168        || !info.status.is_empty()
169        || info.expiration_date.is_some()
170}
171
172/// Canonical EPP status key, independent of RDAP (`client transfer
173/// prohibited`) vs WHOIS (`clientTransferProhibited`) spelling. Mirrors
174/// `domain_info`'s status normalization (a trailing ` (URL)` is dropped).
175fn status_key(code: &str) -> String {
176    code.chars()
177        .take_while(|c| *c != '(')
178        .filter(|c| c.is_ascii_alphanumeric())
179        .map(|c| c.to_ascii_lowercase())
180        .collect()
181}
182
183/// DNSSEC state reduced to signed (`true`) / unsigned (`false`), or `None`
184/// when the value is absent or unrecognized.
185fn dnssec_state(info: &DomainInfo) -> Option<bool> {
186    let v = info.dnssec.as_deref()?.trim().to_ascii_lowercase();
187    if v.starts_with("unsigned") || v == "no" || v == "false" || v == "inactive" {
188        Some(false)
189    } else if v.starts_with("signed") || v == "yes" || v == "true" || v == "active" {
190        Some(true)
191    } else {
192        None
193    }
194}
195
196/// Normalizes a list field (nameservers/status) to a case-insensitive, sorted,
197/// deduplicated, comma-joined string so ordering churn isn't reported as drift.
198/// Returns `None` for an empty list so an absent field compares equal across
199/// snapshots.
200fn joined_set(values: &[String]) -> Option<String> {
201    joined_keys(values, |v| v.trim().trim_end_matches('.').to_lowercase())
202}
203
204/// [`joined_set`] with a caller-supplied normalization.
205fn joined_keys(values: &[String], key: impl Fn(&str) -> String) -> Option<String> {
206    let mut normalized: Vec<String> = values
207        .iter()
208        .map(|v| key(v))
209        .filter(|v| !v.is_empty())
210        .collect();
211    normalized.sort();
212    normalized.dedup();
213    if normalized.is_empty() {
214        None
215    } else {
216        Some(normalized.join(", "))
217    }
218}
219
220/// True when a stored lookup carries registration data, i.e. is usable as a
221/// drift baseline. A throttled or inconclusive lookup recorded into history
222/// must not become the snapshot the next run is compared against.
223pub fn is_comparable(result: &LookupResult) -> bool {
224    has_registration_data(&DomainInfo::from_lookup_result(result))
225}
226
227/// Picks the baseline for a drift check: the most recent comparable stored
228/// snapshot, falling back to the most recent one of any kind.
229pub fn baseline_snapshot<'a>(
230    entries: impl DoubleEndedIterator<Item = &'a LookupResult> + Clone,
231) -> Option<&'a LookupResult> {
232    entries
233        .clone()
234        .rev()
235        .find(|r| is_comparable(r))
236        .or_else(|| entries.clone().next_back())
237}
238
239/// Computes drift between the latest history entry for a domain and the most
240/// recent earlier snapshot that carries registration data.
241///
242/// Returns `None` when the domain has fewer than two stored snapshots (nothing
243/// to compare against yet).
244pub fn drift_from_history(history: &LookupHistory, domain: &str) -> Option<DriftReport> {
245    let entries = history.get(domain);
246    let (current, earlier) = entries.split_last()?;
247    let previous = baseline_snapshot(earlier.iter().map(|e| &e.result))?;
248    Some(DriftReport::from_lookups(domain, previous, &current.result))
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use chrono::{TimeZone, Utc};
255
256    /// Builds a WHOIS-backed snapshot with the given nameservers/registrar so we
257    /// can diff two merged `DomainInfo`s without fabricating full lookups.
258    fn snapshot(registrar: &str, nameservers: &[&str], dnssec: &str) -> DomainInfo {
259        let whois = crate::whois::WhoisResponse {
260            domain: "example.com".to_string(),
261            registrar: Some(registrar.to_string()),
262            registrant: None,
263            organization: None,
264            registrant_email: None,
265            registrant_phone: None,
266            registrant_address: None,
267            registrant_country: None,
268            admin_name: None,
269            admin_organization: None,
270            admin_email: None,
271            admin_phone: None,
272            tech_name: None,
273            tech_organization: None,
274            tech_email: None,
275            tech_phone: None,
276            creation_date: Some(Utc.with_ymd_and_hms(2019, 6, 1, 0, 0, 0).unwrap()),
277            expiration_date: Some(Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap()),
278            updated_date: None,
279            nameservers: nameservers.iter().map(|s| s.to_string()).collect(),
280            status: vec![],
281            dnssec: Some(dnssec.to_string()),
282            whois_server: "whois.example.com".to_string(),
283            raw_response: String::new(),
284        };
285        DomainInfo::from_sources("example.com", None, Some(&whois))
286    }
287
288    #[test]
289    fn between_detects_nameserver_swap_and_registrar_transfer() {
290        let old = snapshot("Old Registrar", &["ns1.old.net", "ns2.old.net"], "signed");
291        let new = snapshot("New Registrar", &["ns1.new.net", "ns2.new.net"], "signed");
292        let report = DriftReport::between("example.com", &old, &new);
293
294        assert!(report.has_drift());
295        let fields: Vec<&str> = report.changes.iter().map(|c| c.field.as_str()).collect();
296        assert!(fields.contains(&"registrar"));
297        assert!(fields.contains(&"nameservers"));
298        assert!(!fields.contains(&"dnssec"), "dnssec unchanged");
299    }
300
301    #[test]
302    fn between_detects_dnssec_removal() {
303        let old = snapshot("R", &["ns1.example.com"], "signed");
304        let new = snapshot("R", &["ns1.example.com"], "unsigned");
305        let report = DriftReport::between("example.com", &old, &new);
306        let dnssec = report.changes.iter().find(|c| c.field == "dnssec").unwrap();
307        assert_eq!(dnssec.old.as_deref(), Some("signed"));
308        assert_eq!(dnssec.new.as_deref(), Some("unsigned"));
309    }
310
311    #[test]
312    fn nameserver_reordering_is_not_drift() {
313        // Same set, different order/case → no change reported.
314        let old = snapshot("R", &["NS1.example.com", "ns2.example.com"], "signed");
315        let new = snapshot("R", &["ns2.example.com", "ns1.EXAMPLE.com"], "signed");
316        let report = DriftReport::between("example.com", &old, &new);
317        assert!(
318            !report.has_drift(),
319            "reordering/case must not count as drift: {:?}",
320            report.changes
321        );
322    }
323
324    #[test]
325    fn rdap_whois_source_flip_is_not_drift() {
326        // RDAP throttled on the second run, so smart lookup fell back to
327        // WHOIS: same facts, different spelling. Must not look like a hijack.
328        let mut old = snapshot("R", &["ns1.example.com."], "signed");
329        old.status = vec!["client transfer prohibited".to_string()];
330        let mut new = snapshot("R", &["NS1.EXAMPLE.COM"], "signedDelegation");
331        new.status = vec!["clientTransferProhibited".to_string()];
332        let report = DriftReport::between("example.com", &old, &new);
333        assert!(!report.has_drift(), "{:?}", report.changes);
334
335        // A real DNSSEC removal in either spelling is still caught.
336        let new = snapshot("R", &["ns1.example.com"], "Unsigned");
337        let report = DriftReport::between("example.com", &old, &new);
338        assert!(report.changes.iter().any(|c| c.field == "dnssec"));
339    }
340
341    #[test]
342    fn a_snapshot_without_registration_data_is_inconclusive_not_drift() {
343        let old = snapshot("R", &["ns1.example.com"], "signed");
344        // What a throttled/inconclusive lookup produces.
345        let mut empty = DomainInfo::from_sources("example.com", None, None);
346        empty.source = DomainInfoSource::Available;
347        empty.availability_verdict = Some("unknown".to_string());
348
349        let report = DriftReport::between("example.com", &old, &empty);
350        assert!(!report.has_drift(), "{:?}", report.changes);
351        assert!(report
352            .inconclusive
353            .as_deref()
354            .unwrap_or_default()
355            .contains("current lookup"));
356        let report = DriftReport::between("example.com", &empty, &old);
357        assert!(!report.has_drift());
358        assert!(report.inconclusive.is_some());
359    }
360
361    #[test]
362    fn a_lapsed_registration_is_drift() {
363        let old = snapshot("R", &["ns1.example.com"], "signed");
364        let mut gone = DomainInfo::from_sources("example.com", None, None);
365        gone.source = DomainInfoSource::Available;
366        gone.availability_verdict = Some("available".to_string());
367        let report = DriftReport::between("example.com", &old, &gone);
368        assert!(report.has_drift());
369        assert_eq!(report.changes[0].field, "registration");
370        assert_eq!(report.changes[0].new.as_deref(), Some("available"));
371    }
372
373    #[test]
374    fn identical_snapshots_have_no_drift() {
375        let a = snapshot("R", &["ns1.example.com"], "signed");
376        let b = snapshot("R", &["ns1.example.com"], "signed");
377        assert!(!DriftReport::between("example.com", &a, &b).has_drift());
378    }
379
380    #[test]
381    fn drift_from_history_needs_two_snapshots() {
382        let mut history = LookupHistory::default();
383        let result = LookupResult::Whois {
384            data: crate::whois::WhoisResponse::parse(
385                "example.com",
386                "whois.example.com",
387                "Domain Name: example.com\nName Server: ns1.example.com\n",
388            ),
389            rdap_error: None,
390            rdap_fallback: None,
391        };
392        history.record("example.com", result.clone());
393        // Only one snapshot → nothing to compare.
394        assert!(drift_from_history(&history, "example.com").is_none());
395        history.record("example.com", result);
396        // Two identical snapshots → a report exists but shows no drift.
397        let report = drift_from_history(&history, "example.com").expect("report");
398        assert!(!report.has_drift());
399    }
400}