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