Skip to main content

seer_core/
confusables.rs

1//! Typosquat / homoglyph look-alike generation and registration scoring.
2//!
3//! Given a domain, generates candidate look-alikes (typo permutations plus a
4//! small ASCII-homoglyph map and common TLD swaps) and scores which candidates
5//! are registered, ranking freshly-registered squats first. This is a
6//! brand-protection / phishing-defense capability built entirely on primitives
7//! seer already has (normalization, smart lookup, availability inference) — no
8//! new protocol code.
9//!
10//! Candidate generation is pure and unit-tested; only the registration scoring
11//! is async.
12//!
13//! ## DNS presence pre-filter (scoring)
14//!
15//! Most generated candidates are unregistered typos, so before paying for a
16//! full RDAP+WHOIS race on each one, [`score_candidates`] first probes every
17//! candidate with a cheap [`DnsResolver::presence`](crate::dns::DnsResolver::presence)
18//! query and drops the ones that answer `NXDOMAIN` — the same signal the
19//! smart-lookup thin-fallback ladder uses to call an apex unregistered. Only
20//! `Present`/`Unknown` candidates get the full lookup (a failed probe is not
21//! evidence, so it is never skipped).
22//!
23//! Caveat: DNS presence is a cheaper but slightly different signal from a
24//! registry lookup. A registered-but-unresolvable domain only looks `Absent`
25//! via NXDOMAIN, which for a registered name is rare; parked squats are
26//! `Present` and still receive the full lookup. The pre-filter therefore trades
27//! a negligible miss rate for a large reduction in registry queries (a
28//! rate-limit-ban risk against port-43 WHOIS).
29
30use chrono::{DateTime, Utc};
31use serde::{Deserialize, Serialize};
32
33use crate::dns::DnsPresence;
34use crate::domain_info::{DomainInfo, DomainInfoSource};
35use crate::error::Result;
36use crate::lookup::SmartLookup;
37use crate::validation::normalize_domain;
38
39/// Upper bound on generated candidates, to keep the subsequent network scoring
40/// bounded regardless of label length.
41const MAX_CANDIDATES: usize = 600;
42
43/// Concurrency for the cheap DNS presence pre-filter. DNS probes are far
44/// lighter than a full RDAP+WHOIS race, so we fan them out wider than the
45/// (registry-facing) full-lookup concurrency to keep the pre-filter fast.
46const PREFILTER_CONCURRENCY: usize = 50;
47
48/// Common alternate TLDs used for TLD-swap squats.
49const SWAP_TLDS: &[&str] = &[
50    "com", "net", "org", "co", "io", "info", "biz", "app", "dev", "xyz", "online", "site",
51];
52
53/// A generated look-alike candidate and the technique that produced it.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct ConfusableCandidate {
56    pub domain: String,
57    /// The permutation technique (e.g. `omission`, `homoglyph`, `tld-swap`).
58    pub technique: String,
59}
60
61/// A registered look-alike surfaced by scoring.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct RegisteredLookalike {
64    pub domain: String,
65    pub technique: String,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub registrar: Option<String>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub creation_date: Option<DateTime<Utc>>,
70    #[serde(skip_serializing_if = "Vec::is_empty")]
71    pub nameservers: Vec<String>,
72}
73
74/// The result of a confusables scan.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ConfusableReport {
77    pub domain: String,
78    pub candidates_generated: usize,
79    pub candidates_checked: usize,
80    /// Registered look-alikes, most-recently-registered first.
81    pub registered: Vec<RegisteredLookalike>,
82}
83
84/// Returns the QWERTY-adjacent keys for `c` (used for fat-finger substitutions).
85fn keyboard_neighbors(c: char) -> &'static [char] {
86    match c {
87        'a' => &['q', 'w', 's', 'z'],
88        'b' => &['v', 'g', 'h', 'n'],
89        'c' => &['x', 'd', 'f', 'v'],
90        'd' => &['s', 'e', 'f', 'c', 'x'],
91        'e' => &['w', 'r', 'd', 's'],
92        'f' => &['d', 'r', 'g', 'v', 'c'],
93        'g' => &['f', 't', 'h', 'b', 'v'],
94        'h' => &['g', 'y', 'j', 'n', 'b'],
95        'i' => &['u', 'o', 'k', 'j'],
96        'j' => &['h', 'u', 'k', 'm', 'n'],
97        'k' => &['j', 'i', 'l', 'm'],
98        'l' => &['k', 'o', 'p'],
99        'm' => &['n', 'j', 'k'],
100        'n' => &['b', 'h', 'j', 'm'],
101        'o' => &['i', 'p', 'l', 'k'],
102        'p' => &['o', 'l'],
103        'q' => &['w', 'a'],
104        'r' => &['e', 't', 'f', 'd'],
105        's' => &['a', 'w', 'd', 'x', 'z'],
106        't' => &['r', 'y', 'g', 'f'],
107        'u' => &['y', 'i', 'j', 'h'],
108        'v' => &['c', 'f', 'g', 'b'],
109        'w' => &['q', 'e', 's', 'a'],
110        'x' => &['z', 's', 'd', 'c'],
111        'y' => &['t', 'u', 'h', 'g'],
112        'z' => &['a', 's', 'x'],
113        _ => &[],
114    }
115}
116
117/// Returns ASCII-homoglyph replacement strings for `c` (visual look-alikes).
118fn homoglyphs(c: char) -> &'static [&'static str] {
119    match c {
120        'o' => &["0"],
121        '0' => &["o"],
122        'l' => &["1", "i"],
123        'i' => &["1", "l"],
124        '1' => &["l", "i"],
125        'e' => &["3"],
126        'a' => &["4"],
127        's' => &["5"],
128        'b' => &["6"],
129        't' => &["7"],
130        'g' => &["9"],
131        'm' => &["rn"],
132        'w' => &["vv"],
133        _ => &[],
134    }
135}
136
137/// Whether a character is legal in an LDH (letter-digit-hyphen) DNS label.
138fn is_ldh(c: char) -> bool {
139    c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'
140}
141
142/// Generates typo/homoglyph look-alike candidates for `domain`.
143///
144/// The final DNS label (the registrable label preceding the TLD) is permuted;
145/// the rest of the name (any subdomains) and the TLD are preserved, except for
146/// the dedicated `tld-swap` technique. Output is deduplicated, excludes the
147/// input itself, and capped at [`MAX_CANDIDATES`].
148pub fn generate_candidates(domain: &str) -> Vec<ConfusableCandidate> {
149    let Ok(normalized) = normalize_domain(domain) else {
150        return Vec::new();
151    };
152    let Some((prefix, tld)) = normalized.rsplit_once('.') else {
153        return Vec::new();
154    };
155    // Permute only the leftmost label of the prefix (the registrable label),
156    // keeping any deeper subdomain labels fixed.
157    let (sub, label) = match prefix.rsplit_once('.') {
158        Some((sub, label)) => (Some(sub), label),
159        None => (None, prefix),
160    };
161
162    let mut seen = std::collections::HashSet::new();
163    let mut out = Vec::new();
164    let mut push = |label_variant: String, technique: &str, out: &mut Vec<ConfusableCandidate>| {
165        if label_variant.is_empty()
166            || label_variant.starts_with('-')
167            || label_variant.ends_with('-')
168        {
169            return;
170        }
171        let candidate = match sub {
172            Some(sub) => format!("{sub}.{label_variant}.{tld}"),
173            None => format!("{label_variant}.{tld}"),
174        };
175        if candidate != normalized && seen.insert(candidate.clone()) {
176            out.push(ConfusableCandidate {
177                domain: candidate,
178                technique: technique.to_string(),
179            });
180        }
181    };
182
183    let chars: Vec<char> = label.chars().collect();
184
185    // Omission: drop each character.
186    for i in 0..chars.len() {
187        let mut v = chars.clone();
188        v.remove(i);
189        push(v.into_iter().collect(), "omission", &mut out);
190    }
191
192    // Transposition: swap adjacent characters.
193    for i in 0..chars.len().saturating_sub(1) {
194        let mut v = chars.clone();
195        v.swap(i, i + 1);
196        push(v.into_iter().collect(), "transposition", &mut out);
197    }
198
199    // Repetition: double each character.
200    for i in 0..chars.len() {
201        let mut v = chars.clone();
202        v.insert(i, chars[i]);
203        push(v.into_iter().collect(), "repetition", &mut out);
204    }
205
206    // Adjacent-key replacement.
207    for (i, &c) in chars.iter().enumerate() {
208        for &n in keyboard_neighbors(c) {
209            let mut v = chars.clone();
210            v[i] = n;
211            push(v.into_iter().collect(), "replacement", &mut out);
212        }
213    }
214
215    // Insertion: insert each lowercase letter at every gap.
216    for i in 0..=chars.len() {
217        for n in b'a'..=b'z' {
218            let mut v = chars.clone();
219            v.insert(i, n as char);
220            push(v.into_iter().collect(), "insertion", &mut out);
221        }
222    }
223
224    // Bitsquatting: flip each bit of each byte; keep valid LDH results.
225    for (i, &c) in chars.iter().enumerate() {
226        if !c.is_ascii() {
227            continue;
228        }
229        for bit in 0..7 {
230            let flipped = (c as u8) ^ (1 << bit);
231            let fc = flipped as char;
232            if is_ldh(fc) && fc != c {
233                let mut v = chars.clone();
234                v[i] = fc;
235                push(v.into_iter().collect(), "bitsquat", &mut out);
236            }
237        }
238    }
239
240    // Homoglyph substitution.
241    for (i, &c) in chars.iter().enumerate() {
242        for &sub_str in homoglyphs(c) {
243            let mut variant = String::new();
244            for (j, &cc) in chars.iter().enumerate() {
245                if i == j {
246                    variant.push_str(sub_str);
247                } else {
248                    variant.push(cc);
249                }
250            }
251            push(variant, "homoglyph", &mut out);
252        }
253    }
254
255    // TLD swap: keep the label, swap the TLD.
256    for &swap in SWAP_TLDS {
257        if swap != tld {
258            let candidate = match sub {
259                Some(sub) => format!("{sub}.{label}.{swap}"),
260                None => format!("{label}.{swap}"),
261            };
262            if candidate != normalized && seen.insert(candidate.clone()) {
263                out.push(ConfusableCandidate {
264                    domain: candidate,
265                    technique: "tld-swap".to_string(),
266                });
267            }
268        }
269    }
270
271    out.truncate(MAX_CANDIDATES);
272    out
273}
274
275/// Whether a candidate survives the DNS presence pre-filter and warrants a
276/// full registry lookup. `Absent` (NXDOMAIN) is treated as unregistered and
277/// dropped; `Unknown` (a failed probe) is not evidence, so it is kept — this
278/// mirrors the smart-lookup thin-fallback ladder's handling of a failed probe.
279fn survives_prefilter(presence: DnsPresence) -> bool {
280    !matches!(presence, DnsPresence::Absent)
281}
282
283/// Scores which `candidates` are registered.
284///
285/// Candidates are first pre-filtered by a cheap DNS presence probe: those that
286/// return `NXDOMAIN` are unregistered and dropped without a registry lookup
287/// (see the module docs). The survivors get a full smart lookup and are kept
288/// when the merged [`DomainInfo`] reports a non-`Available` source.
289///
290/// Returns the ranked registered look-alikes together with the number of
291/// candidates that passed the pre-filter and received a full lookup — the
292/// accurate `candidates_checked` figure for the report.
293///
294/// The full lookups run up to `concurrency` at a time; the pre-filter probes
295/// run at [`PREFILTER_CONCURRENCY`].
296pub async fn score_candidates(
297    lookup: &SmartLookup,
298    candidates: Vec<ConfusableCandidate>,
299    concurrency: usize,
300) -> (Vec<RegisteredLookalike>, usize) {
301    use futures::stream::{self, StreamExt};
302
303    let concurrency = concurrency.max(1);
304
305    // Pre-filter: probe DNS presence and drop NXDOMAIN candidates before any
306    // registry query. A wider fan-out is fine here — DNS is far cheaper than a
307    // full RDAP+WHOIS race.
308    let prefilter_concurrency = concurrency.max(PREFILTER_CONCURRENCY);
309    let survivors: Vec<ConfusableCandidate> = stream::iter(candidates)
310        .map(|cand| async move {
311            survives_prefilter(lookup.presence(&cand.domain).await).then_some(cand)
312        })
313        .buffer_unordered(prefilter_concurrency)
314        .filter_map(|c| async move { c })
315        .collect()
316        .await;
317
318    let candidates_checked = survivors.len();
319
320    let mut registered: Vec<RegisteredLookalike> = stream::iter(survivors)
321        .map(|cand| async move {
322            let result = lookup.lookup(&cand.domain).await.ok()?;
323            let info = DomainInfo::from_lookup_result(&result);
324            if info.source == DomainInfoSource::Available {
325                return None;
326            }
327            Some(RegisteredLookalike {
328                domain: cand.domain,
329                technique: cand.technique,
330                registrar: info.registrar,
331                creation_date: info.creation_date,
332                nameservers: info.nameservers,
333            })
334        })
335        .buffer_unordered(concurrency)
336        .filter_map(|r| async move { r })
337        .collect()
338        .await;
339
340    // Freshly-registered squats are the most actionable — sort newest first,
341    // undated entries last.
342    registered.sort_by(|a, b| match (b.creation_date, a.creation_date) {
343        (Some(bd), Some(ad)) => bd.cmp(&ad),
344        (Some(_), None) => std::cmp::Ordering::Less,
345        (None, Some(_)) => std::cmp::Ordering::Greater,
346        (None, None) => a.domain.cmp(&b.domain),
347    });
348    (registered, candidates_checked)
349}
350
351/// Generates look-alike candidates for `domain` and scores which are
352/// registered, returning a ranked [`ConfusableReport`].
353pub async fn find_confusables(
354    lookup: &SmartLookup,
355    domain: &str,
356    concurrency: usize,
357) -> Result<ConfusableReport> {
358    let domain = normalize_domain(domain)?;
359    let candidates = generate_candidates(&domain);
360    let candidates_generated = candidates.len();
361    let (registered, candidates_checked) = score_candidates(lookup, candidates, concurrency).await;
362    Ok(ConfusableReport {
363        domain,
364        candidates_generated,
365        candidates_checked,
366        registered,
367    })
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    fn domains(candidates: &[ConfusableCandidate]) -> Vec<&str> {
375        candidates.iter().map(|c| c.domain.as_str()).collect()
376    }
377
378    #[test]
379    fn generates_omission_transposition_and_tld_swaps() {
380        let cands = generate_candidates("example.com");
381        let d = domains(&cands);
382        // Omission of the leading 'e'.
383        assert!(d.contains(&"xample.com"), "omission missing");
384        // Transposition of 'xa' -> 'ax'.
385        assert!(d.contains(&"eaxmple.com"), "transposition missing");
386        // TLD swap.
387        assert!(d.contains(&"example.net"), "tld-swap missing");
388        // Homoglyph e -> 3.
389        assert!(d.contains(&"3xample.com"), "homoglyph missing");
390    }
391
392    #[test]
393    fn never_includes_the_original_and_is_deduped() {
394        let cands = generate_candidates("example.com");
395        assert!(!cands.iter().any(|c| c.domain == "example.com"));
396        let mut uniq = std::collections::HashSet::new();
397        for c in &cands {
398            assert!(uniq.insert(&c.domain), "duplicate candidate: {}", c.domain);
399        }
400    }
401
402    #[test]
403    fn preserves_subdomains_and_only_permutes_registrable_label() {
404        let cands = generate_candidates("mail.example.com");
405        // Every candidate (except tld-swaps) keeps the "mail." subdomain and
406        // ".com" tld; the middle label is what varies.
407        assert!(cands
408            .iter()
409            .any(|c| c.domain == "mail.xample.com" && c.technique == "omission"));
410        assert!(cands
411            .iter()
412            .all(|c| c.domain.starts_with("mail.") || c.technique == "tld-swap"));
413    }
414
415    #[test]
416    fn candidate_count_is_capped() {
417        // A long label produces many insertions; the cap must hold.
418        let cands = generate_candidates("averylongexamplelabelname.com");
419        assert!(cands.len() <= MAX_CANDIDATES);
420    }
421
422    #[test]
423    fn variants_are_valid_labels() {
424        // No candidate label may start/end with a hyphen or be empty.
425        for c in generate_candidates("ab.com") {
426            let label = c.domain.rsplit_once('.').unwrap().0;
427            assert!(!label.is_empty());
428            assert!(!label.starts_with('-') && !label.ends_with('-'));
429        }
430    }
431
432    #[test]
433    fn single_label_input_yields_nothing() {
434        assert!(generate_candidates("localhost").is_empty());
435    }
436
437    #[test]
438    fn prefilter_drops_only_nxdomain_candidates() {
439        // NXDOMAIN (Absent) is the unregistered signal → drop it. A delegated
440        // apex (Present) and a failed probe (Unknown) both survive to the full
441        // lookup; a failed probe is not evidence, mirroring the smart-lookup
442        // thin-fallback ladder.
443        assert!(!survives_prefilter(DnsPresence::Absent));
444        assert!(survives_prefilter(DnsPresence::Present));
445        assert!(survives_prefilter(DnsPresence::Unknown));
446    }
447}