use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
use regex::Regex;
use std::sync::LazyLock;
static STRONG_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(concat!(
r"(?i:UN(?:/\*.*?\*/)?ION(?:[\s+]|%20)+(?:ALL(?:[\s+]|%20)+)?SELECT",
r#"|SELECT(?:[\s+]|%20)+(?:DISTINCT(?:[\s+]|%20)+)?(?:\*|[\w"'\[\].]+(?:\([^)\n]{0,40}\))?(?:\s*,\s*[\w"'\[\].]+(?:\([^)\n]{0,40}\))?)*)(?:[\s+]|%20)+FROM(?:[\s+]|%20)+[\w"\[\].]+"#,
r"|/\*!\d*\s*(?:union|select|insert|update|delete|drop|alter|sleep|benchmark|load_file|outfile|or|and|where)\b",
r#"|(?:^|['"();]|\b(?:and|or|select|union|where|having)\b)[\s+]*sleep\s*\("#,
r"|benchmark\s*\(\s*\d+\s*,",
r#"|(?:^|['"();]|\b(?:and|or|select|union|where|having)\b)[\s+]*pg_sleep\s*\("#,
r#"|(?:^|['"();])\s*exec\s+(?:sp_|xp_)"#,
r#"|WAITFOR\s+DELAY\s+['"]?\d"#,
r"|'\s*OR\s*'1'\s*=\s*'1",
r"|'\s*OR\s*1\s*=\s*1\s*--",
r"|\b(?:or|and)\b\s+1\s*=\s*1\s*(?:--|#|$)",
r#"|LOAD_FILE\s*\(\s*(?:['"]|0x)"#,
r#"|INTO\s+(?:OUT|DUMP)FILE\s+['"]"#,
r#"|OUTFILE\s+['"]"#,
r#"|(?:^|[();])\s*DROP\s+TABLE"#,
r#"|(?:^|[();])\s*INSERT\s+INTO"#,
r#"|(?:'\)?#[^"'\n]*(?:\n|$)|'\)[ \t]*--(?:\s|$|\+)|'--(?:\s|$|\+)|'/\*|\)[ \t]*--(?:\s|$|\+))"#,
r#"|(?:/\*.*?\*/|--|#)\s*(?:or\s+['"(\d]|and\s+['"(\d]|union\s+(?:all\s+)?select\b|select\s+(?:\*|['"(\d]|[\w`\[\]]+\s*(?:,|from\b))))"#,
))
.unwrap()
});
static WEAK_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i:information_schema)")
.unwrap()
});
pub struct SqlInjectionDetector;
impl Detector for SqlInjectionDetector {
fn name(&self) -> &'static str {
"sql_injection"
}
fn detect(&self, input: &str) -> Option<DetectionResult> {
regex_detect(
std::slice::from_ref(&*STRONG_PATTERNS),
self.name(),
AttackCategory::Injection,
Severity::Critical,
"SQL injection detected",
input,
)
.or_else(|| {
regex_detect(
std::slice::from_ref(&*WEAK_PATTERNS),
self.name(),
AttackCategory::Injection,
Severity::Low,
"SQL keyword present (weak signal)",
input,
)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn det() -> SqlInjectionDetector {
SqlInjectionDetector
}
fn assert_hit(input: &str) {
assert_hit_at(input, Severity::Critical);
}
fn assert_hit_at(input: &str, severity: Severity) {
crate::test_helpers::assert_detected(&det(), input, AttackCategory::Injection, severity);
}
#[test]
fn name_is_sql_injection() {
assert_eq!(det().name(), "sql_injection");
}
#[test]
fn detects_common_payloads() {
for input in [
"1 UNION SELECT password FROM users",
"1; SELECT pg_sleep(5)",
"admin' OR '1'='1",
"SELECT * FROM users WHERE id=1",
"id=1 /*!50000union select*/",
"1; WAITFOR DELAY '0:0:5'",
"username' OR 1=1 --",
"admin'--",
"1') --",
"x'#comment",
"x'#comment&foo=bar",
"-- or 1=1",
"/*x*/ union select",
"1+UNION+SELECT+username,password+FROM+users",
"1%20UNION%20SELECT%201",
"SELECT+id+FROM+users",
"UN/**/ION SELECT 1,2,3",
"SELECT name, email FROM users",
"SELECT COUNT(*) FROM users",
"SELECT 用户名 FROM 用户表",
"SELECT DISTINCT id FROM t",
"SELECT id, count(*) FROM logs",
"a'/**/or 1=1",
] {
assert_hit(input);
}
}
#[test]
fn benign_inputs_not_detected() {
for input in [
"Hello, this is a normal text input. Nothing suspicious here.",
"Please choose an option below",
"I will sleep well tonight",
"The benchmark results look great",
"Drop me a line when you arrive",
"The information desk is on the second floor",
"q=2024--2025",
"q=donation=5",
"穿越之霸道总裁爱上我--重生之都市修仙",
"chapter 2024--2025 更新",
"donation=5&q=test",
] {
assert!(det().detect(input).is_none(), "false positive: {input}");
}
}
#[test]
fn realistic_text_not_detected() {
for input in [
r##"<a href="#">Back to top</a>"##,
"<a href='#'>",
"See the anchor href=\"#\" for details.",
r##"<a href="#main">Skip to content</a>"##,
r##"<a href="#section-2">Jump</a>"##,
r##"<p>Read the <a href="#faq">FAQ</a> before contacting support.</p>"##,
r##"<a href="/about">About us</a>"##,
r##"<link rel="stylesheet" href="/static/app.css?v=3">"##,
r##"<div id="top">"##,
"color: #09f; background: #fff;",
"Please select a date from the calendar below.",
"Select your language from the menu.",
"select the file from your computer",
"I will select the best option from these.",
"Please select the country from the dropdown list above.",
"We will select the winner from all entries on Friday.",
"Please describe the issue, then select a priority from the list.",
"Veuillez sélectionner votre langue dans le menu.",
"Our team will respond to your request within 24 hours.",
"https://docs.example.com/guide#getting-started",
"https://shop.example.com/products?color=%23ff0000&sort=price_asc",
"http://example.com/search?q=best+laptop+2026&page=2",
r##"{"theme":"dark","accent":"#fff","bg":"#1a1a1a"}"##,
r##"{"build":{"target":"x86_64","profile":"release","features":[]}}"##,
"2026-09-30T12:00:00Z INFO request completed status=200 path=/api/v1/items duration=42ms",
"level=error msg=\"disk usage at 91%\" service=ingest host=node-7",
"The # symbol is also known as a hash or pound sign.",
"Contact us at support@example.com or call +1-555-0100.",
] {
assert!(det().detect(input).is_none(), "false positive: {input}");
}
}
#[test]
fn tightened_shapes_detect_attacks_and_ignore_ordinary_text() {
assert_hit("1 AND SLEEP(5)");
assert_hit("1 OR SLEEP(5)");
assert_hit("1; SELECT SLEEP(5)");
assert_hit("1' AND SLEEP(5)-- ");
assert_hit("1 AND BENCHMARK(1000000,MD5(1))");
assert_hit("1 AND pg_sleep(5)");
assert_hit("1; EXEC xp_cmdshell 'dir'");
assert_hit("1; EXEC sp_executesql N'SELECT 1'");
assert_hit("1' EXEC xp_cmdshell 'dir'");
assert_hit("1; WAITFOR DELAY '0:0:5'");
assert_hit("1; WAITFOR DELAY 0:0:5");
assert_hit("1 AND /*!50000SLEEP(5)*/");
assert_hit("1 UNION SELECT LOAD_FILE('/etc/passwd')");
assert_hit("1 UNION SELECT LOAD_FILE(0x2f657463)");
assert_hit("1 UNION SELECT 'x' INTO OUTFILE '/tmp/a'");
assert_hit("1 UNION SELECT 'x' INTO DUMPFILE '/tmp/a'");
assert_hit("1 UNION SELECT 'x' INTO/**/OUTFILE '/tmp/a'");
assert_hit("1; DROP TABLE users");
assert_hit("1; INSERT INTO users VALUES(1)");
assert_hit("1'; DROP TABLE users");
assert_hit("1'; INSERT INTO users VALUES(1)");
assert_hit("-- or 1=1");
assert_hit("# or 1=1");
assert_hit("/*x*/ union select");
for input in [
"Add a sleep(1) between retries to avoid hammering upstream.",
"2026-09-30T12:00:01Z WARN retrying after sleep(2)",
" except TransientError:\n sleep(2 ** i)",
"time.sleep(0.5) # backoff",
"benchmark(parseConfig, { iterations: 10000 });",
"/*! Parses the config. */\nint parse(const char *p);",
"The <code>load_file(fd)</code> helper reads a descriptor.",
"MySQL's <code>LOAD_FILE()</code> needs the FILE privilege.",
"The outfile is written to the shared drive every night.",
"Please insert into the form the reference number shown above.",
"Then drop table 12 for the party at the venue.",
"Call pg_sleep(5) to pause the session during a migration.",
"ERROR: function pg_sleep(integer) does not exist",
"The stored procedure exec sp_who2 lists active sessions.",
"Legacy jobs call exec xp_cmdshell, which is disabled by default.",
"In T-SQL use WAITFOR DELAY to pause before the retry.",
"Use INTO OUTFILE to export the result set to disk.",
"INTO DUMPFILE writes a single row without delimiters.",
"ERROR 1064: You have an error in your SQL syntax near 'INSERT INTO users (id) VALUES (1)' at line 1",
"ERROR 1051: Unknown table 'users' near 'DROP TABLE users_old' at line 1",
r#"{"query": "INSERT INTO users VALUES (1)"}"#,
r#"[INFO] sql="DROP TABLE tmp_users""#,
"# Or use the CLI instead.",
"## And then run the tests.",
"The results -- and the conclusions -- were surprising.",
"Wait -- or restart the service.",
"/* reset */ and then apply the theme",
] {
crate::test_helpers::assert_clean(&det(), input);
}
}
#[test]
fn numeric_tautology_detected_but_prose_about_it_kept_clean() {
for input in [
"1 OR 1=1",
"1 AND 1=1",
"1 OR 1=1#",
"1 AND 1=1--",
"id=1 OR 1=1",
"admin' OR 1=1#",
"/search?id=1 OR 1=1#",
"1 or 1 = 1 --",
] {
assert_hit(input);
}
for input in [
"1 or 1 = 1 is a tautology",
"In Boolean algebra, 1 and 1 = 1 while 1 and 0 = 0.",
"A condition like `x = 1 OR 1 = 1` makes the clause always true.",
"Searching for \"1 or 1=1\" brings up injection tutorials.",
] {
crate::test_helpers::assert_clean(&det(), input);
}
}
#[test]
fn quote_comment_lookalikes_are_not_injection() {
for input in [
"admin'--",
"1') --",
"1) --",
"x'#comment",
"x'#comment&foo=bar",
"1')#x",
"a'/**/or 1=1",
"username' OR 1=1 --",
] {
assert_hit(input);
}
for input in [
r##"let re = Regex::new(r#"\d+"#).unwrap(),"##,
"static P: LazyLock<Vec<Regex>> = LazyLock::new(|| vec![Regex::new(r\"#x\").unwrap()]);",
"int r = compute(a, b) /* note: keep in sync */;",
"if (n) --i;",
"#set($x = 5)\n#foreach($item in $items)",
"echo 'hello' # say hi",
"grep 'pattern' -- file.txt",
"git commit -m 'msg' -- file.txt",
"x = 'a' /* c */",
] {
crate::test_helpers::assert_clean(&det(), input);
}
}
#[test]
fn information_schema_is_low_not_critical() {
for input in [
"ERROR query failed: relation \"information_schema\" does not exist",
"Read <code>information_schema.tables</code> to list tables.",
] {
assert_hit_at(input, Severity::Low);
}
for input in [
"1 UNION SELECT table_name FROM information_schema.tables",
"1 AND (SELECT 1 FROM information_schema.tables)",
] {
assert_hit(input);
}
}
#[test]
fn edge_cases() {
assert!(det().detect("").is_none());
assert!(det().detect(" \t\n ").is_none());
assert!(det().detect("你好世界 こんにちは").is_none());
assert!(det().detect("UNOIN SILE CT *").is_none());
assert!(det().detect("select from users").is_none());
assert!(det().detect("sleep 5").is_none());
}
#[test]
fn obfuscated_variants_detected() {
for input in [
"1 UnIoN SeLeCt password",
"Sleep(5)",
"1; SELECT Pg_Sleep(10)",
"' or '1'='1",
] {
assert_hit(input);
}
}
}