systemprompt_security/services/
scanner.rs1use std::path::Path;
2
3const SCANNER_EXTENSIONS: &[&str] = &[
4 "php", "env", "git", "sql", "bak", "old", "zip", "gz", "db", "config", "cgi", "htm",
5];
6
7const SCANNER_PATHS: &[&str] = &[
8 "/admin",
9 "/wp-admin",
10 "/wp-content",
11 "/uploads",
12 "/cgi-bin",
13 "/phpmyadmin",
14 "/xmlrpc",
15 "/luci",
16 "/ssi.cgi",
17 "internal_forms_authentication",
18 "/identity",
19 "/login.htm",
20 "/manager/html",
21 "/config/",
22 "/setup.cgi",
23 "/eval-stdin.php",
24 "/shell.php",
25 "/c99.php",
26];
27
28const MIN_USER_AGENT_LENGTH: usize = 10;
29const MIN_CHROME_VERSION: i32 = 120;
30const MIN_FIREFOX_VERSION: i32 = 120;
31const MAX_REQUESTS_PER_MINUTE: f64 = 30.0;
32const MAX_CURL_UA_LENGTH: usize = 20;
33const MAX_WGET_UA_LENGTH: usize = 20;
34const MAX_PYTHON_REQUESTS_UA_LENGTH: usize = 30;
35const MAX_GO_HTTP_CLIENT_UA_LENGTH: usize = 30;
36const MAX_RUBY_UA_LENGTH: usize = 25;
37
38#[derive(Debug, Clone, Copy)]
39pub struct ScannerDetector;
40
41impl ScannerDetector {
42 pub fn is_scanner_path(path: &str) -> bool {
43 Self::has_scanner_extension(path) || Self::has_scanner_directory(path)
44 }
45
46 fn has_scanner_extension(path: &str) -> bool {
47 Path::new(path)
48 .extension()
49 .and_then(|ext| ext.to_str())
50 .is_some_and(|ext| {
51 SCANNER_EXTENSIONS
52 .iter()
53 .any(|scanner_ext| ext.eq_ignore_ascii_case(scanner_ext))
54 })
55 }
56
57 fn has_scanner_directory(path: &str) -> bool {
58 let path_lower = path.to_lowercase();
59 SCANNER_PATHS.iter().any(|p| path_lower.contains(p))
60 }
61
62 pub fn is_scanner_agent(user_agent: &str) -> bool {
63 let ua_lower = user_agent.to_lowercase();
64
65 if user_agent.is_empty() || user_agent.len() < MIN_USER_AGENT_LENGTH {
66 return true;
67 }
68
69 if user_agent == "Mozilla/5.0" || user_agent.trim() == "Mozilla/5.0" {
70 return true;
71 }
72
73 ua_lower.contains("masscan")
74 || ua_lower.contains("nmap")
75 || ua_lower.contains("nikto")
76 || ua_lower.contains("sqlmap")
77 || ua_lower.contains("havij")
78 || ua_lower.contains("acunetix")
79 || ua_lower.contains("nessus")
80 || ua_lower.contains("openvas")
81 || ua_lower.contains("w3af")
82 || ua_lower.contains("metasploit")
83 || ua_lower.contains("burpsuite")
84 || ua_lower.contains("zap")
85 || ua_lower.contains("zgrab")
86 || ua_lower.contains("censys")
87 || ua_lower.contains("shodan")
88 || ua_lower.contains("palo alto")
89 || ua_lower.contains("cortex")
90 || ua_lower.contains("xpanse")
91 || ua_lower.contains("probe-image-size")
92 || ua_lower.contains("libredtail")
93 || ua_lower.contains("httpclient")
94 || ua_lower.contains("httpunit")
95 || ua_lower.contains("java/")
96 || ua_lower.starts_with("wordpress/")
97 || ua_lower.contains("wp-http")
98 || ua_lower.contains("wp-cron")
99 || (ua_lower.contains("curl") && ua_lower.len() < MAX_CURL_UA_LENGTH)
100 || (ua_lower.contains("wget") && ua_lower.len() < MAX_WGET_UA_LENGTH)
101 || (ua_lower.contains("python-requests")
102 && ua_lower.len() < MAX_PYTHON_REQUESTS_UA_LENGTH)
103 || (ua_lower.contains("go-http-client")
104 && ua_lower.len() < MAX_GO_HTTP_CLIENT_UA_LENGTH)
105 || (ua_lower.contains("ruby") && ua_lower.len() < MAX_RUBY_UA_LENGTH)
106 || Self::is_outdated_browser(&ua_lower)
107 }
108
109 fn is_outdated_browser(ua_lower: &str) -> bool {
110 if let Some(pos) = ua_lower.find("chrome/")
111 && let Some(dot_pos) = ua_lower[pos + 7..].find('.')
112 && let Ok(major) = ua_lower[pos + 7..][..dot_pos].parse::<i32>()
113 && major < MIN_CHROME_VERSION
114 {
115 return true;
116 }
117
118 if let Some(pos) = ua_lower.find("firefox/")
119 && let Some(space_pos) = ua_lower[pos + 8..].find(|c: char| !c.is_numeric() && c != '.')
120 && let Ok(major) = ua_lower[pos + 8..][..space_pos].parse::<i32>()
121 && major < MIN_FIREFOX_VERSION
122 {
123 return true;
124 }
125
126 false
127 }
128
129 pub fn is_high_velocity(request_count: i64, duration_seconds: i64) -> bool {
130 if duration_seconds < 1 {
131 return false;
132 }
133
134 let requests_per_minute = (request_count as f64 / duration_seconds as f64) * 60.0;
135 requests_per_minute > MAX_REQUESTS_PER_MINUTE
136 }
137
138 pub fn is_scanner(
139 path: Option<&str>,
140 user_agent: Option<&str>,
141 request_count: Option<i64>,
142 duration_seconds: Option<i64>,
143 ) -> bool {
144 if let Some(p) = path {
145 if Self::is_scanner_path(p) {
146 return true;
147 }
148 }
149
150 match user_agent {
151 Some(ua) => {
152 if Self::is_scanner_agent(ua) {
153 return true;
154 }
155 },
156 None => {
157 return true;
158 },
159 }
160
161 if let (Some(count), Some(duration)) = (request_count, duration_seconds) {
162 if Self::is_high_velocity(count, duration) {
163 return true;
164 }
165 }
166
167 false
168 }
169}