Skip to content

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.

Updated 02 Nov 2026Checked against cli@de9b52a
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

  1. Remove shell: true and rely on the default shell: false behaviour, passing arguments as a separate array.

  2. Validate all user-supplied values against a strict allowlist of permitted values before using them in any command.

  3. Use execFile() instead of exec() for commands that do not need shell features, execFile() never uses a shell by default.

  4. 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

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.

Edit this pageSources: cli/internal/sast/rules/vnx-node-026.rego, cli/internal/sast/docs/vnx-node-026.md
esc
↑ ↓ move↵ openSearches in your browser. A key like autonomy.max or a code like threshold_required jumps straight to it.