1use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
4use regex::Regex;
5use std::sync::LazyLock;
6
7static STRONG_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
16 Regex::new(concat!(
17 r"(?i:UN(?:/\*.*?\*/)?ION(?:[\s+]|%20)+(?:ALL(?:[\s+]|%20)+)?SELECT",
19 r#"|SELECT(?:[\s+]|%20)+(?:DISTINCT(?:[\s+]|%20)+)?(?:\*|[\w"'\[\].]+(?:\([^)\n]{0,40}\))?(?:\s*,\s*[\w"'\[\].]+(?:\([^)\n]{0,40}\))?)*)(?:[\s+]|%20)+FROM(?:[\s+]|%20)+[\w"\[\].]+"#,
21 r"|/\*!\d*\s*(?:union|select|insert|update|delete|drop|alter|sleep|benchmark|load_file|outfile|or|and|where)\b",
23 r#"|(?:^|['"();]|\b(?:and|or|select|union|where|having)\b)[\s+]*sleep\s*\("#,
25 r"|benchmark\s*\(\s*\d+\s*,",
27 r#"|(?:^|['"();]|\b(?:and|or|select|union|where|having)\b)[\s+]*pg_sleep\s*\("#,
30 r#"|(?:^|['"();])\s*exec\s+(?:sp_|xp_)"#,
33 r#"|WAITFOR\s+DELAY\s+['"]?\d"#,
35 r"|'\s*OR\s*'1'\s*=\s*'1",
36 r"|'\s*OR\s*1\s*=\s*1\s*--",
37 r"|\b(?:or|and)\b\s+1\s*=\s*1\s*(?:--|#|$)",
44 r#"|LOAD_FILE\s*\(\s*(?:['"]|0x)"#,
46 r#"|INTO\s+(?:OUT|DUMP)FILE\s+['"]"#,
49 r#"|OUTFILE\s+['"]"#,
51 r#"|(?:^|[();])\s*DROP\s+TABLE"#,
56 r#"|(?:^|[();])\s*INSERT\s+INTO"#,
57 r#"|(?:'\)?#[^"'\n]*(?:\n|$)|'\)[ \t]*--(?:\s|$|\+)|'--(?:\s|$|\+)|'/\*|\)[ \t]*--(?:\s|$|\+))"#,
63 r#"|(?:/\*.*?\*/|--|#)\s*(?:or\s+['"(\d]|and\s+['"(\d]|union\s+(?:all\s+)?select\b|select\s+(?:\*|['"(\d]|[\w`\[\]]+\s*(?:,|from\b))))"#,
66 ))
67 .unwrap()
68});
69
70static WEAK_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
78 Regex::new(r"(?i:information_schema)")
79 .unwrap()
80});
81
82pub struct SqlInjectionDetector;
83
84impl Detector for SqlInjectionDetector {
85 fn name(&self) -> &'static str {
86 "sql_injection"
87 }
88
89 fn detect(&self, input: &str) -> Option<DetectionResult> {
90 regex_detect(
91 std::slice::from_ref(&*STRONG_PATTERNS),
92 self.name(),
93 AttackCategory::Injection,
94 Severity::Critical,
95 "SQL injection detected",
96 input,
97 )
98 .or_else(|| {
99 regex_detect(
100 std::slice::from_ref(&*WEAK_PATTERNS),
101 self.name(),
102 AttackCategory::Injection,
103 Severity::Low,
104 "SQL keyword present (weak signal)",
105 input,
106 )
107 })
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114
115 fn det() -> SqlInjectionDetector {
116 SqlInjectionDetector
117 }
118
119 fn assert_hit(input: &str) {
120 assert_hit_at(input, Severity::Critical);
121 }
122
123 fn assert_hit_at(input: &str, severity: Severity) {
124 crate::test_helpers::assert_detected(&det(), input, AttackCategory::Injection, severity);
125 }
126
127 #[test]
128 fn name_is_sql_injection() {
129 assert_eq!(det().name(), "sql_injection");
130 }
131
132 #[test]
133 fn detects_common_payloads() {
134 for input in [
135 "1 UNION SELECT password FROM users",
136 "1; SELECT pg_sleep(5)",
137 "admin' OR '1'='1",
138 "SELECT * FROM users WHERE id=1",
139 "id=1 /*!50000union select*/",
140 "1; WAITFOR DELAY '0:0:5'",
141 "username' OR 1=1 --",
142 "admin'--",
143 "1') --",
144 "x'#comment",
145 "x'#comment&foo=bar",
146 "-- or 1=1",
147 "/*x*/ union select",
148 "1+UNION+SELECT+username,password+FROM+users",
150 "1%20UNION%20SELECT%201",
151 "SELECT+id+FROM+users",
152 "UN/**/ION SELECT 1,2,3",
154 "SELECT name, email FROM users",
156 "SELECT COUNT(*) FROM users",
157 "SELECT 用户名 FROM 用户表",
158 "SELECT DISTINCT id FROM t",
159 "SELECT id, count(*) FROM logs",
160 "a'/**/or 1=1",
161 ] {
162 assert_hit(input);
163 }
164 }
165
166 #[test]
167 fn benign_inputs_not_detected() {
168 for input in [
169 "Hello, this is a normal text input. Nothing suspicious here.",
170 "Please choose an option below",
171 "I will sleep well tonight",
172 "The benchmark results look great",
173 "Drop me a line when you arrive",
174 "The information desk is on the second floor",
175 "q=2024--2025",
176 "q=donation=5",
177 "穿越之霸道总裁爱上我--重生之都市修仙",
178 "chapter 2024--2025 更新",
179 "donation=5&q=test",
180 ] {
181 assert!(det().detect(input).is_none(), "false positive: {input}");
182 }
183 }
184
185 #[test]
189 fn realistic_text_not_detected() {
190 for input in [
191 r##"<a href="#">Back to top</a>"##,
193 "<a href='#'>",
194 "See the anchor href=\"#\" for details.",
195 r##"<a href="#main">Skip to content</a>"##,
196 r##"<a href="#section-2">Jump</a>"##,
197 r##"<p>Read the <a href="#faq">FAQ</a> before contacting support.</p>"##,
198 r##"<a href="/about">About us</a>"##,
199 r##"<link rel="stylesheet" href="/static/app.css?v=3">"##,
200 r##"<div id="top">"##,
201 "color: #09f; background: #fff;",
202 "Please select a date from the calendar below.",
204 "Select your language from the menu.",
205 "select the file from your computer",
206 "I will select the best option from these.",
207 "Please select the country from the dropdown list above.",
208 "We will select the winner from all entries on Friday.",
209 "Please describe the issue, then select a priority from the list.",
210 "Veuillez sélectionner votre langue dans le menu.",
211 "Our team will respond to your request within 24 hours.",
212 "https://docs.example.com/guide#getting-started",
214 "https://shop.example.com/products?color=%23ff0000&sort=price_asc",
215 "http://example.com/search?q=best+laptop+2026&page=2",
216 r##"{"theme":"dark","accent":"#fff","bg":"#1a1a1a"}"##,
217 r##"{"build":{"target":"x86_64","profile":"release","features":[]}}"##,
218 "2026-09-30T12:00:00Z INFO request completed status=200 path=/api/v1/items duration=42ms",
220 "level=error msg=\"disk usage at 91%\" service=ingest host=node-7",
221 "The # symbol is also known as a hash or pound sign.",
223 "Contact us at support@example.com or call +1-555-0100.",
224 ] {
225 assert!(det().detect(input).is_none(), "false positive: {input}");
226 }
227 }
228
229 #[test]
233 fn tightened_shapes_detect_attacks_and_ignore_ordinary_text() {
234 assert_hit("1 AND SLEEP(5)");
236 assert_hit("1 OR SLEEP(5)");
237 assert_hit("1; SELECT SLEEP(5)");
238 assert_hit("1' AND SLEEP(5)-- ");
239 assert_hit("1 AND BENCHMARK(1000000,MD5(1))");
241 assert_hit("1 AND pg_sleep(5)");
243 assert_hit("1; EXEC xp_cmdshell 'dir'");
244 assert_hit("1; EXEC sp_executesql N'SELECT 1'");
245 assert_hit("1' EXEC xp_cmdshell 'dir'");
246 assert_hit("1; WAITFOR DELAY '0:0:5'");
247 assert_hit("1; WAITFOR DELAY 0:0:5");
248 assert_hit("1 AND /*!50000SLEEP(5)*/");
250 assert_hit("1 UNION SELECT LOAD_FILE('/etc/passwd')");
252 assert_hit("1 UNION SELECT LOAD_FILE(0x2f657463)");
253 assert_hit("1 UNION SELECT 'x' INTO OUTFILE '/tmp/a'");
254 assert_hit("1 UNION SELECT 'x' INTO DUMPFILE '/tmp/a'");
255 assert_hit("1 UNION SELECT 'x' INTO/**/OUTFILE '/tmp/a'");
256 assert_hit("1; DROP TABLE users");
258 assert_hit("1; INSERT INTO users VALUES(1)");
259 assert_hit("1'; DROP TABLE users");
260 assert_hit("1'; INSERT INTO users VALUES(1)");
261 assert_hit("-- or 1=1");
263 assert_hit("# or 1=1");
264 assert_hit("/*x*/ union select");
265
266 for input in [
267 "Add a sleep(1) between retries to avoid hammering upstream.",
269 "2026-09-30T12:00:01Z WARN retrying after sleep(2)",
270 " except TransientError:\n sleep(2 ** i)",
271 "time.sleep(0.5) # backoff",
272 "benchmark(parseConfig, { iterations: 10000 });",
274 "/*! Parses the config. */\nint parse(const char *p);",
276 "The <code>load_file(fd)</code> helper reads a descriptor.",
278 "MySQL's <code>LOAD_FILE()</code> needs the FILE privilege.",
279 "The outfile is written to the shared drive every night.",
280 "Please insert into the form the reference number shown above.",
281 "Then drop table 12 for the party at the venue.",
282 "Call pg_sleep(5) to pause the session during a migration.",
284 "ERROR: function pg_sleep(integer) does not exist",
285 "The stored procedure exec sp_who2 lists active sessions.",
286 "Legacy jobs call exec xp_cmdshell, which is disabled by default.",
287 "In T-SQL use WAITFOR DELAY to pause before the retry.",
288 "Use INTO OUTFILE to export the result set to disk.",
289 "INTO DUMPFILE writes a single row without delimiters.",
290 "ERROR 1064: You have an error in your SQL syntax near 'INSERT INTO users (id) VALUES (1)' at line 1",
292 "ERROR 1051: Unknown table 'users' near 'DROP TABLE users_old' at line 1",
293 r#"{"query": "INSERT INTO users VALUES (1)"}"#,
294 r#"[INFO] sql="DROP TABLE tmp_users""#,
295 "# Or use the CLI instead.",
297 "## And then run the tests.",
298 "The results -- and the conclusions -- were surprising.",
299 "Wait -- or restart the service.",
300 "/* reset */ and then apply the theme",
301 ] {
302 crate::test_helpers::assert_clean(&det(), input);
303 }
304 }
305
306 #[test]
312 fn numeric_tautology_detected_but_prose_about_it_kept_clean() {
313 for input in [
314 "1 OR 1=1",
315 "1 AND 1=1",
316 "1 OR 1=1#",
317 "1 AND 1=1--",
318 "id=1 OR 1=1",
319 "admin' OR 1=1#",
320 "/search?id=1 OR 1=1#",
321 "1 or 1 = 1 --",
322 ] {
323 assert_hit(input);
324 }
325 for input in [
326 "1 or 1 = 1 is a tautology",
328 "In Boolean algebra, 1 and 1 = 1 while 1 and 0 = 0.",
329 "A condition like `x = 1 OR 1 = 1` makes the clause always true.",
330 "Searching for \"1 or 1=1\" brings up injection tutorials.",
331 ] {
332 crate::test_helpers::assert_clean(&det(), input);
333 }
334 }
335
336 #[test]
341 fn quote_comment_lookalikes_are_not_injection() {
342 for input in [
344 "admin'--",
345 "1') --",
346 "1) --",
347 "x'#comment",
348 "x'#comment&foo=bar",
349 "1')#x",
350 "a'/**/or 1=1",
351 "username' OR 1=1 --",
352 ] {
353 assert_hit(input);
354 }
355 for input in [
357 r##"let re = Regex::new(r#"\d+"#).unwrap(),"##,
359 "static P: LazyLock<Vec<Regex>> = LazyLock::new(|| vec![Regex::new(r\"#x\").unwrap()]);",
360 "int r = compute(a, b) /* note: keep in sync */;",
362 "if (n) --i;",
363 "#set($x = 5)\n#foreach($item in $items)",
365 "echo 'hello' # say hi",
367 "grep 'pattern' -- file.txt",
368 "git commit -m 'msg' -- file.txt",
369 "x = 'a' /* c */",
371 ] {
372 crate::test_helpers::assert_clean(&det(), input);
373 }
374 }
375
376 #[test]
380 fn information_schema_is_low_not_critical() {
381 for input in [
382 "ERROR query failed: relation \"information_schema\" does not exist",
383 "Read <code>information_schema.tables</code> to list tables.",
384 ] {
385 assert_hit_at(input, Severity::Low);
386 }
387 for input in [
389 "1 UNION SELECT table_name FROM information_schema.tables",
390 "1 AND (SELECT 1 FROM information_schema.tables)",
391 ] {
392 assert_hit(input);
393 }
394 }
395
396 #[test]
397 fn edge_cases() {
398 assert!(det().detect("").is_none());
399 assert!(det().detect(" \t\n ").is_none());
400 assert!(det().detect("你好世界 こんにちは").is_none());
401 assert!(det().detect("UNOIN SILE CT *").is_none());
403 assert!(det().detect("select from users").is_none());
404 assert!(det().detect("sleep 5").is_none());
405 }
406
407 #[test]
408 fn obfuscated_variants_detected() {
409 for input in [
410 "1 UnIoN SeLeCt password",
411 "Sleep(5)",
412 "1; SELECT Pg_Sleep(10)",
413 "' or '1'='1",
414 ] {
415 assert_hit(input);
416 }
417 }
418}