use crate::detectors::base::{Detector, DetectorConfig};
use crate::graph::GraphStore;
use crate::models::{Finding, Severity};
use anyhow::Result;
use regex::Regex;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use uuid::Uuid;
static SHELL_EXEC: OnceLock<Regex> = OnceLock::new();
static GO_EXEC: OnceLock<Regex> = OnceLock::new();
static JS_EXEC_DIRECT: OnceLock<Regex> = OnceLock::new();
fn shell_exec() -> &'static Regex {
SHELL_EXEC.get_or_init(|| Regex::new(r#"(?i)(os\.system|os\.popen|subprocess\.(call|run|Popen)|child_process\.(exec|spawn|fork)|execSync|execAsync|spawnSync|require\(['"]child_process['"]\)|shell_exec|proc_open)"#).unwrap())
}
fn go_exec() -> &'static Regex {
GO_EXEC.get_or_init(|| Regex::new(r#"exec\.(Command|CommandContext)\s*\("#).unwrap())
}
fn js_exec_direct() -> &'static Regex {
JS_EXEC_DIRECT.get_or_init(|| Regex::new(r#"(?:^|[^.\w])(exec|execSync|execAsync)\s*\("#).unwrap())
}
pub struct CommandInjectionDetector {
repository_path: PathBuf,
max_findings: usize,
}
impl CommandInjectionDetector {
pub fn new(repository_path: impl Into<PathBuf>) -> Self {
Self { repository_path: repository_path.into(), max_findings: 50 }
}
fn relative_path(&self, path: &Path) -> PathBuf {
path.strip_prefix(&self.repository_path)
.unwrap_or(path)
.to_path_buf()
}
}
impl Detector for CommandInjectionDetector {
fn name(&self) -> &'static str { "command-injection" }
fn description(&self) -> &'static str { "Detects command injection vulnerabilities" }
fn detect(&self, _graph: &GraphStore) -> Result<Vec<Finding>> {
let mut findings = vec![];
let walker = ignore::WalkBuilder::new(&self.repository_path).hidden(false).git_ignore(true).build();
for entry in walker.filter_map(|e| e.ok()) {
if findings.len() >= self.max_findings { break; }
let path = entry.path();
if !path.is_file() { continue; }
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if !matches!(ext, "py"|"js"|"ts"|"rb"|"php"|"java"|"go"|"sh") { continue; }
if let Some(content) = crate::cache::global_cache().get_content(path) {
let lines: Vec<&str> = content.lines().collect();
let mut dangerous_vars: Vec<String> = vec![];
for line in &lines {
if (line.contains("const ") || line.contains("let ") || line.contains("var "))
&& line.contains("`") && line.contains("${") {
let lower = line.to_lowercase();
let has_risky_interpolation =
lower.contains("${") && (
lower.contains("id}") || lower.contains("id,") ||
lower.contains("param") || lower.contains("input") ||
lower.contains("user") || lower.contains("name}") ||
lower.contains("args") || lower.contains("arg}") ||
lower.contains("req.") || lower.contains("body") ||
lower.contains("query")
);
if has_risky_interpolation {
if let Some(eq_pos) = line.find('=') {
let before_eq = &line[..eq_pos];
let var_name = before_eq.split_whitespace().last().unwrap_or("");
if !var_name.is_empty() {
dangerous_vars.push(var_name.to_string());
}
}
}
}
}
for (i, line) in lines.iter().enumerate() {
if shell_exec().is_match(line) {
let has_user_input = line.contains("req.") || line.contains("request.") ||
line.contains("params.") || line.contains("params[") ||
line.contains("query.") || line.contains("body.") ||
line.contains("input") || line.contains("argv") || line.contains("args");
let has_interpolation = line.contains("f\"") || line.contains("${") ||
line.contains("+ ") || line.contains(".format(");
let has_template_interpolation = line.contains("`") && line.contains("${");
let uses_dangerous_var = dangerous_vars.iter().any(|v| line.contains(v));
let has_shell_true = line.contains("shell=True") || line.contains("shell: true");
let is_risky = has_shell_true
|| (has_user_input && has_interpolation)
|| has_template_interpolation
|| uses_dangerous_var;
if is_risky {
let desc = if has_template_interpolation {
"Template literal with interpolation passed directly to shell execution. Variables are inserted unsanitized."
} else if uses_dangerous_var {
"Shell execution using a command string built from template literal. User input may flow into the command."
} else if has_shell_true {
"subprocess with shell=True allows shell injection through any unsanitized input."
} else {
"Shell command execution with potential user input."
};
findings.push(Finding {
id: Uuid::new_v4().to_string(),
detector: "CommandInjectionDetector".to_string(),
severity: Severity::Critical,
title: "Potential command injection".to_string(),
description: desc.to_string(),
affected_files: vec![self.relative_path(path)],
line_start: Some((i + 1) as u32),
line_end: Some((i + 1) as u32),
suggested_fix: Some("Use subprocess/spawn with array arguments instead of shell string. Never interpolate user input into commands.".to_string()),
estimated_effort: Some("45 minutes".to_string()),
category: Some("security".to_string()),
cwe_id: Some("CWE-78".to_string()),
why_it_matters: Some("Attackers could execute arbitrary system commands by injecting shell metacharacters.".to_string()),
});
}
}
else if line.contains("exec(") || line.contains("execSync(") || line.contains("execAsync(") {
if line.contains("`") && line.contains("${") {
findings.push(Finding {
id: Uuid::new_v4().to_string(),
detector: "CommandInjectionDetector".to_string(),
severity: Severity::Critical,
title: "Command injection via template literal".to_string(),
description: "Template literal with variable interpolation passed to exec(). This is a classic command injection pattern.".to_string(),
affected_files: vec![self.relative_path(path)],
line_start: Some((i + 1) as u32),
line_end: Some((i + 1) as u32),
suggested_fix: Some("Use spawn() with array arguments: spawn('cmd', [arg1, arg2]) instead of exec(`cmd ${arg}`)".to_string()),
estimated_effort: Some("30 minutes".to_string()),
category: Some("security".to_string()),
cwe_id: Some("CWE-78".to_string()),
why_it_matters: Some("An attacker can inject shell commands by providing input like '; rm -rf /' or '$(malicious_command)'".to_string()),
});
}
else if dangerous_vars.iter().any(|v| line.contains(&format!("({})", v)) || line.contains(&format!("({},", v))) {
findings.push(Finding {
id: Uuid::new_v4().to_string(),
detector: "CommandInjectionDetector".to_string(),
severity: Severity::Critical,
title: "Command injection via interpolated variable".to_string(),
description: "Shell execution using a command string that was built with template literal interpolation. User input may flow into the shell command.".to_string(),
affected_files: vec![self.relative_path(path)],
line_start: Some((i + 1) as u32),
line_end: Some((i + 1) as u32),
suggested_fix: Some("Use spawn() with array arguments instead of building command strings. Never interpolate user input.".to_string()),
estimated_effort: Some("45 minutes".to_string()),
category: Some("security".to_string()),
cwe_id: Some("CWE-78".to_string()),
why_it_matters: Some("The command variable was built using ${} interpolation, allowing shell injection.".to_string()),
});
}
}
if js_exec_direct().is_match(line) {
let has_direct_user_input = line.contains("req.body") || line.contains("req.query") ||
line.contains("req.params") || line.contains("request.body") ||
line.contains("request.query") || line.contains("request.params");
if has_direct_user_input {
findings.push(Finding {
id: Uuid::new_v4().to_string(),
detector: "CommandInjectionDetector".to_string(),
severity: Severity::Critical,
title: "Command injection via direct user input".to_string(),
description: "User-controlled input (req.body/query/params) passed directly to shell execution function. This allows arbitrary command execution.".to_string(),
affected_files: vec![self.relative_path(path)],
line_start: Some((i + 1) as u32),
line_end: Some((i + 1) as u32),
suggested_fix: Some("Never pass user input directly to exec(). Use a whitelist of allowed commands, or use spawn() with a fixed command and user input only as arguments.".to_string()),
estimated_effort: Some("1 hour".to_string()),
category: Some("security".to_string()),
cwe_id: Some("CWE-78".to_string()),
why_it_matters: Some("An attacker can execute ANY system command by sending malicious input like 'rm -rf /' or 'cat /etc/passwd'.".to_string()),
});
}
}
if go_exec().is_match(line) {
let has_user_input = line.contains("r.") || line.contains("req.") ||
line.contains("request.") || line.contains("c.") ||
line.contains("ctx.") || line.contains("Param") ||
line.contains("Query") || line.contains("FormValue") ||
line.contains("PostForm") || line.contains("userInput") ||
line.contains("input") || line.contains("cmd") ||
line.contains("command");
let has_risky_var = line.to_lowercase().contains("userinput") ||
line.to_lowercase().contains("user_input") ||
line.to_lowercase().contains("usercmd") ||
line.to_lowercase().contains("user_cmd");
if has_user_input || has_risky_var {
findings.push(Finding {
id: Uuid::new_v4().to_string(),
detector: "CommandInjectionDetector".to_string(),
severity: Severity::Critical,
title: "Potential command injection in Go exec.Command".to_string(),
description: "exec.Command called with potentially user-controlled input. If the command or arguments come from user input, this allows arbitrary command execution.".to_string(),
affected_files: vec![self.relative_path(path)],
line_start: Some((i + 1) as u32),
line_end: Some((i + 1) as u32),
suggested_fix: Some("Validate user input against a whitelist of allowed commands. Never pass raw user input to exec.Command. Use filepath.Clean for paths.".to_string()),
estimated_effort: Some("1 hour".to_string()),
category: Some("security".to_string()),
cwe_id: Some("CWE-78".to_string()),
why_it_matters: Some("Go's exec.Command runs system commands. If user input controls the command or arguments, attackers can execute arbitrary commands.".to_string()),
});
}
}
}
}
}
Ok(findings)
}
}