Skip to main content
interlace
Plugin: node-securityRules

no-shell-injection

Disallow string concatenation or template expressions in shell command arguments (CWE-78)

CWE: CWE-78 OWASP: A03:2021 — Injection

exec and execSync hand their argument to /bin/sh. The shell then interprets ;, &&, |, `, $() and newline as syntax. Any value spliced into that string stops being an argument and becomes code — which is why command injection remains one of the highest-severity defects a Node service can carry.

Rule details

Reports a shell command assembled by concatenation or template interpolation.

Examples of incorrect code:

const { exec } = require('child_process');

// `; rm -rf /` in `repoUrl` is a second command, not a URL.
exec('git clone ' + repoUrl);
const { execSync } = require('child_process');
execSync(`npm install ${packageName}`);
// A shell is a shell even when you asked for spawn.
spawn(`tar -xzf ${archivePath}`, { shell: true });

Examples of correct code:

const { execFile } = require('child_process');

// The binary is named, the arguments are a vector. No shell, no metacharacters.
execFile('git', ['clone', repoUrl], (err, stdout) => { /* … */ });
const { spawn } = require('child_process');
spawn('npm', ['install', packageName], { shell: false });
const { execFile } = require('child_process');
// `--` ends option parsing, so a filename beginning with `-` cannot become a flag.
execFile('grep', ['--', pattern, file]);

The fix is always the same shape

Replace one string with a binary plus an argument array:

// instead of                              use
exec('cmd ' + arg)                      -> execFile('cmd', [arg])
execSync(`cmd ${arg}`)                  -> execFileSync('cmd', [arg])
spawn(`cmd ${arg}`, { shell: true })    -> spawn('cmd', [arg], { shell: false })

The argument vector is passed to execve directly. There is no shell to parse metacharacters, so quoting and escaping stop being your responsibility.

Argument injection still applies

Removing the shell closes command injection, not argument injection. A tool that accepts --upload-file or -o can still be steered by a value that starts with a dash. Where the binary is fixed and the argument is attacker-influenced, pass -- before the user value.

Why there is no autofix

Splitting a command string into a binary and an argument vector requires shell-accurate tokenisation of quoting, escapes and expansions. An autofix that got it wrong would change what the program executes — the exact failure this rule exists to prevent.

When not to use it

Disable it in a build script whose command is assembled entirely from literals and __dirname. Prefer a scoped eslint-disable-next-line over switching the rule off for the package.

⚙️ Options

OptionTypeDefaultDescription
requireModuleEvidencebooleantrueOnly report when the callee resolves to child_process. Turning this off restores the pre-2026-08 behaviour, where any callee named exec/execSync/spawn was treated as a shell sink — which reported better-sqlite3 db.exec(sql) as CWE-78 at CVSS 9.8.

Did this rule catch something? Star the repo to get new CWE coverage as we ship it — or follow the AI-code-security benchmarks behind these rules.