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