1#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Impersonation {
22 pub brand: String,
23 pub domain: String,
24}
25
26pub 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
40pub 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
70fn 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
80fn 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
89fn 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 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
125fn 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
130fn 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 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 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 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 #[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 #[test]
216 fn an_everyday_brand_counts_alone_or_with_its_service() {
217 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 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 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 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}