Skip to main content

sioul_core/
lookalike.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Senders who borrow a name: "lnfos PrimeVlDEO <info@unrelated.example>".
5//!
6//! A signature proves the domain, not the name shown before it. Phishing
7//! writes a brand or a public service in the display name, often with
8//! lookalike letters (l for I, 0 for O, rn for m, Cyrillic а for a), and sends
9//! from any domain. Names are compared by their skeleton, as Unicode's
10//! confusable detection does (UTS #39 §4): both sides are reduced to one
11//! prototype per family of lookalike letters. A brand named by a sender
12//! outside its own domains is an impersonation. A brand whose name is also
13//! an everyday word or a place (`BrandWords::everyday`: Orange, Apple, La
14//! Poste…) is borrowed only when its name stands alone, or beside the words
15//! of a service ("Apple Support", "Amazon.com"), so that "Orange County
16//! Library" or "Apple Pie Bakery" borrow nothing; any other brand counts
17//! anywhere in a name ("Crédit Agricole Nord de France", "PayPal Kundendienst").
18
19/// A display name claiming a brand, or one of your own domains, that its address does not belong to.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Impersonation {
22    pub brand: String,
23    pub domain: String,
24}
25
26/// The domains of your own addresses that can be impersonated: not those of
27/// mail providers millions share (`shared`, `words::BrandWords::shared`),
28/// where an address makes the domain nobody's own.
29pub fn own_domains<'a>(shared: &[String], addresses: impl IntoIterator<Item = &'a str>) -> Vec<String> {
30    let mut domains: Vec<String> = addresses
31        .into_iter()
32        .filter_map(|a| a.rsplit_once('@').map(|(_, d)| d.to_ascii_lowercase()))
33        .filter(|d| !shared.iter().any(|s| s.trim().eq_ignore_ascii_case(d)))
34        .collect();
35    domains.sort();
36    domains.dedup();
37    domains
38}
39
40/// Whether a sender's display name claims a brand, or one of `own` (your domains),
41/// from outside its domains. `brands`: the brands and public services, by the
42/// words of their name, and their domains (a sender in one of them or below
43/// it is genuine), and the words of a service, which a fake name wraps a brand
44/// or a domain in ("PayPal Service", "janedoe.example Mail Admin";
45/// `words::BrandWords`). A brand is claimed when its words are in the name
46/// (`names`); an everyday one, when its name stands alone or beside such
47/// words only (`borrows`).
48pub fn impersonation(brands: &crate::words::BrandWords, name: Option<&str>, domain: Option<&str>, own: &[String]) -> Option<Impersonation> {
49    let name = name?;
50    let domain = domain?.to_ascii_lowercase();
51    let raw = words(name);
52    let words: Vec<String> = raw.iter().map(|w| skeleton(w)).collect();
53    let service: Vec<String> = brands.service_words.iter().map(|w| skeleton(w)).collect();
54    let everyday: Vec<String> = brands.everyday.iter().map(|b| crate::words::folded(b)).collect();
55    let claims = |brand: &str, domains: &[String]| if everyday.contains(&crate::words::folded(brand)) { borrows(&raw, &words, brand, domains, &service) } else { names(&words, brand) };
56    let inside = |domains: &[String]| domains.iter().map(|d| d.trim().to_ascii_lowercase()).any(|d| !d.is_empty() && (domain == d || domain.ends_with(&format!(".{d}"))));
57    let brand = brands
58        .brands
59        .iter()
60        .filter(|(brand, domains)| claims(brand, domains))
61        .find(|(_, domains)| !inside(domains))
62        .map(|(brand, _)| Impersonation { brand: brand.to_string(), domain: domain.clone() });
63    brand.or_else(|| {
64        own.iter()
65            .find(|mine| !inside(std::slice::from_ref(mine)) && claims_own(&brands.service_words, &words, mine))
66            .map(|mine| Impersonation { brand: mine.clone(), domain: domain.clone() })
67    })
68}
69
70/// A name made of nothing but your domain and service words: "janedoe",
71/// "Mail Admin janedoe.example". Your own name in full ("Jane Doe") is not
72/// your domain run together, and a name with any other word is someone's.
73fn claims_own(service_words: &[String], words: &[String], domain: &str) -> bool {
74    let labels: Vec<String> = domain.split('.').map(skeleton).collect();
75    let main = &labels[0];
76    let generic: Vec<String> = service_words.iter().map(|w| skeleton(w)).collect();
77    words.iter().any(|w| w == main) && words.iter().all(|w| labels.contains(w) || generic.contains(w))
78}
79
80/// Whether the brand's words appear in the name: one after the other, or run together.
81fn names(words: &[String], brand: &str) -> bool {
82    let wanted: Vec<String> = words_of_brand(brand);
83    let joined = wanted.concat();
84    let as_sequence = words.windows(wanted.len()).any(|w| w == wanted.as_slice());
85    let run_together = words.iter().any(|w| *w == joined);
86    as_sequence || run_together
87}
88
89/// Whether a name claims an everyday brand: its words are there, one after the other
90/// or run together, and every other word of the name is a word of a service
91/// (`service`, skeletons: "Support", "Service client", "Infos"), a number
92/// ("Microsoft 365"), or a label of the brand's own domains ("Amazon.com",
93/// "PayPal.fr"). The brand's name standing among other words ("Orange County
94/// Library", "Apple Pie Bakery", "Café de la Poste") is someone else's name.
95/// `raw`: the name's words as written; `words`: their skeletons.
96fn borrows(raw: &[String], words: &[String], brand: &str, domains: &[String], service: &[String]) -> bool {
97    let wanted: Vec<String> = words_of_brand(brand);
98    if wanted.is_empty() {
99        return false;
100    }
101    let joined = wanted.concat();
102    // The words that are the brand's, wherever it is named.
103    let mut theirs = vec![false; words.len()];
104    let mut at = 0;
105    while at < words.len() {
106        if words[at..].starts_with(&wanted) {
107            theirs[at..at + wanted.len()].iter_mut().for_each(|t| *t = true);
108            at += wanted.len();
109        } else {
110            theirs[at] |= words[at] == joined;
111            at += 1;
112        }
113    }
114    if !theirs.contains(&true) {
115        return false;
116    }
117    let labels: Vec<String> = domains.iter().flat_map(|d| d.split('.').map(skeleton).collect::<Vec<_>>()).filter(|l| !l.is_empty()).collect();
118    words.iter().zip(raw).zip(&theirs).all(|((word, written), &brand)| brand || service.contains(word) || labels.contains(word) || written.chars().all(|c| c.is_ascii_digit()))
119}
120
121fn words_of_brand(brand: &str) -> Vec<String> {
122    words(brand).iter().map(|w| skeleton(w)).collect()
123}
124
125/// Words of a name, split on everything that is not a letter or a digit.
126fn words(text: &str) -> Vec<String> {
127    text.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).map(str::to_string).collect()
128}
129
130/// One prototype per family of lookalike letters, lowercase and without accents:
131/// "PrimeVlDEO" and "Prime Video" share "prlmevldeo".
132fn skeleton(word: &str) -> String {
133    let lower: String = word.chars().map(crate::text::fold_char).flat_map(char::to_lowercase).map(prototype).collect();
134    lower.replace("rn", "m").replace("vv", "w")
135}
136
137fn prototype(c: char) -> char {
138    match c {
139        'i' | 'l' | '1' | '|' | 'ı' | 'ӏ' | 'і' | 'ι' => 'l',
140        '0' | 'о' | 'ο' => 'o',
141        'а' | 'α' => 'a',
142        'е' | 'ε' => 'e',
143        'р' | 'ρ' => 'p',
144        'с' | 'ϲ' => 'c',
145        'у' => 'y',
146        'х' | 'χ' => 'x',
147        'ѕ' => 's',
148        'ԁ' => 'd',
149        'ν' => 'v',
150        '5' => 's',
151        other => other,
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    /// With the packs built in (French and English, France).
160    fn impersonation(name: Option<&str>, domain: Option<&str>, own: &[String]) -> Option<Impersonation> {
161        super::impersonation(&crate::words::Words::builtin().brands, name, domain, own)
162    }
163
164    fn own_domains<'a>(addresses: impl IntoIterator<Item = &'a str>) -> Vec<String> {
165        super::own_domains(&crate::words::Words::builtin().brands.shared, addresses)
166    }
167
168    #[test]
169    fn a_brand_of_yours_added() {
170        let config: crate::config::Config = toml::from_str("[words]\nlanguages = [\"en\"]\ncountries = []\n[words.brands.brands]\nadd = { \"Banque Exemple\" = [\"banque-exemple.example\"] }\n").unwrap();
171        let yours = crate::words::Words::of(&config);
172        assert!(impersonation(Some("Banque Exemple"), Some("phish.example"), &[]).is_none());
173        assert!(super::impersonation(&yours.brands, Some("Banque Exemple Sécurité"), Some("phish.example"), &[]).is_some());
174        assert!(super::impersonation(&yours.brands, Some("Banque Exemple"), Some("mail.banque-exemple.example"), &[]).is_none());
175        // Without France's names, its public bodies are no brands.
176        assert!(super::impersonation(&yours.brands, Some("Assurance Maladie"), Some("remboursement.example"), &[]).is_none());
177    }
178
179    #[test]
180    fn borrowed_names_are_caught() {
181        let fake = impersonation(Some("lnfos PrimeVlDEO"), Some("unrelated.example"), &[]).unwrap();
182        assert_eq!(fake.brand, "Prime Video");
183        assert!(impersonation(Some("PayPaI Service"), Some("pay-secure.example"), &[]).is_some());
184        assert!(impersonation(Some("Аmazon"), Some("deals.example"), &[]).is_some(), "Cyrillic А");
185        assert!(impersonation(Some("Assurance Maladie"), Some("remboursement.example"), &[]).is_some());
186    }
187
188    #[test]
189    fn your_own_domain_borrowed() {
190        let own = own_domains(["jean@jeanexemple.example", "jean.exemple@gmail.com"]);
191        assert_eq!(own, vec!["jeanexemple.example".to_string()]);
192        let fake = impersonation(Some("jeanexemple"), Some("sales.example"), &own).unwrap();
193        assert_eq!((fake.brand.as_str(), fake.domain.as_str()), ("jeanexemple.example", "sales.example"));
194        assert!(impersonation(Some("Mail Admin jeanexemple.example"), Some("sales.example"), &own).is_some());
195        // Your host's own mail, your full name, your own server: not impersonations.
196        assert_eq!(impersonation(Some("Hosting Co - jeanexemple.example"), Some("host.example"), &own), None);
197        assert_eq!(impersonation(Some("Jean Exemple"), Some("sales.example"), &own), None);
198        assert_eq!(impersonation(Some("cPanel on jeanexemple.example"), Some("jeanexemple.example"), &own), None);
199    }
200
201    /// Every regional bank of Crédit Agricole mails from its own domain, and
202    /// names the group: not a borrowed name (the France pack lists them all).
203    #[test]
204    fn a_regional_bank_names_its_group() {
205        for domain in ["ca-norddefrance.fr", "mail.ca-languedoc.fr", "ca-reunion.fr", "ca-paris.fr", "credit-agricole.com"] {
206            assert_eq!(impersonation(Some("Crédit Agricole"), Some(domain), &[]), None, "{domain}");
207        }
208        assert!(impersonation(Some("Credit Agricole"), Some("ca-securite.example"), &[]).is_some());
209        assert!(impersonation(Some("Crédit Agricole Nord de France"), Some("ca-nordest.fr"), &[]).is_some(), "a domain that sends no mail is not one of theirs");
210    }
211
212    /// A brand whose name is an everyday word or a place counts alone, or
213    /// beside the words of a service only; among other words, it is someone
214    /// else's name. Any other brand counts anywhere in a name.
215    #[test]
216    fn an_everyday_brand_counts_alone_or_with_its_service() {
217        // The true impersonations: an everyday brand alone, with service words, with its own domain's labels.
218        for name in ["Apple", "Apple Support", "Amazon.com", "Amazon Customer Service", "Service client Orange", "Orange - Votre espace client", "UPS Delivery Notification", "Outlook Team", "Infos La Poste", "Service des Impôts des Particuliers"] {
219            assert!(impersonation(Some(name), Some("phish.example"), &[]).is_some(), "{name}");
220        }
221        // Any other brand, anywhere in the name, as before.
222        for name in ["Crédit Agricole Nord de France", "PayPal Kundendienst", "Microsoft 365", "Netflix Billing", "PayPal.fr Sécurité", "Mon conseiller BNP Paribas"] {
223            assert!(impersonation(Some(name), Some("phish.example"), &[]).is_some(), "{name}");
224        }
225        // Everyday words and places, from their own domains.
226        for name in ["Orange County Library", "Apple Pie Bakery", "Amazon Rainforest Trust", "Weekly Outlook", "Ups and Downs Club", "Café de la Poste", "Jean Orange", "Cabinet Martin, conseil en impôts", "Société Générale de Transports"] {
227            assert_eq!(impersonation(Some(name), Some("their-own.example"), &[]), None, "{name}");
228        }
229        // Yours to mark: a brand of yours made everyday spares a name it stands in.
230        let config: crate::config::Config = toml::from_str("[words]\nlanguages = [\"en\"]\ncountries = []\n[words.brands.brands]\nadd = { \"Banque Exemple\" = [\"banque-exemple.example\"] }\n[words.brands.everyday]\nadd = [\"Banque Exemple\"]\n").unwrap();
231        let yours = crate::words::Words::of(&config);
232        assert!(super::impersonation(&yours.brands, Some("Banque Exemple Bakery"), Some("bakery.example"), &[]).is_none());
233        assert!(super::impersonation(&yours.brands, Some("Banque Exemple Support"), Some("phish.example"), &[]).is_some());
234    }
235
236    #[test]
237    fn genuine_senders_and_other_names_pass() {
238        assert_eq!(impersonation(Some("PayPal"), Some("news.paypal.com"), &[]), None);
239        assert_eq!(impersonation(Some("Votre Assurance Maladie"), Some("app.ameli.fr"), &[]), None);
240        assert_eq!(impersonation(Some("service@paypal.fr"), Some("paypal.fr"), &[]), None);
241        assert_eq!(impersonation(Some("Médfrance"), Some("example.org"), &[]), None);
242        assert_eq!(impersonation(Some("Freedom Mobile Club"), Some("example.org"), &[]), None);
243        assert_eq!(impersonation(Some("Jean Exemple"), Some("example.org"), &[]), None);
244        assert_eq!(impersonation(None, Some("example.org"), &[]), None);
245    }
246}