Skip to main content

systemprompt_security/services/
scanner.rs

1//! Heuristic bot/scanner classification from request shape and user agent.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use 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}