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