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. Collected separately so the
256    // cap below cannot drop them: label permutations grow with label length
257    // (insertion alone is 26×(L+1)), and a plain tail truncation silently
258    // discarded every tld-swap for ~16+ char labels.
259    let mut swaps = Vec::new();
260    for &swap in SWAP_TLDS {
261        if swap != tld {
262            let candidate = match sub {
263                Some(sub) => format!("{sub}.{label}.{swap}"),
264                None => format!("{label}.{swap}"),
265            };
266            if candidate != normalized && seen.insert(candidate.clone()) {
267                swaps.push(ConfusableCandidate {
268                    domain: candidate,
269                    technique: "tld-swap".to_string(),
270                });
271            }
272        }
273    }
274
275    // Budget the cap: tld-swaps (a dozen at most) always fit; the label
276    // permutations absorb the truncation.
277    out.truncate(MAX_CANDIDATES.saturating_sub(swaps.len()));
278    out.extend(swaps);
279    out
280}
281
282/// Whether a candidate survives the DNS presence pre-filter and warrants a
283/// full registry lookup. `Absent` (NXDOMAIN) is treated as unregistered and
284/// dropped; `Unknown` (a failed probe) is not evidence, so it is kept — this
285/// mirrors the smart-lookup thin-fallback ladder's handling of a failed probe.
286fn survives_prefilter(presence: DnsPresence) -> bool {
287    !matches!(presence, DnsPresence::Absent)
288}
289
290/// Scores which `candidates` are registered.
291///
292/// Candidates are first pre-filtered by a cheap DNS presence probe: those that
293/// return `NXDOMAIN` are unregistered and dropped without a registry lookup
294/// (see the module docs). The survivors get a full smart lookup and are kept
295/// when the merged [`DomainInfo`] reports a non-`Available` source.
296///
297/// Returns the ranked registered look-alikes together with the number of
298/// candidates that passed the pre-filter and received a full lookup — the
299/// accurate `candidates_checked` figure for the report.
300///
301/// The full lookups run up to `concurrency` at a time; the pre-filter probes
302/// run at [`PREFILTER_CONCURRENCY`].
303pub async fn score_candidates(
304    lookup: &SmartLookup,
305    candidates: Vec<ConfusableCandidate>,
306    concurrency: usize,
307) -> (Vec<RegisteredLookalike>, usize) {
308    use futures::stream::{self, StreamExt};
309
310    let concurrency = concurrency.max(1);
311
312    // Pre-filter: probe DNS presence and drop NXDOMAIN candidates before any
313    // registry query. A wider fan-out is fine here — DNS is far cheaper than a
314    // full RDAP+WHOIS race.
315    let prefilter_concurrency = concurrency.max(PREFILTER_CONCURRENCY);
316    let survivors: Vec<ConfusableCandidate> = stream::iter(candidates)
317        .map(|cand| async move {
318            survives_prefilter(lookup.presence(&cand.domain).await).then_some(cand)
319        })
320        .buffer_unordered(prefilter_concurrency)
321        .filter_map(|c| async move { c })
322        .collect()
323        .await;
324
325    let candidates_checked = survivors.len();
326
327    let mut registered: Vec<RegisteredLookalike> = stream::iter(survivors)
328        .map(|cand| async move {
329            let result = lookup.lookup(&cand.domain).await.ok()?;
330            let info = DomainInfo::from_lookup_result(&result);
331            if info.source == DomainInfoSource::Available {
332                return None;
333            }
334            Some(RegisteredLookalike {
335                domain: cand.domain,
336                technique: cand.technique,
337                registrar: info.registrar,
338                creation_date: info.creation_date,
339                nameservers: info.nameservers,
340            })
341        })
342        .buffer_unordered(concurrency)
343        .filter_map(|r| async move { r })
344        .collect()
345        .await;
346
347    // Freshly-registered squats are the most actionable — sort newest first,
348    // undated entries last.
349    registered.sort_by(|a, b| match (b.creation_date, a.creation_date) {
350        (Some(bd), Some(ad)) => bd.cmp(&ad),
351        (Some(_), None) => std::cmp::Ordering::Less,
352        (None, Some(_)) => std::cmp::Ordering::Greater,
353        (None, None) => a.domain.cmp(&b.domain),
354    });
355    (registered, candidates_checked)
356}
357
358/// Generates look-alike candidates for `domain` and scores which are
359/// registered, returning a ranked [`ConfusableReport`].
360pub async fn find_confusables(
361    lookup: &SmartLookup,
362    domain: &str,
363    concurrency: usize,
364) -> Result<ConfusableReport> {
365    let domain = normalize_domain(domain)?;
366    let candidates = generate_candidates(&domain);
367    let candidates_generated = candidates.len();
368    let (registered, candidates_checked) = score_candidates(lookup, candidates, concurrency).await;
369    Ok(ConfusableReport {
370        domain,
371        candidates_generated,
372        candidates_checked,
373        registered,
374    })
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    fn domains(candidates: &[ConfusableCandidate]) -> Vec<&str> {
382        candidates.iter().map(|c| c.domain.as_str()).collect()
383    }
384
385    #[test]
386    fn generates_omission_transposition_and_tld_swaps() {
387        let cands = generate_candidates("example.com");
388        let d = domains(&cands);
389        // Omission of the leading 'e'.
390        assert!(d.contains(&"xample.com"), "omission missing");
391        // Transposition of 'xa' -> 'ax'.
392        assert!(d.contains(&"eaxmple.com"), "transposition missing");
393        // TLD swap.
394        assert!(d.contains(&"example.net"), "tld-swap missing");
395        // Homoglyph e -> 3.
396        assert!(d.contains(&"3xample.com"), "homoglyph missing");
397    }
398
399    #[test]
400    fn never_includes_the_original_and_is_deduped() {
401        let cands = generate_candidates("example.com");
402        assert!(!cands.iter().any(|c| c.domain == "example.com"));
403        let mut uniq = std::collections::HashSet::new();
404        for c in &cands {
405            assert!(uniq.insert(&c.domain), "duplicate candidate: {}", c.domain);
406        }
407    }
408
409    #[test]
410    fn preserves_subdomains_and_only_permutes_registrable_label() {
411        let cands = generate_candidates("mail.example.com");
412        // Every candidate (except tld-swaps) keeps the "mail." subdomain and
413        // ".com" tld; the middle label is what varies.
414        assert!(cands
415            .iter()
416            .any(|c| c.domain == "mail.xample.com" && c.technique == "omission"));
417        assert!(cands
418            .iter()
419            .all(|c| c.domain.starts_with("mail.") || c.technique == "tld-swap"));
420    }
421
422    #[test]
423    fn candidate_count_is_capped() {
424        // A long label produces many insertions; the cap must hold.
425        let cands = generate_candidates("averylongexamplelabelname.com");
426        assert!(cands.len() <= MAX_CANDIDATES);
427    }
428
429    #[test]
430    fn tld_swaps_survive_the_cap_for_long_labels() {
431        // tld-swaps are generated last; a naive tail truncation dropped every
432        // one of them for ~16+ char labels (insertion alone is 26×(L+1)),
433        // silently disabling one of the most common real squat patterns for
434        // exactly the brand names most worth checking (2026-07-11 review).
435        for domain in ["wellsfargobanking.com", "averylongexamplelabelname.com"] {
436            let cands = generate_candidates(domain);
437            assert!(cands.len() <= MAX_CANDIDATES);
438            let swaps = cands.iter().filter(|c| c.technique == "tld-swap").count();
439            assert_eq!(
440                swaps,
441                SWAP_TLDS.len() - 1, // minus the domain's own TLD
442                "{domain}: every tld-swap candidate must survive the cap"
443            );
444        }
445    }
446
447    #[test]
448    fn variants_are_valid_labels() {
449        // No candidate label may start/end with a hyphen or be empty.
450        for c in generate_candidates("ab.com") {
451            let label = c.domain.rsplit_once('.').unwrap().0;
452            assert!(!label.is_empty());
453            assert!(!label.starts_with('-') && !label.ends_with('-'));
454        }
455    }
456
457    #[test]
458    fn single_label_input_yields_nothing() {
459        assert!(generate_candidates("localhost").is_empty());
460    }
461
462    #[test]
463    fn prefilter_drops_only_nxdomain_candidates() {
464        // NXDOMAIN (Absent) is the unregistered signal → drop it. A delegated
465        // apex (Present) and a failed probe (Unknown) both survive to the full
466        // lookup; a failed probe is not evidence, mirroring the smart-lookup
467        // thin-fallback ladder.
468        assert!(!survives_prefilter(DnsPresence::Absent));
469        assert!(survives_prefilter(DnsPresence::Present));
470        assert!(survives_prefilter(DnsPresence::Unknown));
471    }
472}