VNX-NODE-026: Child process spawn with shell:true enables command injection
Reports spawn(), spawnSync(), execFile(), execFileSync() and execa() calls that enable a shell (shell: true or a shell path) while the command or an argument is not a constant, so shell metacharacters in that value run as commands.
| Field | Value |
|---|---|
| Rule ID | VNX-NODE-026 |
| Severity | High |
| SARIF level | error |
| Kind | sast |
| Languages | JavaScript / TypeScript |
| CWE | CWE-78 |
| CAPEC | CAPEC-88 |
| ATT&CK | T1059.004 |
| CWSS vector | CWSS:1.0/TI:H/AP:A/AL:A/IC:N/FC:LT/RP:N/RL:A/AV:I/AS:N/IN:A/SC:A/BI:H/DI:H/EX:H/EC:N/P:H |
| Tags | command-injection spawn shell child-process node |
| Source | internal/sast/rules/vnx-node-026.rego |
Overview
This rule detects calls to child_process.spawn() or child_process.spawnSync() that include the option shell: true. When this option is set, Node.js executes the command via /bin/sh -c on Unix or cmd.exe /d /s /c on Windows, meaning the entire command string is interpreted by a shell before being executed. Shell metacharacters such as ;, &&, |, `, $(), >, and < take on special meaning in this context.
The key difference from execFile() is that spawn() by default accepts a separate argument array, giving it a similar safety profile, each argument is passed as a distinct parameter to the OS execve() call, bypassing the shell entirely. Setting shell: true throws away this separation and reintroduces all the risks of string-based shell execution. Any user-controlled data in the command string or argument array can then inject additional shell commands.
This is a subtle but serious footgun: developers often add shell: true to work around quoting issues or to enable shell features like environment variable expansion, without realising it opens a command injection path.
Why This Matters
Command injection via shell: true has the same impact as any other OS command injection, arbitrary code execution with the privileges of the Node.js process. The risk is compounded because spawn() with shell: true is often used in code that is otherwise well-structured (using argument arrays), giving the false impression that it is safe.
In practice, even “safe-looking” argument arrays are not safe when shell: true is set. The shell receives the entire command line as a concatenated string and re-parses it, so a value like ; rm -rf / in the argument array becomes a second shell command. Developers who rely on argument separation for safety are caught off guard when shell: true negates that protection.
CI/CD pipelines, developer tooling, and build systems are the highest-risk areas. These applications frequently invoke external commands and sometimes accept repository names, branch names, or user-provided parameters that are passed to those commands.
What Gets Flagged
const { spawn } = require('child_process');
// FLAGGED: a request value reaches a shell
const file = req.body.file;
spawn('convert', [file, 'out.png'], { shell: true });
// FLAGGED: options across lines, command from a parameter
cp.spawn(command, {
cwd,
shell: true,
});
// FLAGGED: a shell path counts as a shell; template interpolation is not constant
childProcess.execFileSync('grep', ['-r', pattern, dir], { shell: '/bin/bash' });
await execa(`git checkout ${branch}`, { shell: true });
Not flagged: shell: false or no shell option, shell: process.platform === 'win32', and a call whose command and arguments are all literals or constants (spawnSync('npm run build', { shell: true })). exec()/execSync() always use a shell and are covered by VNX-NODE-003.
Remediation
-
Remove
shell: trueand rely on the defaultshell: falsebehaviour, passing arguments as a separate array. -
Validate all user-supplied values against a strict allowlist of permitted values before using them in any command.
-
Use
execFile()instead ofexec()for commands that do not need shell features,execFile()never uses a shell by default. -
If shell features are genuinely needed (glob expansion, piping), perform those operations in Node.js code rather than delegating to the shell.
// SAFE: shell:false (default), arguments passed directly to the OS, no shell involved
const { spawn } = require('child_process');
app.post('/clone', (req, res) => {
const repoName = req.body.repoName;
// Validate against allowlist
if (!/^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_.-]+$/.test(repoName)) {
return res.status(400).json({ error: 'Invalid repo name' });
}
const proc = spawn('git', ['clone', '--', `https://github.com/${repoName}.git`], {
shell: false, // explicit and safe, this is also the default
stdio: 'pipe',
});
proc.on('close', (code) => {
res.json({ exitCode: code });
});
});
References
- CWE-78: Improper Neutralization of Special Elements used in an OS Command
- CAPEC-88: OS Command Injection
- OWASP Command Injection Defense Cheat Sheet
- OWASP Node.js Security Cheat Sheet
- Node.js child_process.spawn() documentation
- Node.js Security Best Practices – child processes
Suppressing this rule
Put a nosec comment on the line, or on line 1 to cover the whole file. Use the comment marker of the file’s language (#, //, -- or ;):
# nosec vnx-node-026
// nosec vnx-node-026
Or record an ignore rule: vulnetix ignore add --rule vnx-node-026 --reason "...". See Suppressing findings.