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 commands(v: &str, hits: &mut Vec<u32>) {
280 if !v.bytes().any(|b| b";|&`$\n".contains(&b)) {
281 return;
282 }
283 for sep in [";", "|", "&&", "`", "$(", "\n"] {
284 let mut from = 0;
285 while let Some(off) = v[from..].find(sep) {
286 let after = v[from + off + sep.len()..].trim_start();
287 let w: String = after.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '/' || *c == '.').collect();
288 let name = w.rsplit('/').next().unwrap_or("");
289 let rest = &after[w.len()..];
290 if COMMANDS.contains(&name) && (rest.is_empty() || rest.starts_with([' ', ';', '|', '&', '`', ')', '<', '>', '$'])) {
291 hits.push(932100);
292 return;
293 }
294 from += off + sep.len();
295 }
296 }
297 if v.contains("${ifs}") || v.contains("$ifs") {
298 hits.push(932110);
299 }
300 if v.contains("$(") || v.matches('`').count() >= 2 {
301 hits.push(932200);
302 }
303}
304
305fn ssrf(v: &str, hits: &mut Vec<u32>) {
306 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)) {
307 hits.push(934100);
308 }
309}
310
311pub fn log4shell(v: &str) -> bool {
314 if !v.contains("${") {
315 return false;
316 }
317 let mut s = v.to_string();
318 for _ in 0..16 {
319 let Some(start) = s.rfind("${") else { break };
320 let Some(len) = s[start..].find('}') else { break };
321 let inner = &s[start + 2..start + len];
322 let resolved = if let Some((_, default)) = inner.split_once(":-") {
323 default.to_string()
324 } else if let Some(rest) = inner.strip_prefix("lower:").or_else(|| inner.strip_prefix("upper:")) {
325 rest.to_string()
326 } else if inner.starts_with("jndi:") {
327 return true;
328 } else {
329 format!("\u{0}{inner}\u{0}")
330 };
331 s.replace_range(start..start + len + 1, &resolved);
332 }
333 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")
334}
335
336pub fn value(v: &str, hits: &mut Vec<u32>) {
338 if v.is_empty() {
339 return;
340 }
341 sqli(v, hits);
342 xss(v, hits);
343 files(v, hits);
344 commands(v, hits);
345 ssrf(v, hits);
346 if log4shell(v) {
347 hits.push(944100);
348 }
349}
350
351pub fn scanner(user_agent: &str) -> bool {
353 ["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"]
354 .iter()
355 .any(|p| user_agent.contains(p))
356}