use crate::extractor::registrable_domain;
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;
pub(crate) const THRESHOLD: f32 = 0.20;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Stage1Category {
Normal,
Grey,
}
pub struct Stage1Analysis {
pub category: Stage1Category,
pub base: f32,
pub reason: Option<&'static str>,
}
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 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()
}
pub fn analyze_stage1(url: &str) -> Stage1Analysis {
let host = host_of(url);
let reg = registrable_domain(&host);
let (category, base, reason) = whitelist_verdict(®, &host);
Stage1Analysis {
category,
base,
reason,
}
}
fn whitelist_verdict(reg: &str, host: &str) -> (Stage1Category, f32, Option<&'static str>) {
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"),
);
}
}
(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::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_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_defers_to_forest() {
let a = analyze_stage1("http://paypa1.com/login");
assert_eq!(a.category, Stage1Category::Grey);
let b = analyze_stage1("http://a1b2c3.tk/login");
assert_eq!(b.category, Stage1Category::Grey);
}
#[test]
fn test_stage1_unusual_subdomain_defers() {
let a = analyze_stage1("https://login-secure-account.google.com/login");
assert_eq!(a.category, Stage1Category::Grey);
}
#[test]
fn test_score_url_whitelist_and_forest() {
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);
}
}