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 = 90;
30const MIN_FIREFOX_VERSION: i32 = 88;
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 ua_lower.contains("chrome/") {
111            if let Some(pos) = ua_lower.find("chrome/") {
112                let version_str = &ua_lower[pos + 7..];
113                if let Some(dot_pos) = version_str.find('.') {
114                    if let Ok(major) = version_str[..dot_pos].parse::<i32>() {
115                        if major < MIN_CHROME_VERSION {
116                            return true;
117                        }
118                    }
119                }
120            }
121        }
122
123        if ua_lower.contains("firefox/") {
124            if let Some(pos) = ua_lower.find("firefox/") {
125                let version_str = &ua_lower[pos + 8..];
126                if let Some(space_pos) = version_str.find(|c: char| !c.is_numeric() && c != '.') {
127                    if let Ok(major) = version_str[..space_pos].parse::<i32>() {
128                        if major < MIN_FIREFOX_VERSION {
129                            return true;
130                        }
131                    }
132                }
133            }
134        }
135
136        false
137    }
138
139    pub fn is_high_velocity(request_count: i64, duration_seconds: i64) -> bool {
140        if duration_seconds < 1 {
141            return false;
142        }
143
144        let requests_per_minute = (request_count as f64 / duration_seconds as f64) * 60.0;
145        requests_per_minute > MAX_REQUESTS_PER_MINUTE
146    }
147
148    pub fn is_scanner(
149        path: Option<&str>,
150        user_agent: Option<&str>,
151        request_count: Option<i64>,
152        duration_seconds: Option<i64>,
153    ) -> bool {
154        if let Some(p) = path {
155            if Self::is_scanner_path(p) {
156                return true;
157            }
158        }
159
160        match user_agent {
161            Some(ua) => {
162                if Self::is_scanner_agent(ua) {
163                    return true;
164                }
165            },
166            None => {
167                return true;
168            },
169        }
170
171        if let (Some(count), Some(duration)) = (request_count, duration_seconds) {
172            if Self::is_high_velocity(count, duration) {
173                return true;
174            }
175        }
176
177        false
178    }
179}