use crate::extractor::registrable_domain;
use crate::model::Model;
use crate::predictor::predict_forest;
use once_cell::sync::Lazy;
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 whitelist_store() -> &'static Vec<&'static str> {
static LIST: Lazy<Vec<&'static str>> = Lazy::new(|| {
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
});
&LIST
}
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<'a>(host: &'a str, reg: &str) -> &'a str {
if host == reg {
return "";
}
if let Some(prefix) = host.strip_suffix(reg) {
if let Some(rest) = prefix.strip_suffix('.') {
return rest;
}
return prefix;
}
host
}
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_SUBDOMAINS.contains(&sub)
|| sub.split('.').all(|p| COMMON_SUBDOMAINS.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);
}
}