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