1#[derive(Debug, Clone, Copy, PartialEq)]
9pub struct Rule {
10 pub id: u32,
11 pub score: u32,
13 pub paranoia: u8,
15 pub message: &'static str,
16}
17
18pub const RULES: &[Rule] = &[
19 Rule { id: 942100, score: 5, paranoia: 1, message: "SQL injection: boolean logic after a quote" },
20 Rule { id: 942110, score: 5, paranoia: 1, message: "SQL injection: comment after a quote" },
21 Rule { id: 942120, score: 5, paranoia: 1, message: "SQL injection: UNION SELECT" },
22 Rule { id: 942130, score: 5, paranoia: 1, message: "SQL injection: stacked query" },
23 Rule { id: 942140, score: 5, paranoia: 1, message: "SQL injection: time-based probe" },
24 Rule { id: 942150, score: 5, paranoia: 1, message: "SQL injection: schema or file access" },
25 Rule { id: 942160, score: 5, paranoia: 1, message: "SQL injection: numeric tautology" },
26 Rule { id: 942200, score: 3, paranoia: 2, message: "SQL injection: SELECT … FROM" },
27 Rule { id: 942300, score: 2, paranoia: 3, message: "SQL comment sequence" },
28 Rule { id: 941100, score: 5, paranoia: 1, message: "XSS: script tag" },
29 Rule { id: 941110, score: 5, paranoia: 1, message: "XSS: event handler attribute" },
30 Rule { id: 941120, score: 5, paranoia: 1, message: "XSS: javascript:/vbscript:/data: URL" },
31 Rule { id: 941130, score: 5, paranoia: 1, message: "XSS: dangerous tag" },
32 Rule { id: 941140, score: 5, paranoia: 1, message: "XSS: CSS or srcdoc injection" },
33 Rule { id: 941300, score: 2, paranoia: 3, message: "HTML tag in a value" },
34 Rule { id: 930100, score: 5, paranoia: 1, message: "Path traversal" },
35 Rule { id: 930110, score: 5, paranoia: 1, message: "Access to a sensitive file" },
36 Rule { id: 931100, score: 5, paranoia: 1, message: "Remote or local file inclusion through a URL wrapper" },
37 Rule { id: 932100, score: 5, paranoia: 1, message: "Command injection: shell command after a separator" },
38 Rule { id: 932110, score: 5, paranoia: 1, message: "Command injection: $IFS evasion" },
39 Rule { id: 932200, score: 3, paranoia: 2, message: "Shell substitution syntax" },
40 Rule { id: 934100, score: 5, paranoia: 1, message: "SSRF: cloud metadata address" },
41 Rule { id: 944100, score: 5, paranoia: 1, message: "Java: JNDI lookup (Log4Shell)" },
42 Rule { id: 913100, score: 5, paranoia: 1, message: "Security scanner" },
43 Rule { id: 920100, score: 5, paranoia: 1, message: "Too many arguments" },
44 Rule { id: 920200, score: 3, paranoia: 2, message: "Argument value too long" },
45];
46
47pub fn rule(id: u32) -> Option<&'static Rule> {
48 RULES.iter().find(|r| r.id == id)
49}
50
51#[derive(Debug, Clone, PartialEq)]
54enum Tok {
55 Quote,
56 Word(String),
57 Num(String),
58 Op(String),
59 Comment,
60 Paren,
61 Semi,
62 Other,
63}
64
65fn tokens(s: &str) -> Vec<Tok> {
67 let b: Vec<char> = s.chars().collect();
68 let mut out = Vec::new();
69 let mut i = 0;
70 while i < b.len() && out.len() < 4096 {
71 let c = b[i];
72 if c == ' ' {
73 i += 1;
74 } else if c == '\'' || c == '"' || c == '`' {
75 out.push(Tok::Quote);
76 i += 1;
77 } else if c.is_ascii_alphabetic() || c == '_' || c == '@' || c == '$' {
78 let start = i;
79 while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == '_' || b[i] == '@' || b[i] == '$' || b[i] == '.') {
80 i += 1;
81 }
82 out.push(Tok::Word(b[start..i].iter().collect()));
83 } else if c.is_ascii_digit() {
84 let start = i;
85 while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == '.') {
86 i += 1;
87 }
88 out.push(Tok::Num(b[start..i].iter().collect()));
89 } else if c == '-' && b.get(i + 1) == Some(&'-') || c == '#' {
90 out.push(Tok::Comment);
91 i += if c == '#' { 1 } else { 2 };
92 } else if c == '(' || c == ')' {
93 out.push(Tok::Paren);
94 i += 1;
95 } else if c == ';' {
96 out.push(Tok::Semi);
97 i += 1;
98 } else if "=<>!|&".contains(c) {
99 let start = i;
100 while i < b.len() && "=<>!|&".contains(b[i]) && i - start < 3 {
101 i += 1;
102 }
103 out.push(Tok::Op(b[start..i].iter().collect()));
104 } else {
105 out.push(Tok::Other);
106 i += 1;
107 }
108 }
109 out
110}
111
112fn word(t: Option<&Tok>, words: &[&str]) -> bool {
113 matches!(t, Some(Tok::Word(w)) if words.contains(&w.as_str()))
114}
115
116fn comparison(t: Option<&Tok>) -> bool {
117 matches!(t, Some(Tok::Op(o)) if ["=", "==", "<", ">", "<=", ">=", "<>", "!=", "<=>"].contains(&o.as_str())) || word(t, &["like", "rlike", "regexp", "is", "in", "between", "sounds"])
118}
119
120fn logic(t: Option<&Tok>) -> bool {
121 word(t, &["or", "and", "xor", "div"]) || matches!(t, Some(Tok::Op(o)) if o == "||" || o == "&&")
122}
123
124fn sqli(v: &str, hits: &mut Vec<u32>) {
125 if !v.bytes().any(|b| b"'\"`;=()-#".contains(&b) || b == b' ') {
126 return;
127 }
128 let t = tokens(v);
129 let at = |i: usize| t.get(i);
130 for i in 0..t.len() {
131 if t[i] == Tok::Quote {
133 let mut j = i + 1;
134 while at(j) == Some(&Tok::Paren) {
135 j += 1;
136 }
137 if logic(at(j)) {
138 let window = (j + 1..(j + 6).min(t.len())).any(|k| comparison(at(k)) || word(at(k), &["true", "false", "null", "not"]));
139 if window || matches!(at(j + 1), Some(Tok::Num(_))) && at(j + 2).is_none() {
140 hits.push(942100);
141 }
142 }
143 if matches!(at(j), Some(Tok::Comment)) || (at(j) == Some(&Tok::Semi) && matches!(at(j + 1), Some(Tok::Comment))) {
145 hits.push(942110);
146 }
147 }
148 if word(at(i), &["union"]) {
150 let mut j = i + 1;
151 while at(j) == Some(&Tok::Paren) {
152 j += 1;
153 }
154 if word(at(j), &["all", "distinct"]) {
155 j += 1;
156 }
157 while at(j) == Some(&Tok::Paren) {
158 j += 1;
159 }
160 if word(at(j), &["select"]) {
161 hits.push(942120);
162 }
163 }
164 if t[i] == Tok::Semi && word(at(i + 1), &["select", "insert", "update", "delete", "drop", "create", "alter", "truncate", "exec", "execute", "declare", "shutdown", "grant", "revoke", "merge", "call"]) {
166 let next = at(i + 2);
167 if next.is_some() && !matches!(next, Some(Tok::Other)) {
168 hits.push(942130);
169 }
170 }
171 if word(at(i), &["sleep", "benchmark", "pg_sleep", "dbms_lock.sleep", "dbms_pipe.receive_message"]) && at(i + 1) == Some(&Tok::Paren) && matches!(at(i + 2), Some(Tok::Num(_)) | Some(Tok::Word(_)) | Some(Tok::Quote)) {
173 hits.push(942140);
174 }
175 if word(at(i), &["waitfor"]) && word(at(i + 1), &["delay", "time"]) {
176 hits.push(942140);
177 }
178 if logic(at(i)) {
180 if let (Some(Tok::Num(a)), Some(Tok::Op(o)), Some(Tok::Num(b))) = (at(i + 1), at(i + 2), at(i + 3)) {
181 if a == b && (o == "=" || o == "==" || o == "<=>" || o == ">=" || o == "<=") {
182 hits.push(942160);
183 }
184 }
185 }
186 if word(at(i), &["select"]) && (i + 1..(i + 12).min(t.len())).any(|k| word(at(k), &["from"])) {
188 hits.push(942200);
189 }
190 }
191 if ["information_schema", "load_file(", "into outfile", "into dumpfile", "@@version", "xp_cmdshell", "sysobjects", "pg_shadow", "mysql.user", "sqlite_master"].iter().any(|p| v.contains(p)) {
193 hits.push(942150);
194 }
195 if v.contains("--") || v.contains(" #") {
196 hits.push(942300);
197 }
198}
199
200fn squeezed(v: &str) -> String {
204 v.chars().filter(|c| !c.is_whitespace()).collect()
205}
206
207fn xss(v: &str, hits: &mut Vec<u32>) {
208 if !v.bytes().any(|b| b == b'<' || b == b':' || b == b'=' || b == b'(') {
209 return;
210 }
211 if v.contains("<script") || v.contains("</script") {
212 hits.push(941100);
213 }
214 let bytes = v.as_bytes();
216 let mut i = 0;
217 while let Some(off) = v[i..].find("on") {
218 let at = i + off;
219 let before = if at == 0 { b' ' } else { bytes[at - 1] };
220 let name_end = at + 2 + v[at + 2..].bytes().take_while(|b| b.is_ascii_alphabetic()).count();
221 let name_len = name_end - at - 2;
222 let after = v[name_end..].trim_start();
223 if name_len >= 3 && after.starts_with('=') && matches!(before, b' ' | b'/' | b'"' | b'\'' | b'`' | b';') {
224 let tag = v[..at].rfind('<').is_some_and(|lt| !v[lt..at].contains('>'));
226 let breakout = v[..at].trim_end().ends_with(['"', '\'', '`']);
227 if tag || breakout {
228 hits.push(941110);
229 break;
230 }
231 }
232 i = at + 2;
233 if i >= v.len() {
234 break;
235 }
236 }
237 let tight = squeezed(v);
238 let url_start = |s: &str| ["javascript:", "vbscript:", "livescript:", "data:text/html", "data:image/svg+xml"].iter().any(|p| s.starts_with(p));
239 if url_start(tight.trim_start_matches(['"', '\'', '`'])) || ["=javascript:", "=\"javascript:", "='javascript:", "=vbscript:", "(javascript:", "url(javascript:"].iter().any(|p| tight.contains(p)) {
240 hits.push(941120);
241 }
242 if ["<iframe", "<object", "<embed", "<applet", "<base ", "<base/", "<meta ", "<frameset", "<svg/on", "<math", "<link rel=import", "<form action"].iter().any(|p| v.contains(p)) || (v.contains("<svg") && v.contains("onload")) {
243 hits.push(941130);
244 }
245 if tight.contains("expression(") || tight.contains("-moz-binding") || tight.contains("srcdoc=") || tight.contains("@import") && tight.contains("url(") {
246 hits.push(941140);
247 }
248 if v.find('<').is_some_and(|lt| v[lt + 1..].starts_with(|c: char| c.is_ascii_alphabetic() || c == '/' || c == '!')) {
249 hits.push(941300);
250 }
251}
252
253fn files(v: &str, hits: &mut Vec<u32>) {
256 let traversals = v.matches("../").count() + v.matches("..\\").count() + v.matches("..;/").count();
257 if traversals >= 2 {
259 hits.push(930100);
260 }
261 const SENSITIVE: [&str; 17] = [
262 "/etc/passwd", "/etc/shadow", "/etc/group", "/proc/self/", "/proc/1/", "c:\\windows\\", "c:/windows/", "boot.ini", "win.ini", "/web.config", "wp-config.php",
263 "/.htaccess", "/.htpasswd", "/.git/", "/.ssh/", "id_rsa", "/.aws/credentials",
264 ];
265 if SENSITIVE.iter().any(|p| v.contains(p)) || v == ".env" || v.ends_with("/.env") || v.contains("../.env") {
266 hits.push(930110);
267 }
268 if ["php://", "phar://", "expect://", "file://", "gopher://", "dict://", "zip://", "jar:file", "data://", "glob://", "ogg://", "ssh2."].iter().any(|p| v.contains(p)) {
269 hits.push(931100);
270 }
271}
272
273const COMMANDS: [&str; 46] = [
274 "cat", "ls", "id", "whoami", "uname", "pwd", "wget", "curl", "nc", "ncat", "netcat", "bash", "sh", "zsh", "ksh", "dash", "python", "python3", "perl", "ruby", "php", "ping", "nslookup",
275 "dig", "rm", "chmod", "chown", "telnet", "ssh", "scp", "powershell", "cmd", "certutil", "base64", "sudo", "kill", "ifconfig", "ps", "printenv", "systemctl", "tftp", "head", "tail",
276 "more", "busybox", "xterm",
277];
278
279fn shell_like(sep: &str, rest: &str) -> bool {
284 if rest.is_empty() || rest.starts_with([';', '|', '&', '`', ')', '<', '>', '$']) {
285 return true;
286 }
287 let Some(arg) = rest.strip_prefix(' ') else { return false };
288 let arg = arg.trim_start();
289 if arg.is_empty() || arg.starts_with(['/', '-', '.', '$', '~', '\'', '"', '*', '<', '>', '|', ';', '&', '`']) || arg.starts_with(|c: char| c.is_ascii_digit()) {
290 return true;
291 }
292 matches!(sep, "`" | "$(")
294}
295
296fn commands(v: &str, hits: &mut Vec<u32>) {
297 if !v.bytes().any(|b| b";|&`$\n".contains(&b)) {
298 return;
299 }
300 for sep in [";", "|", "&&", "`", "$(", "\n"] {
301 let mut from = 0;
302 while let Some(off) = v[from..].find(sep) {
303 let after = v[from + off + sep.len()..].trim_start();
304 let w: String = after.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '/' || *c == '.').collect();
305 let name = w.rsplit('/').next().unwrap_or("");
306 let rest = &after[w.len()..];
307 if COMMANDS.contains(&name) && shell_like(sep, rest) {
308 hits.push(932100);
309 return;
310 }
311 from += off + sep.len();
312 }
313 }
314 if v.contains("${ifs}") || v.contains("$ifs") {
315 hits.push(932110);
316 }
317 if v.contains("$(") || v.matches('`').count() >= 2 {
318 hits.push(932200);
319 }
320}
321
322fn ssrf(v: &str, hits: &mut Vec<u32>) {
323 if ["169.254.169.254", "metadata.google.internal", "100.100.100.200", "fd00:ec2::254", "instance-data.ec2", "metadata.azure.com", "169.254.170.2"].iter().any(|p| v.contains(p)) {
324 hits.push(934100);
325 }
326}
327
328pub fn log4shell(v: &str) -> bool {
331 if !v.contains("${") {
332 return false;
333 }
334 let mut s = v.to_string();
335 for _ in 0..16 {
336 let Some(start) = s.rfind("${") else { break };
337 let Some(len) = s[start..].find('}') else { break };
338 let inner = &s[start + 2..start + len];
339 let resolved = if let Some((_, default)) = inner.split_once(":-") {
340 default.to_string()
341 } else if let Some(rest) = inner.strip_prefix("lower:").or_else(|| inner.strip_prefix("upper:")) {
342 rest.to_string()
343 } else if inner.starts_with("jndi:") {
344 return true;
345 } else {
346 format!("\u{0}{inner}\u{0}")
347 };
348 s.replace_range(start..start + len + 1, &resolved);
349 }
350 s.contains("jndi:ldap") || s.contains("jndi:rmi") || s.contains("jndi:dns") || s.contains("jndi:iiop") || s.contains("jndi:nis") || s.contains("jndi:corba") || s.contains("jndi:http")
351}
352
353pub fn value(v: &str, hits: &mut Vec<u32>) {
355 if v.is_empty() {
356 return;
357 }
358 sqli(v, hits);
359 xss(v, hits);
360 files(v, hits);
361 commands(v, hits);
362 ssrf(v, hits);
363 if log4shell(v) {
364 hits.push(944100);
365 }
366}
367
368pub fn scanner(user_agent: &str) -> bool {
370 ["sqlmap", "nikto", "nmap scripting engine", "masscan", "acunetix", "nessus", "openvas", "wpscan", "dirbuster", "gobuster", "fuzz faster u fool", "nuclei", "zgrab", "havij", "w3af", "netsparker", "arachni", "appscan", "jaeles", "zmeu", "morfeus", "paros", "webinspect", "commix"]
371 .iter()
372 .any(|p| user_agent.contains(p))
373}