use std::sync::LazyLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RuleCategory {
Union,
BooleanBlind,
TimeBlind,
DynamicExec,
FileOps,
InfoLeak,
Encoding,
Stacked,
Comment,
Other,
#[cfg(feature = "sql-parser")]
DynamicVariable,
DdlForbidden,
GraphProcedure,
}
#[derive(Debug)]
pub struct InjectionRule {
pub id: &'static str,
pub category: RuleCategory,
pub pattern: &'static str,
}
pub struct InjectionEngine {
rules: Vec<InjectionRule>,
#[cfg(feature = "sql-parser")]
variable_regexes: Vec<regex::Regex>,
}
static GLOBAL_ENGINE: LazyLock<InjectionEngine> = LazyLock::new(InjectionEngine::build_global);
impl InjectionEngine {
pub fn global() -> &'static Self {
&GLOBAL_ENGINE
}
pub fn rule_count(&self) -> usize {
self.rules.len()
}
pub fn scan(&self, prepared: &str, categories: &[RuleCategory]) -> Vec<&InjectionRule> {
self.rules
.iter()
.filter(|rule| categories.contains(&rule.category) && prepared.contains(rule.pattern))
.collect()
}
#[cfg(feature = "sql-parser")]
pub fn scan_relational(&self, sql: &str) -> Vec<&InjectionRule> {
use crate::access::sql_parser::{
normalize_unicode, remove_string_literals, strip_block_comments,
};
let normalized = normalize_unicode(sql);
if normalized.contains("/*") {
return self
.rules
.iter()
.filter(|rule| rule.id == "comment.block_marker")
.collect();
}
let without_strings = remove_string_literals(&normalized);
let without_comments = strip_block_comments(&without_strings);
let prepared = without_comments.to_uppercase();
self.scan(
&prepared,
&[
RuleCategory::Union,
RuleCategory::BooleanBlind,
RuleCategory::TimeBlind,
RuleCategory::DynamicExec,
RuleCategory::FileOps,
RuleCategory::InfoLeak,
RuleCategory::Encoding,
RuleCategory::Stacked,
RuleCategory::Comment,
RuleCategory::Other,
],
)
}
#[cfg(feature = "sql-parser")]
pub fn is_suspicious_relational(&self, sql: &str) -> bool {
!self.scan_relational(sql).is_empty()
}
#[cfg(feature = "sql-parser")]
pub fn has_dynamic_variables(&self, sql: &str) -> bool {
use crate::access::sql_parser::remove_string_literals;
let without_strings = remove_string_literals(sql);
self.variable_regexes
.iter()
.any(|re| re.is_match(&without_strings))
}
pub fn scan_ddl(&self, sql: &str) -> Vec<&InjectionRule> {
let prepared = sql.trim().to_uppercase();
self.scan(&prepared, &[RuleCategory::DdlForbidden])
}
pub fn scan_graph(&self, cypher: &str) -> Vec<&InjectionRule> {
let prepared = cypher.to_ascii_lowercase();
self.scan(
&prepared,
&[RuleCategory::Comment, RuleCategory::GraphProcedure],
)
.into_iter()
.filter(|rule| {
rule.pattern.starts_with("call ") || rule.pattern == "/*" || rule.pattern == "*/"
})
.collect()
}
fn build_global() -> Self {
const RELATIONAL: &[(&str, RuleCategory, &str)] = &[
("union.select", RuleCategory::Union, "UNION SELECT"),
("union.all_select", RuleCategory::Union, "UNION ALL SELECT"),
(
"union.distinct_select",
RuleCategory::Union,
"UNION DISTINCT SELECT",
),
("bool.or_1eq1", RuleCategory::BooleanBlind, " OR 1=1"),
("bool.or_1sp1", RuleCategory::BooleanBlind, " OR 1 =1"),
("bool.or_1sp_eq1", RuleCategory::BooleanBlind, " OR 1= 1"),
(
"bool.or_1sp_eq_sp1",
RuleCategory::BooleanBlind,
" OR 1 = 1",
),
("bool.or_true", RuleCategory::BooleanBlind, " OR TRUE"),
("bool.or_false", RuleCategory::BooleanBlind, " OR FALSE"),
("bool.and_1eq1", RuleCategory::BooleanBlind, " AND 1=1"),
("bool.and_true", RuleCategory::BooleanBlind, " AND TRUE"),
("bool.and_false", RuleCategory::BooleanBlind, " AND FALSE"),
("time.sleep", RuleCategory::TimeBlind, "SLEEP("),
("time.benchmark", RuleCategory::TimeBlind, "BENCHMARK("),
("time.pg_sleep", RuleCategory::TimeBlind, "PG_SLEEP("),
(
"time.pg_sleep_for",
RuleCategory::TimeBlind,
"PG_SLEEP_FOR(",
),
(
"time.pg_sleep_until",
RuleCategory::TimeBlind,
"PG_SLEEP_UNTIL(",
),
(
"time.waitfor_delay",
RuleCategory::TimeBlind,
"WAITFOR DELAY",
),
("time.waitfor_time", RuleCategory::TimeBlind, "WAITFOR TIME"),
(
"time.dbms_pipe",
RuleCategory::TimeBlind,
"DBMS_PIPE.RECEIVE_MESSAGE(",
),
(
"time.dbms_lock",
RuleCategory::TimeBlind,
"DBMS_LOCK.SLEEP(",
),
("exec.exec", RuleCategory::DynamicExec, "EXEC("),
("exec.execute", RuleCategory::DynamicExec, "EXECUTE("),
(
"exec.sp_executesql",
RuleCategory::DynamicExec,
"SP_EXECUTESQL",
),
("exec.xp_cmdshell", RuleCategory::DynamicExec, "XP_CMDSHELL"),
("exec.xp_generic", RuleCategory::DynamicExec, " xp_"),
("file.load_file", RuleCategory::FileOps, "LOAD_FILE("),
("file.into_outfile", RuleCategory::FileOps, "INTO OUTFILE"),
("file.into_dumpfile", RuleCategory::FileOps, "INTO DUMPFILE"),
(
"info.information_schema",
RuleCategory::InfoLeak,
"INFORMATION_SCHEMA",
),
("info.sysobjects", RuleCategory::InfoLeak, "SYSOBJECTS"),
("info.syscolumns", RuleCategory::InfoLeak, "SYSCOLUMNS"),
("info.sys_tables", RuleCategory::InfoLeak, "SYS.TABLES"),
("info.sys_columns", RuleCategory::InfoLeak, "SYS.COLUMNS"),
(
"info.sys_databases",
RuleCategory::InfoLeak,
"SYS.DATABASES",
),
("info.mysql_user", RuleCategory::InfoLeak, "MYSQL.USER"),
("info.pg_user", RuleCategory::InfoLeak, "PG_USER"),
("info.pg_shadow", RuleCategory::InfoLeak, "PG_SHADOW"),
("info.all_tables", RuleCategory::InfoLeak, "ALL_TABLES"),
("info.all_columns", RuleCategory::InfoLeak, "ALL_COLUMNS"),
(
"info.all_tab_columns",
RuleCategory::InfoLeak,
"ALL_TAB_COLUMNS",
),
("info.user_tables", RuleCategory::InfoLeak, "USER_TABLES"),
(
"info.user_tab_columns",
RuleCategory::InfoLeak,
"USER_TAB_COLUMNS",
),
("enc.char", RuleCategory::Encoding, "CHAR("),
("enc.chr", RuleCategory::Encoding, "CHR("),
("enc.concat", RuleCategory::Encoding, "CONCAT("),
("enc.concat_ws", RuleCategory::Encoding, "CONCAT_WS("),
("enc.hex_prefix", RuleCategory::Encoding, "0X"),
("stack.drop", RuleCategory::Stacked, "; DROP"),
("stack.delete", RuleCategory::Stacked, "; DELETE"),
("stack.update", RuleCategory::Stacked, "; UPDATE"),
("stack.insert", RuleCategory::Stacked, "; INSERT"),
("stack.truncate", RuleCategory::Stacked, "; TRUNCATE"),
("stack.alter", RuleCategory::Stacked, "; ALTER"),
("stack.create", RuleCategory::Stacked, "; CREATE"),
("stack.exec", RuleCategory::Stacked, "; EXEC"),
("comment.dash_space", RuleCategory::Comment, "-- "),
("comment.dash_plus", RuleCategory::Comment, "--+"),
("comment.hash", RuleCategory::Comment, "#"),
("other.having_tautology", RuleCategory::Other, "HAVING 1=1"),
("other.order_by_dash", RuleCategory::Other, "ORDER BY 1--"),
(
"other.procedure_analyse",
RuleCategory::Other,
"PROCEDURE ANALYSE(",
),
("other.extractvalue", RuleCategory::Other, "EXTRACTVALUE("),
("other.updatexml", RuleCategory::Other, "UPDATEXML("),
("other.xmltype", RuleCategory::Other, "XMLTYPE("),
("other.utl_http", RuleCategory::Other, "UTL_HTTP.REQUEST("),
(
"other.utl_inaddr_host",
RuleCategory::Other,
"UTL_INADDR.GET_HOST_ADDRESS(",
),
(
"other.utl_inaddr_name",
RuleCategory::Other,
"UTL_INADDR.GET_HOST_NAME(",
),
];
const DDL: &[(&str, RuleCategory, &str)] = &[
(
"ddl.drop_database",
RuleCategory::DdlForbidden,
"DROP DATABASE",
),
("ddl.drop_all", RuleCategory::DdlForbidden, "DROP ALL"),
];
const GRAPH: &[(&str, RuleCategory, &str)] = &[
("comment.block_marker", RuleCategory::Comment, "/*"),
("graph.block_comment_close", RuleCategory::Comment, "*/"),
(
"graph.call_apoc",
RuleCategory::GraphProcedure,
"call apoc.",
),
(
"graph.call_dbms",
RuleCategory::GraphProcedure,
"call dbms.",
),
("graph.call_db", RuleCategory::GraphProcedure, "call db."),
("graph.call_tx", RuleCategory::GraphProcedure, "call tx."),
];
let mut rules: Vec<InjectionRule> = RELATIONAL
.iter()
.chain(DDL.iter())
.chain(GRAPH.iter())
.map(|(id, category, pattern)| InjectionRule {
id,
category: *category,
pattern,
})
.collect();
let mut seen: std::collections::HashSet<(&str, &str)> = std::collections::HashSet::new();
rules.retain(|rule| seen.insert((rule.pattern, category_key(&rule.category))));
Self {
rules,
#[cfg(feature = "sql-parser")]
variable_regexes: vec![
regex::Regex::new(r"@[\w]+").expect("Regex pattern should be valid"),
regex::Regex::new(r":[a-zA-Z_][\w]*").expect("Regex pattern should be valid"),
regex::Regex::new(r"\$\{?[\w]+\}?").expect("Regex pattern should be valid"),
regex::Regex::new(r"%[\w]+%").expect("Regex pattern should be valid"),
regex::Regex::new(r"0x[0-9A-Fa-f]+").expect("Regex pattern should be valid"),
],
}
}
}
fn category_key(category: &RuleCategory) -> &'static str {
match category {
RuleCategory::Union => "union",
RuleCategory::BooleanBlind => "bool",
RuleCategory::TimeBlind => "time",
RuleCategory::DynamicExec => "exec",
RuleCategory::FileOps => "file",
RuleCategory::InfoLeak => "info",
RuleCategory::Encoding => "enc",
RuleCategory::Stacked => "stack",
RuleCategory::Comment => "comment",
RuleCategory::Other => "other",
#[cfg(feature = "sql-parser")]
RuleCategory::DynamicVariable => "dynvar",
RuleCategory::DdlForbidden => "ddl",
RuleCategory::GraphProcedure => "graph",
}
}
#[cfg(test)]
mod tests {
use super::*;
const LEGACY_RELATIONAL: &[&str] = &[
"UNION SELECT",
"UNION ALL SELECT",
"UNION DISTINCT SELECT",
" OR 1=1",
" OR 1 =1",
" OR 1= 1",
" OR 1 = 1",
" OR TRUE",
" OR FALSE",
" AND 1=1",
" AND TRUE",
" AND FALSE",
"SLEEP(",
"BENCHMARK(",
"PG_SLEEP(",
"PG_SLEEP_FOR(",
"PG_SLEEP_UNTIL(",
"WAITFOR DELAY",
"WAITFOR TIME",
"DBMS_PIPE.RECEIVE_MESSAGE(",
"DBMS_LOCK.SLEEP(",
"EXEC(",
"EXECUTE(",
"SP_EXECUTESQL",
"XP_CMDSHELL",
" xp_",
"EXEC xp_",
"EXECUTE xp_",
"LOAD_FILE(",
"INTO OUTFILE",
"INTO DUMPFILE",
"INFORMATION_SCHEMA",
"SYSOBJECTS",
"SYSCOLUMNS",
"SYS.TABLES",
"SYS.COLUMNS",
"SYS.DATABASES",
"MYSQL.USER",
"PG_USER",
"PG_SHADOW",
"ALL_TABLES",
"ALL_COLUMNS",
"ALL_TAB_COLUMNS",
"USER_TABLES",
"USER_TAB_COLUMNS",
"CHAR(",
"CHR(",
"CONCAT(",
"CONCAT_WS(",
"0X",
"; DROP",
"; DELETE",
"; UPDATE",
"; INSERT",
"; TRUNCATE",
"; ALTER",
"; CREATE",
"; EXEC",
"; EXECUTE",
"-- ",
"--+",
"#",
"HAVING 1=1",
"ORDER BY 1--",
"ORDER BY 1#",
"PROCEDURE ANALYSE(",
"EXTRACTVALUE(",
"UPDATEXML(",
"XMLTYPE(",
"UTL_HTTP.REQUEST(",
"UTL_INADDR.GET_HOST_ADDRESS(",
"UTL_INADDR.GET_HOST_NAME(",
];
fn legacy_contains(prepared_upper: &str) -> bool {
LEGACY_RELATIONAL
.iter()
.any(|pattern| prepared_upper.contains(pattern))
}
fn engine_contains(prepared_upper: &str) -> bool {
!InjectionEngine::global()
.scan(
prepared_upper,
&[
RuleCategory::Union,
RuleCategory::BooleanBlind,
RuleCategory::TimeBlind,
RuleCategory::DynamicExec,
RuleCategory::FileOps,
RuleCategory::InfoLeak,
RuleCategory::Encoding,
RuleCategory::Stacked,
RuleCategory::Comment,
RuleCategory::Other,
],
)
.is_empty()
}
#[test]
fn test_parity_with_legacy_rule_set() {
let attack_corpus = [
"SELECT * FROM USERS WHERE ID = 1 UNION SELECT PASSWORD FROM CREDENTIALS",
"SELECT 1 OR 1=1",
"SELECT 1 AND TRUE",
"SELECT SLEEP(10)",
"SELECT PG_SLEEP(5)",
"WAITFOR DELAY 0:0:10",
"EXEC XP_CMDSHELL DIR",
"EXECUTE SP_EXECUTESQL X",
"SELECT USER_X FROM T", "SELECT LOAD_FILE('/ETC/PASSWD')",
"SELECT * INTO OUTFILE '/TMP/X'",
"SELECT * FROM INFORMATION_SCHEMA.TABLES",
"SELECT CHR(65)",
"SELECT CONCAT(A, B) FROM T",
"SELECT 1; DROP TABLE USERS",
"SELECT 1 -- COMMENT",
"SELECT 1 #COMMENT",
"SELECT * FROM T HAVING 1=1",
"SELECT EXTRACTVALUE(1, CONCAT(0X5C))",
];
let benign_corpus = [
"SELECT ID, NAME FROM USERS WHERE ID = ?",
"SELECT COUNT(*) AS C FROM ORDERS WHERE STATUS = PAID",
"INSERT INTO LOGS (LEVEL, MESSAGE) VALUES (INFO, OK)",
"UPDATE USERS SET LAST_SEEN = NOW() WHERE ID = 42",
"SELECT NAME FROM CATEGORIES WHERE PARENT_ID IS NULL",
"SELECT A FROM T WHERE B IN (1, 2, 3)",
];
let corpora: [&[&str]; 2] = [&attack_corpus, &benign_corpus];
for corpus in corpora {
for input in corpus {
assert_eq!(
engine_contains(input),
legacy_contains(input),
"引擎与遗留规则表判定不一致: {input}"
);
}
}
#[cfg(feature = "sql-parser")]
assert!(
!InjectionEngine::global()
.scan_relational("SELECT /* HIDDEN */ 1")
.is_empty()
);
}
#[test]
fn test_dedup_pruned_rules_have_no_unique_matches() {
let engine = InjectionEngine::global();
let relational_count = engine
.rules
.iter()
.filter(|rule| !rule.id.starts_with("graph.") && rule.id != "comment.block_marker")
.filter(|rule| {
matches!(
rule.category,
RuleCategory::Union
| RuleCategory::BooleanBlind
| RuleCategory::TimeBlind
| RuleCategory::DynamicExec
| RuleCategory::FileOps
| RuleCategory::InfoLeak
| RuleCategory::Encoding
| RuleCategory::Stacked
| RuleCategory::Comment
| RuleCategory::Other
)
})
.count();
assert_eq!(
relational_count,
LEGACY_RELATIONAL.len() - 4,
"关系型规则应恰剪除 4 条冗余"
);
assert!(engine_contains("SELECT 1; EXECUTE SP_X"));
assert!(engine_contains("SELECT 1 ORDER BY 1#"));
}
#[test]
fn test_scan_reports_rule_ids_and_categories() {
let engine = InjectionEngine::global();
let findings = engine.scan("SELECT 1; DROP TABLE T", &[RuleCategory::Stacked]);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].id, "stack.drop");
assert_eq!(findings[0].category, RuleCategory::Stacked);
}
#[test]
fn test_ddl_and_graph_pipelines() {
let engine = InjectionEngine::global();
let ddl = engine.scan_ddl(" drop database production ");
assert_eq!(ddl.len(), 1);
assert_eq!(ddl[0].id, "ddl.drop_database");
let graph = engine.scan_graph("MATCH (n) CALL dbms.components() YIELD * RETURN n");
assert!(graph.iter().any(|r| r.id == "graph.call_dbms"));
let comment = engine.scan_graph("MATCH (n) RETURN n /* sneaky */");
assert!(comment.iter().any(|r| r.id == "comment.block_marker"));
}
#[cfg(feature = "sql-parser")]
#[test]
fn test_dynamic_variables_via_engine() {
let engine = InjectionEngine::global();
assert!(engine.has_dynamic_variables("SELECT * FROM t WHERE a = @var"));
assert!(engine.has_dynamic_variables("SELECT ${col} FROM t"));
assert!(!engine.has_dynamic_variables("SELECT * FROM t WHERE a = ?"));
}
}