use crate::extractor::{extract_manual_features, ngrams_for_bucket};
use crate::model::Model;
use crate::predictor::{predict_tree_with_path, predict_url};
use std::collections::HashMap;
const MAX_INDICATORS: usize = 5;
const MIN_TREE_VOTES: usize = 3;
#[derive(Debug, Clone)]
pub struct Prediction {
pub score: f32,
pub indicators: Vec<Indicator>,
}
#[derive(Debug, Clone)]
pub struct Indicator {
pub category: IndicatorCategory,
pub description: String,
pub weight: f32,
pub source: IndicatorSource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IndicatorCategory {
Domain,
Path,
Structure,
NGram,
}
#[derive(Debug, Clone)]
pub enum IndicatorSource {
Model {
tree_count: usize,
total_trees: usize,
},
Heuristic,
}
pub fn predict_url_detailed(url: &str, model: &Model) -> Prediction {
let score = predict_url(url, model);
let total_trees = model.trees.len();
let features = model.extract_features(url);
let manual_features = extract_manual_features(url);
let mut feature_votes: HashMap<i32, usize> = HashMap::new();
for tree in &model.trees {
let (_, path) = predict_tree_with_path(tree, &features);
for step in &path {
*feature_votes.entry(step.feature_idx).or_insert(0) += 1;
}
}
let mut ranked_features: Vec<(i32, usize)> = feature_votes
.into_iter()
.filter(|&(_, count)| count >= MIN_TREE_VOTES)
.collect();
ranked_features.sort_by_key(|b| std::cmp::Reverse(b.1));
let mut indicators: Vec<Indicator> = Vec::new();
let mut covered_manual: Vec<usize> = Vec::new();
for (feature_idx, tree_count) in &ranked_features {
if indicators.len() >= MAX_INDICATORS {
break;
}
let feat_idx = *feature_idx;
if (feat_idx as usize) >= model.n_features {
let manual_idx = feat_idx as usize - model.n_features;
if manual_idx >= manual_features.len() {
continue;
}
let val = manual_features[manual_idx];
if let Some((category, desc)) = manual_feature_indicator(manual_idx, val, url) {
covered_manual.push(manual_idx);
indicators.push(Indicator {
category,
description: desc,
weight: *tree_count as f32 / total_trees as f32,
source: IndicatorSource::Model {
tree_count: *tree_count,
total_trees,
},
});
}
}
}
for (idx, &val) in manual_features.iter().enumerate() {
if indicators.len() >= MAX_INDICATORS {
break;
}
if covered_manual.contains(&idx) {
continue;
}
if let Some((category, desc)) = manual_feature_indicator(idx, val, url) {
covered_manual.push(idx);
indicators.push(Indicator {
category,
description: desc,
weight: 0.5,
source: IndicatorSource::Heuristic,
});
}
}
if indicators.len() < MAX_INDICATORS {
if let Some(desc) = high_risk_tld_indicator(url) {
indicators.push(Indicator {
category: IndicatorCategory::Domain,
description: desc,
weight: 0.5,
source: IndicatorSource::Heuristic,
});
}
}
for (feature_idx, tree_count) in &ranked_features {
if indicators.len() >= MAX_INDICATORS {
break;
}
let feat_idx = *feature_idx;
if (feat_idx as usize) < model.n_features {
let bucket = feat_idx as usize;
let ngrams = ngrams_for_bucket(url, bucket, model.n_features, model.ngram_range);
if let Some(best) = ngrams.iter().max_by_key(|g| g.len()) {
indicators.push(Indicator {
category: IndicatorCategory::NGram,
description: format!("Suspicious character pattern: '{}'", best),
weight: *tree_count as f32 / total_trees as f32,
source: IndicatorSource::Model {
tree_count: *tree_count,
total_trees,
},
});
}
}
}
Prediction { score, indicators }
}
fn manual_feature_indicator(
idx: usize,
val: f32,
url: &str,
) -> Option<(IndicatorCategory, String)> {
match idx {
2 if val >= 4.0 => Some((
IndicatorCategory::Structure,
"URL length abnormal".to_string(),
)),
3 if val >= 3.0 => Some((
IndicatorCategory::Domain,
"Domain length abnormal".to_string(),
)),
4 if val == 1.0 => Some((
IndicatorCategory::Domain,
"Uses IP address instead of domain name".to_string(),
)),
5 if val >= 3.0 => Some((
IndicatorCategory::Domain,
"Excessive subdomain levels".to_string(),
)),
7 if val >= 3.0 => Some((
IndicatorCategory::Structure,
"Excessive hyphens in URL".to_string(),
)),
9 if val >= 1.0 => Some((
IndicatorCategory::Structure,
"Contains @ symbol (URL obfuscation)".to_string(),
)),
10 if val >= 1.0 => Some((
IndicatorCategory::Structure,
"High percent-encoding usage".to_string(),
)),
14 if val > 0.3 => Some((
IndicatorCategory::Structure,
"High digit-to-character ratio".to_string(),
)),
15 if val >= 3.0 => Some((IndicatorCategory::Path, "Path length abnormal".to_string())),
18 if val == 1.0 => {
let keyword = find_sensitive_keyword(url);
Some((
IndicatorCategory::Path,
if let Some(kw) = keyword {
format!("Contains sensitive keyword: '{}'", kw)
} else {
"Contains sensitive keyword (login/verify/paypal)".to_string()
},
))
}
_ => None,
}
}
fn find_sensitive_keyword(url: &str) -> Option<&'static str> {
let url_lower = url.to_lowercase();
let words = [
"login",
"signin",
"verify",
"account",
"password",
"secure",
"update",
"bank",
"paypal",
"facebook",
"google",
"apple",
"amazon",
"ebay",
"microsoft",
"yahoo",
"linkedin",
];
words.iter().find(|&&w| url_lower.contains(w)).copied()
}
fn high_risk_tld_indicator(url: &str) -> Option<String> {
let url_lower = url.to_lowercase();
let cleaned = cleaned_domain(&url_lower);
let tld = cleaned.split('.').next_back().unwrap_or("");
let high_risk_tlds = [
"tk",
"ml",
"ga",
"cf",
"gq",
"xyz",
"top",
"click",
"country",
"stream",
"download",
"loan",
"work",
"men",
"racing",
"review",
"party",
"trade",
"science",
"accountant",
"date",
"cricket",
"faith",
"win",
];
if high_risk_tlds.contains(&tld) {
Some(format!("Uses high-risk TLD: .{}", tld))
} else {
None
}
}
fn cleaned_domain(url_lower: &str) -> &str {
let stripped = url_lower
.strip_prefix("http://")
.or_else(|| url_lower.strip_prefix("https://"))
.unwrap_or(url_lower);
match stripped.find('/') {
Some(pos) => &stripped[..pos],
None => stripped,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_predict_url_detailed_phishing() {
let model = crate::load_default_model().expect("Failed to load model");
let result = predict_url_detailed(
"nobell.it/70ffb52d079109dca5664cce6f317373782/login.SkyPe.com",
&model,
);
assert!(
result.score >= 0.45,
"Phishing URL should score >= 0.45, got {}",
result.score
);
assert!(
!result.indicators.is_empty(),
"Phishing URL should have indicators"
);
assert!(
result.indicators.len() <= MAX_INDICATORS,
"Should have at most {} indicators, got {}",
MAX_INDICATORS,
result.indicators.len()
);
let has_model = result
.indicators
.iter()
.any(|i| matches!(i.source, IndicatorSource::Model { .. }));
assert!(has_model, "Should have at least one model indicator");
}
#[test]
fn test_predict_url_detailed_normal() {
let model = crate::load_default_model().expect("Failed to load model");
let result = predict_url_detailed("http://example.com", &model);
assert!(
result.score < 0.45,
"Normal URL should score < 0.45, got {}",
result.score
);
assert!(
result.indicators.len() <= MAX_INDICATORS,
"Should have at most {} indicators",
MAX_INDICATORS
);
}
#[test]
fn test_indicator_limit() {
let model = crate::load_default_model().expect("Failed to load model");
let urls = vec![
"http://192.168.1.1/login/verify/account?password=123&token=abc",
"http://very-long-phishing-url-with-many-subdomains.a.b.c.example.com/login",
];
for url in &urls {
let result = predict_url_detailed(url, &model);
assert!(
result.indicators.len() <= MAX_INDICATORS,
"URL '{}' produced {} indicators (max {})",
url,
result.indicators.len(),
MAX_INDICATORS
);
}
}
#[test]
fn test_high_risk_tld_detection() {
assert!(high_risk_tld_indicator("http://example.tk").is_some());
assert!(high_risk_tld_indicator("http://phish.xyz").is_some());
assert!(high_risk_tld_indicator("http://example.com").is_none());
}
#[test]
fn test_cleaned_domain() {
assert_eq!(cleaned_domain("http://example.com/path"), "example.com");
assert_eq!(cleaned_domain("https://sub.example.com"), "sub.example.com");
assert_eq!(cleaned_domain("example.com"), "example.com");
assert_eq!(cleaned_domain("http://192.168.1.1/x"), "192.168.1.1");
}
#[test]
fn test_find_sensitive_keyword() {
assert_eq!(
find_sensitive_keyword("http://example.com/login"),
Some("login")
);
assert_eq!(
find_sensitive_keyword("http://verify.account.com"),
Some("verify")
);
assert_eq!(find_sensitive_keyword("http://example.com"), None);
}
#[test]
fn test_ip_url_indicators() {
let model = crate::load_default_model().expect("Failed to load model");
let result = predict_url_detailed("http://192.168.1.1/login", &model);
let has_ip_indicator = result
.indicators
.iter()
.any(|i| i.description.contains("IP address"));
assert!(
has_ip_indicator,
"IP-based URL should have IP address indicator, got: {:?}",
result.indicators
);
}
}