use std::path::Path;
const SCANNER_EXTENSIONS: &[&str] = &[
"php", "env", "git", "sql", "bak", "old", "zip", "gz", "db", "config", "cgi", "htm",
];
const SCANNER_PATHS: &[&str] = &[
"/admin",
"/wp-admin",
"/wp-content",
"/uploads",
"/cgi-bin",
"/phpmyadmin",
"/xmlrpc",
"/luci",
"/ssi.cgi",
"internal_forms_authentication",
"/identity",
"/login.htm",
"/manager/html",
"/config/",
"/setup.cgi",
"/eval-stdin.php",
"/shell.php",
"/c99.php",
];
const MIN_USER_AGENT_LENGTH: usize = 10;
const MIN_CHROME_VERSION: i32 = 120;
const MIN_FIREFOX_VERSION: i32 = 120;
const MAX_REQUESTS_PER_MINUTE: f64 = 30.0;
const MAX_CURL_UA_LENGTH: usize = 20;
const MAX_WGET_UA_LENGTH: usize = 20;
const MAX_PYTHON_REQUESTS_UA_LENGTH: usize = 30;
const MAX_GO_HTTP_CLIENT_UA_LENGTH: usize = 30;
const MAX_RUBY_UA_LENGTH: usize = 25;
#[derive(Debug, Clone, Copy)]
pub struct ScannerDetector;
impl ScannerDetector {
pub fn is_scanner_path(path: &str) -> bool {
Self::has_scanner_extension(path) || Self::has_scanner_directory(path)
}
fn has_scanner_extension(path: &str) -> bool {
Path::new(path)
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| {
SCANNER_EXTENSIONS
.iter()
.any(|scanner_ext| ext.eq_ignore_ascii_case(scanner_ext))
})
}
fn has_scanner_directory(path: &str) -> bool {
let path_lower = path.to_lowercase();
SCANNER_PATHS.iter().any(|p| path_lower.contains(p))
}
pub fn is_scanner_agent(user_agent: &str) -> bool {
let ua_lower = user_agent.to_lowercase();
if user_agent.is_empty() || user_agent.len() < MIN_USER_AGENT_LENGTH {
return true;
}
if user_agent == "Mozilla/5.0" || user_agent.trim() == "Mozilla/5.0" {
return true;
}
ua_lower.contains("masscan")
|| ua_lower.contains("nmap")
|| ua_lower.contains("nikto")
|| ua_lower.contains("sqlmap")
|| ua_lower.contains("havij")
|| ua_lower.contains("acunetix")
|| ua_lower.contains("nessus")
|| ua_lower.contains("openvas")
|| ua_lower.contains("w3af")
|| ua_lower.contains("metasploit")
|| ua_lower.contains("burpsuite")
|| ua_lower.contains("zap")
|| ua_lower.contains("zgrab")
|| ua_lower.contains("censys")
|| ua_lower.contains("shodan")
|| ua_lower.contains("palo alto")
|| ua_lower.contains("cortex")
|| ua_lower.contains("xpanse")
|| ua_lower.contains("probe-image-size")
|| ua_lower.contains("libredtail")
|| ua_lower.contains("httpclient")
|| ua_lower.contains("httpunit")
|| ua_lower.contains("java/")
|| ua_lower.starts_with("wordpress/")
|| ua_lower.contains("wp-http")
|| ua_lower.contains("wp-cron")
|| (ua_lower.contains("curl") && ua_lower.len() < MAX_CURL_UA_LENGTH)
|| (ua_lower.contains("wget") && ua_lower.len() < MAX_WGET_UA_LENGTH)
|| (ua_lower.contains("python-requests")
&& ua_lower.len() < MAX_PYTHON_REQUESTS_UA_LENGTH)
|| (ua_lower.contains("go-http-client")
&& ua_lower.len() < MAX_GO_HTTP_CLIENT_UA_LENGTH)
|| (ua_lower.contains("ruby") && ua_lower.len() < MAX_RUBY_UA_LENGTH)
|| Self::is_outdated_browser(&ua_lower)
}
fn is_outdated_browser(ua_lower: &str) -> bool {
if let Some(pos) = ua_lower.find("chrome/")
&& let Some(dot_pos) = ua_lower[pos + 7..].find('.')
&& let Ok(major) = ua_lower[pos + 7..][..dot_pos].parse::<i32>()
&& major < MIN_CHROME_VERSION
{
return true;
}
if let Some(pos) = ua_lower.find("firefox/")
&& let Some(space_pos) = ua_lower[pos + 8..].find(|c: char| !c.is_numeric() && c != '.')
&& let Ok(major) = ua_lower[pos + 8..][..space_pos].parse::<i32>()
&& major < MIN_FIREFOX_VERSION
{
return true;
}
false
}
pub fn is_high_velocity(request_count: i64, duration_seconds: i64) -> bool {
if duration_seconds < 1 {
return false;
}
let requests_per_minute = (request_count as f64 / duration_seconds as f64) * 60.0;
requests_per_minute > MAX_REQUESTS_PER_MINUTE
}
pub fn is_scanner(
path: Option<&str>,
user_agent: Option<&str>,
request_count: Option<i64>,
duration_seconds: Option<i64>,
) -> bool {
if let Some(p) = path {
if Self::is_scanner_path(p) {
return true;
}
}
match user_agent {
Some(ua) => {
if Self::is_scanner_agent(ua) {
return true;
}
},
None => {
return true;
},
}
if let (Some(count), Some(duration)) = (request_count, duration_seconds) {
if Self::is_high_velocity(count, duration) {
return true;
}
}
false
}
}