use crate::extractor::{levenshtein, registrable_domain, BRAND_CANONICAL, MULTI_PART_TLDS};
use crate::model::Model;
use crate::predictor::predict_forest;
use std::collections::HashSet;
use std::sync::OnceLock;
const WHITELIST_BYTES: &[u8] = include_bytes!("../resources/whitelist.bin");
const NORMAL_BASE: f32 = 0.1;
const PHISH_BASE: f32 = 0.5;
pub(crate) const THRESHOLD: f32 = 0.20;
const BRAND_THRESHOLD: f32 = 0.6;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Stage1Category {
Normal,
Phishing,
Grey,
}
pub struct Stage1Analysis {
pub category: Stage1Category,
pub base: f32,
pub reason: Option<&'static str>,
}
const HIGH_RISK_TLDS: &[&str] = &[
"accountant", "bid", "bond", "cf", "cfd", "click", "country", "cyou", "date", "download",
"forex", "gdn", "gq", "ga", "host", "icu", "loan", "men", "ml", "monster", "party", "pro",
"racing", "rest", "review", "science", "sbs", "stream", "tk", "top", "trade", "win", "xyz",
"zip",
];
const TLD_NEVER_RISKY: &[&str] = &[
"app", "ar", "asia", "au", "bd", "br", "buzz", "ca", "ci", "cl", "cm", "cn", "co", "co.in",
"co.jp", "co.uk", "com.au", "de", "dev", "edu", "fr", "gh", "gg", "gov", "gov.uk", "gs", "im",
"in", "ir", "je", "jp", "ke", "kz", "kiwi", "lk", "link", "me", "ms", "mx", "net", "ng",
"ninja", "np", "org", "org.uk", "pages", "pe", "ph", "pk", "sh", "sn", "st", "tc", "tl", "tv",
"tz", "ug", "uk", "us", "vc", "ve", "ws", "work", "za", "to",
];
const HOSTING_PLATFORMS: &[&str] = &[
"vercel.app", "vercel.dev", "pages.dev", "r2.dev", "workers.dev", "github.io", "gitlab.io",
"gitbook.io", "webflow.io", "wixstudio.com", "weeblysite.com", "weebly.com", "framer.app",
"square.site", "ukit.me", "webwave.dev", "sviluppo.host", "teachable.com", "webcindario.com",
"jotform.com", "hsforms.com", "campaign-archive.com", "firebaseapp.com", "web.app",
"azurewebsites.net", "herokuapp.com", "netlify.app", "netlify.com", "render.com", "surge.sh",
"glitch.me", "replit.app", "replit.dev", "pythonanywhere.com", "ngrok.io", "ngrok.app",
"tunnel.me", "neocities.org", "tumblr.com", "substack.com", "medium.com", "wordpress.com",
"wordpress.org", "blogspot.com", "blogspot.co.uk", "strikingly.com", "carrd.co",
"googlepages.com", "spruz.com", "wix.com", "yolasite.com", "webs.com", "bravenet.com",
"x10host.com", "000webhostapp.com", "infinityfree.net", "epizy.com", "gotpantheon.com",
"fly.dev", "deno.dev", "observablehq.com", "hashnode.dev", "bitbucket.org", "discordapp.com",
"webnode.com", "wixsite.com", "webself.net", "simplesite.com", "snack.ws", "altervista.org",
"angelfire.com", "tripod.com", "lycos.com", "fc2.com", "livejournal.com", "blogspot.jp",
"weebly.co", "ucraft.me", "webstarts.com", "simdif.com", "bit.ly", "tinyurl.com", "t.co",
"goo.gl", "qrco.de", "q-r.to", "urlz.fr", "ln.run", "ead.me", "wl.co", "tiny.cc", "ow.ly",
"buff.ly", "is.gd", "cutt.ly", "rebrand.ly", "shorturl.at", "rb.gy", "bit.do", "soo.gd",
"clck.ru", "cli.gs", "fur.ly", "tinyarrows.com", "tr.im", "x.co", "snip.ly", "bl.ink",
"short.io", "reurl.cc", "t.cn", "dwz.cn", "suo.im", "ddns.net", "ddns.info", "zapto.org",
"no-ip.org", "servehttp.com", "servegame.com", "hopto.org", "myftp.org", "dyndns.org",
"freeddns.org", "duckdns.org", "noip.me", "gotdns.ch", "selfhost.net", "dnsalias.com",
"mediafire.com", "sendgrid.net", "zendesk.com", "dropbox.com", "box.com", "drive.google.com",
"docs.google.com", "arweave.net", "dweb.link", "ipfs.io", "ipfs.dweb.link",
"cloudflare-ipfs.com", "s3.amazonaws.com", "amazonaws.com", "googleusercontent.com",
"cloudfront.net", "appspot.com", "storage.googleapis.com", "blob.core.windows.net",
"r2.cloudflarestorage.com", "transfer.sh", "file.io", "anonfiles.com", "mega.nz",
"4shared.com", "wetransfer.com", "sendspace.com", "zippyshare.com", "mediafire.co",
"start.page", "linktr.ee", "lnk.bio", "notion.site", "pixelfed.social", "teletype.in",
"launch.app", "biodrop.io", "surl.li", "shorter.me", "short.gy", "shorten.is", "did.li",
"t.ly", "flow.page", "teemill.com", "godaddysites.com", "pantheonsite.io", "mystrikingly.com",
"framer.website", "framer.ai", "ghost.io", "w3spaces.com", "gofile.io", "windows.net",
"tmpfiles.org", "webnode.page", "strikingly.com", "brand.site", "sites.google.com",
"padlet.com", "canva.com", "beacons.ai", "bio.link", "lnk.bio",
];
const COMMON_SUBDOMAINS: &[&str] = &[
"www", "www2", "m", "mobile", "mail", "email", "webmail", "maps", "drive", "docs",
"documents", "accounts", "account", "api", "blog", "shop", "store", "my", "go", "app",
"admin", "support", "help", "news", "en", "cdn", "static", "assets", "img", "images", "image",
"media", "dev", "staging", "beta", "portal", "calendar", "cal", "groups", "sites", "plus",
"translate", "photos", "play", "music", "tv", "cloud", "one", "vpn", "learn", "login",
"log-in", "secure", "pay", "payment", "auth", "web", "online", "live", "home", "main", "site",
"us", "uk", "eu",
];
fn hosting_set() -> &'static HashSet<&'static str> {
static S: OnceLock<HashSet<&'static str>> = OnceLock::new();
S.get_or_init(|| HOSTING_PLATFORMS.iter().copied().collect())
}
fn high_risk_set() -> &'static HashSet<&'static str> {
static S: OnceLock<HashSet<&'static str>> = OnceLock::new();
S.get_or_init(|| HIGH_RISK_TLDS.iter().copied().collect())
}
fn never_risky_set() -> &'static HashSet<&'static str> {
static S: OnceLock<HashSet<&'static str>> = OnceLock::new();
S.get_or_init(|| TLD_NEVER_RISKY.iter().copied().collect())
}
fn common_subdomain_set() -> &'static HashSet<&'static str> {
static S: OnceLock<HashSet<&'static str>> = OnceLock::new();
S.get_or_init(|| COMMON_SUBDOMAINS.iter().copied().collect())
}
fn whitelist_store() -> &'static Vec<&'static str> {
static LIST: OnceLock<Vec<&'static str>> = OnceLock::new();
LIST.get_or_init(|| {
let bytes = WHITELIST_BYTES;
if bytes.len() < 4 {
return Vec::new();
}
let count = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize;
let mut domains = Vec::with_capacity(count);
let mut pos = 4usize;
for _ in 0..count {
if pos + 2 > bytes.len() {
break;
}
let len =
u16::from_le_bytes([bytes[pos], bytes[pos + 1]]) as usize;
pos += 2;
if pos + len > bytes.len() {
break;
}
let s = std::str::from_utf8(&bytes[pos..pos + len])
.expect("whitelist domain is not valid UTF-8");
domains.push(s);
pos += len;
}
domains
})
}
fn is_whitelisted(reg: &str) -> bool {
whitelist_store().binary_search(®).is_ok()
}
fn host_of(url: &str) -> String {
let no_frag = match url.find('#') {
Some(p) => &url[..p],
None => url,
};
let s = no_frag.to_lowercase();
let after = match s.find("://") {
Some(p) => &s[p + 3..],
None => &s[..],
};
let end = after
.find(|c| ['/', '?', '#'].contains(&c))
.unwrap_or(after.len());
let mut host = &after[..end];
if let Some(at) = host.rfind('@') {
host = &host[at + 1..];
}
if let Some(colon) = host.rfind(':') {
host = &host[..colon];
}
host.to_string()
}
fn subdomain_of(host: &str, reg: &str) -> String {
if host == reg {
return String::new();
}
let suffix = format!(".{}", reg);
if let Some(stripped) = host.strip_suffix(&suffix) {
return stripped.to_string();
}
if let Some(stripped) = host.strip_suffix(reg) {
return stripped.trim_end_matches('.').to_string();
}
host.to_string()
}
fn tld_key_of(reg: &str) -> String {
let labels: Vec<&str> = reg.split('.').collect();
let tld = if labels.len() >= 2 {
format!("{}.{}", labels[labels.len() - 2], labels[labels.len() - 1])
} else {
labels.last().copied().unwrap_or("").to_string()
};
if MULTI_PART_TLDS.contains(&tld.as_str()) {
tld
} else {
labels.last().copied().unwrap_or("").to_string()
}
}
fn brand_impersonation_score(reg: &str) -> f32 {
let reg_sld = reg.split('.').next().unwrap_or("");
let mut best = 0.0f32;
for (_keyword, canonical) in BRAND_CANONICAL {
if reg == *canonical {
return 0.0; }
let sld = canonical.split('.').next().unwrap_or(canonical);
let sld_len = sld.chars().count().max(1) as i32;
let max_dist = 2.max(sld_len / 4);
let d = levenshtein(reg_sld, sld) as i32;
let len_diff = (reg_sld.chars().count() as i32 - sld_len).abs();
let mut sim = if d <= max_dist && len_diff <= 3 {
1.0 - d as f32 / sld_len as f32
} else {
0.0
};
for tok in reg_sld.split('-') {
if tok.is_empty() {
continue;
}
let dt = levenshtein(tok, sld) as i32;
let lt = (tok.chars().count() as i32 - sld_len).abs();
if dt <= max_dist && lt <= 3 {
sim = sim.max(1.0 - dt as f32 / sld_len as f32);
}
}
if sim > best {
best = sim;
}
}
best
}
pub fn analyze_stage1(url: &str) -> Stage1Analysis {
let host = host_of(url);
let reg = registrable_domain(&host);
let (category, base, reason) = stage1_verdict(®, &host);
Stage1Analysis {
category,
base,
reason,
}
}
fn stage1_verdict(reg: &str, host: &str) -> (Stage1Category, f32, Option<&'static str>) {
if hosting_set().contains(reg) {
return (Stage1Category::Grey, 0.0, None);
}
let bsim = brand_impersonation_score(reg);
if bsim >= BRAND_THRESHOLD {
return (
Stage1Category::Phishing,
PHISH_BASE,
Some("Domain closely impersonates a known brand"),
);
}
let tld_key = tld_key_of(reg);
if !never_risky_set().contains(tld_key.as_str()) && high_risk_set().contains(tld_key.as_str()) {
return (
Stage1Category::Phishing,
PHISH_BASE,
Some("Uses a high-risk top-level domain"),
);
}
if is_whitelisted(reg) {
let sub = subdomain_of(host, reg);
let benign_sub = sub.is_empty()
|| common_subdomain_set().contains(sub.as_str())
|| sub.split('.').all(|p| common_subdomain_set().contains(p));
if benign_sub {
return (
Stage1Category::Normal,
NORMAL_BASE,
Some("Whitelisted trusted domain"),
);
}
return (Stage1Category::Grey, 0.0, None);
}
(Stage1Category::Grey, 0.0, None)
}
pub fn score_url(url: &str, model: &Model) -> f32 {
let s1 = analyze_stage1(url);
let forest = predict_forest(url, model);
match s1.category {
Stage1Category::Normal => {
if forest >= THRESHOLD {
forest
} else {
forest.max(NORMAL_BASE)
}
}
Stage1Category::Phishing if THRESHOLD < PHISH_BASE => forest,
Stage1Category::Phishing => s1.base.max(forest),
Stage1Category::Grey => forest,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::load_default_model;
#[test]
fn test_host_of() {
assert_eq!(host_of("https://www.google.com/mail"), "www.google.com");
assert_eq!(host_of("http://example.com:8080/path"), "example.com");
assert_eq!(host_of("user@phish.com/login"), "phish.com");
assert_eq!(host_of("example.com/path?q=1"), "example.com");
assert_eq!(host_of("https://a.b.c.example.com"), "a.b.c.example.com");
}
#[test]
fn test_subdomain_of() {
assert_eq!(subdomain_of("mail.google.com", "google.com"), "mail");
assert_eq!(subdomain_of("a.b.cnn.com", "cnn.com"), "a.b");
assert_eq!(subdomain_of("cnn.com", "cnn.com"), "");
assert_eq!(
subdomain_of("login.secure-paypal.com", "secure-paypal.com"),
"login"
);
assert_eq!(subdomain_of("login.secure-paypal.com", "paypal.com"), "login.secure-");
}
#[test]
fn test_brand_impersonation() {
assert!(brand_impersonation_score("paypa1.com") >= BRAND_THRESHOLD);
assert!(brand_impersonation_score("g00gle.com") >= BRAND_THRESHOLD);
assert!(brand_impersonation_score("apple-id-verify.com") >= BRAND_THRESHOLD);
assert!(brand_impersonation_score("secure-paypa1.com") >= BRAND_THRESHOLD);
assert_eq!(brand_impersonation_score("google.com"), 0.0);
assert!(brand_impersonation_score("googleanalytics.com") < BRAND_THRESHOLD);
assert!(brand_impersonation_score("example.com") < BRAND_THRESHOLD);
}
#[test]
fn test_whitelist_lookup() {
assert!(is_whitelisted("example.com"));
assert!(!is_whitelisted("nobell.it"));
assert!(!is_whitelisted("this-domain-does-not-exist-zzz.com"));
}
#[test]
fn test_stage1_normal_fix() {
let a = analyze_stage1("https://mail.google.com/mail/u/0/");
assert_eq!(a.category, Stage1Category::Normal);
assert_eq!(a.base, 0.1);
}
#[test]
fn test_stage1_brand_phishing() {
let a = analyze_stage1("http://paypa1.com/login");
assert_eq!(a.category, Stage1Category::Phishing);
assert_eq!(a.base, 0.5);
}
#[test]
fn test_stage1_high_risk_tld() {
let a = analyze_stage1("http://a1b2c3.tk/login");
assert_eq!(a.category, Stage1Category::Phishing);
assert_eq!(a.base, 0.5);
}
#[test]
fn test_stage1_hosting_grey() {
let a = analyze_stage1("https://evil-phish.vercel.app/login");
assert_eq!(a.category, Stage1Category::Grey);
}
#[test]
fn test_stage1_embedded_brand_attacker_tld() {
let a = analyze_stage1("https://www.google.com.abcd.xyz/login");
assert_eq!(a.category, Stage1Category::Phishing);
assert_eq!(a.base, 0.5);
}
#[test]
fn test_score_url_two_stage() {
let model = load_default_model().expect("Failed to load model");
let n = score_url("https://www.google.com", &model);
assert!(n < THRESHOLD);
let p = score_url("http://paypa1.com/login", &model);
assert!(p >= THRESHOLD);
let p2 = score_url("http://a1b2c3.tk/login", &model);
assert!(p2 >= THRESHOLD);
}
}