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        push("registrar", old.registrar.clone(), new.registrar.clone());
116        push(
117            "organization",
118            old.organization.clone(),
119            new.organization.clone(),
120        );
121        push("registrant", old.registrant.clone(), new.registrant.clone());
122        push(
123            "nameservers",
124            joined_set(&old.nameservers),
125            joined_set(&new.nameservers),
126        );
127        push(
128            "status",
129            joined_keys(&old.status, status_key),
130            joined_keys(&new.status, status_key),
131        );
132        push(
133            "expiration_date",
134            old.expiration_date.map(|d| d.to_rfc3339()),
135            new.expiration_date.map(|d| d.to_rfc3339()),
136        );
137        // Only a definite signed/unsigned on BOTH sides is comparable: one
138        // side simply not reporting DNSSEC (thin WHOIS) is not a toggle.
139        if let (Some(o), Some(n)) = (dnssec_state(old), dnssec_state(new)) {
140            if o != n {
141                push("dnssec", old.dnssec.clone(), new.dnssec.clone());
142            }
143        }
144
145        DriftReport {
146            domain: domain.to_string(),
147            changes,
148            inconclusive: None,
149        }
150    }
151
152    /// Diffs two [`LookupResult`]s by merging each into a [`DomainInfo`] first.
153    pub fn from_lookups(domain: &str, previous: &LookupResult, current: &LookupResult) -> Self {
154        let old = DomainInfo::from_lookup_result(previous);
155        let new = DomainInfo::from_lookup_result(current);
156        Self::between(domain, &old, &new)
157    }
158}
159
160/// True when a snapshot carries any registration data at all. A lookup that
161/// was throttled or inconclusive produces a snapshot with none of these, and
162/// diffing it would report every field as "removed".
163fn has_registration_data(info: &DomainInfo) -> bool {
164    info.source != DomainInfoSource::Available
165        || info.registrar.is_some()
166        || !info.nameservers.is_empty()
167        || !info.status.is_empty()
168        || info.expiration_date.is_some()
169}
170
171/// Canonical EPP status key, independent of RDAP (`client transfer
172/// prohibited`) vs WHOIS (`clientTransferProhibited`) spelling. Mirrors
173/// `domain_info`'s status normalization (a trailing ` (URL)` is dropped).
174fn status_key(code: &str) -> String {
175    code.chars()
176        .take_while(|c| *c != '(')
177        .filter(|c| c.is_ascii_alphanumeric())
178        .map(|c| c.to_ascii_lowercase())
179        .collect()
180}
181
182/// DNSSEC state reduced to signed (`true`) / unsigned (`false`), or `None`
183/// when the value is absent or unrecognized.
184fn dnssec_state(info: &DomainInfo) -> Option<bool> {
185    let v = info.dnssec.as_deref()?.trim().to_ascii_lowercase();
186    if v.starts_with("unsigned") || v == "no" || v == "false" || v == "inactive" {
187        Some(false)
188    } else if v.starts_with("signed") || v == "yes" || v == "true" || v == "active" {
189        Some(true)
190    } else {
191        None
192    }
193}
194
195/// Normalizes a list field (nameservers/status) to a case-insensitive, sorted,
196/// deduplicated, comma-joined string so ordering churn isn't reported as drift.
197/// Returns `None` for an empty list so an absent field compares equal across
198/// snapshots.
199fn joined_set(values: &[String]) -> Option<String> {
200    joined_keys(values, |v| v.trim().trim_end_matches('.').to_lowercase())
201}
202
203/// [`joined_set`] with a caller-supplied normalization.
204fn joined_keys(values: &[String], key: impl Fn(&str) -> String) -> Option<String> {
205    let mut normalized: Vec<String> = values
206        .iter()
207        .map(|v| key(v))
208        .filter(|v| !v.is_empty())
209        .collect();
210    normalized.sort();
211    normalized.dedup();
212    if normalized.is_empty() {
213        None
214    } else {
215        Some(normalized.join(", "))
216    }
217}
218
219/// True when a stored lookup carries registration data, i.e. is usable as a
220/// drift baseline. A throttled or inconclusive lookup recorded into history
221/// must not become the snapshot the next run is compared against.
222pub fn is_comparable(result: &LookupResult) -> bool {
223    has_registration_data(&DomainInfo::from_lookup_result(result))
224}
225
226/// Picks the baseline for a drift check: the most recent comparable stored
227/// snapshot, falling back to the most recent one of any kind.
228pub fn baseline_snapshot<'a>(
229    entries: impl DoubleEndedIterator<Item = &'a LookupResult> + Clone,
230) -> Option<&'a LookupResult> {
231    entries
232        .clone()
233        .rev()
234        .find(|r| is_comparable(r))
235        .or_else(|| entries.clone().next_back())
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use chrono::{TimeZone, Utc};
242
243    /// Builds a WHOIS-backed snapshot with the given nameservers/registrar so we
244    /// can diff two merged `DomainInfo`s without fabricating full lookups.
245    fn snapshot(registrar: &str, nameservers: &[&str], dnssec: &str) -> DomainInfo {
246        let whois = crate::whois::WhoisResponse {
247            domain: "example.com".to_string(),
248            registrar: Some(registrar.to_string()),
249            creation_date: Some(Utc.with_ymd_and_hms(2019, 6, 1, 0, 0, 0).unwrap()),
250            expiration_date: Some(Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap()),
251            nameservers: nameservers.iter().map(|s| s.to_string()).collect(),
252            dnssec: Some(dnssec.to_string()),
253            whois_server: "whois.example.com".to_string(),
254            ..Default::default()
255        };
256        DomainInfo::from_sources("example.com", None, Some(&whois))
257    }
258
259    #[test]
260    fn between_detects_nameserver_swap_and_registrar_transfer() {
261        let old = snapshot("Old Registrar", &["ns1.old.net", "ns2.old.net"], "signed");
262        let new = snapshot("New Registrar", &["ns1.new.net", "ns2.new.net"], "signed");
263        let report = DriftReport::between("example.com", &old, &new);
264
265        assert!(report.has_drift());
266        let fields: Vec<&str> = report.changes.iter().map(|c| c.field.as_str()).collect();
267        assert!(fields.contains(&"registrar"));
268        assert!(fields.contains(&"nameservers"));
269        assert!(!fields.contains(&"dnssec"), "dnssec unchanged");
270    }
271
272    #[test]
273    fn between_detects_dnssec_removal() {
274        let old = snapshot("R", &["ns1.example.com"], "signed");
275        let new = snapshot("R", &["ns1.example.com"], "unsigned");
276        let report = DriftReport::between("example.com", &old, &new);
277        let dnssec = report.changes.iter().find(|c| c.field == "dnssec").unwrap();
278        assert_eq!(dnssec.old.as_deref(), Some("signed"));
279        assert_eq!(dnssec.new.as_deref(), Some("unsigned"));
280    }
281
282    #[test]
283    fn nameserver_reordering_is_not_drift() {
284        // Same set, different order/case → no change reported.
285        let old = snapshot("R", &["NS1.example.com", "ns2.example.com"], "signed");
286        let new = snapshot("R", &["ns2.example.com", "ns1.EXAMPLE.com"], "signed");
287        let report = DriftReport::between("example.com", &old, &new);
288        assert!(
289            !report.has_drift(),
290            "reordering/case must not count as drift: {:?}",
291            report.changes
292        );
293    }
294
295    #[test]
296    fn rdap_whois_source_flip_is_not_drift() {
297        // RDAP throttled on the second run, so smart lookup fell back to
298        // WHOIS: same facts, different spelling. Must not look like a hijack.
299        let mut old = snapshot("R", &["ns1.example.com."], "signed");
300        old.status = vec!["client transfer prohibited".to_string()];
301        let mut new = snapshot("R", &["NS1.EXAMPLE.COM"], "signedDelegation");
302        new.status = vec!["clientTransferProhibited".to_string()];
303        let report = DriftReport::between("example.com", &old, &new);
304        assert!(!report.has_drift(), "{:?}", report.changes);
305
306        // A real DNSSEC removal in either spelling is still caught.
307        let new = snapshot("R", &["ns1.example.com"], "Unsigned");
308        let report = DriftReport::between("example.com", &old, &new);
309        assert!(report.changes.iter().any(|c| c.field == "dnssec"));
310    }
311
312    #[test]
313    fn a_snapshot_without_registration_data_is_inconclusive_not_drift() {
314        let old = snapshot("R", &["ns1.example.com"], "signed");
315        // What a throttled/inconclusive lookup produces.
316        let mut empty = DomainInfo::from_sources("example.com", None, None);
317        empty.source = DomainInfoSource::Available;
318        empty.availability_verdict = Some("unknown".to_string());
319
320        let report = DriftReport::between("example.com", &old, &empty);
321        assert!(!report.has_drift(), "{:?}", report.changes);
322        assert!(report
323            .inconclusive
324            .as_deref()
325            .unwrap_or_default()
326            .contains("current lookup"));
327        let report = DriftReport::between("example.com", &empty, &old);
328        assert!(!report.has_drift());
329        assert!(report.inconclusive.is_some());
330    }
331
332    #[test]
333    fn a_lapsed_registration_is_drift() {
334        let old = snapshot("R", &["ns1.example.com"], "signed");
335        let mut gone = DomainInfo::from_sources("example.com", None, None);
336        gone.source = DomainInfoSource::Available;
337        gone.availability_verdict = Some("available".to_string());
338        let report = DriftReport::between("example.com", &old, &gone);
339        assert!(report.has_drift());
340        assert_eq!(report.changes[0].field, "registration");
341        assert_eq!(report.changes[0].new.as_deref(), Some("available"));
342    }
343
344    #[test]
345    fn identical_snapshots_have_no_drift() {
346        let a = snapshot("R", &["ns1.example.com"], "signed");
347        let b = snapshot("R", &["ns1.example.com"], "signed");
348        assert!(!DriftReport::between("example.com", &a, &b).has_drift());
349    }
350
351    #[test]
352    fn baseline_snapshot_needs_an_earlier_snapshot() {
353        let result = LookupResult::Whois {
354            data: crate::whois::WhoisResponse::parse(
355                "example.com",
356                "whois.example.com",
357                "Domain Name: example.com\nName Server: ns1.example.com\n",
358            ),
359            rdap_error: None,
360            rdap_fallback: None,
361        };
362        // No earlier snapshot → nothing to compare.
363        assert!(baseline_snapshot(std::iter::empty()).is_none());
364        // Two identical snapshots → a report exists but shows no drift.
365        let previous = baseline_snapshot([&result].into_iter()).expect("baseline");
366        let report = DriftReport::from_lookups("example.com", previous, &result);
367        assert!(!report.has_drift());
368    }
369}