Skip to main content

systemprompt_security/services/
scanner.rs

1use 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}