Skip to main content
interlace
Plugin: node-securityRules

no-dynamic-command-string

Detects dynamically assembled command strings handed to a shell flag (bash -c) or to a command-runner that does not escape (execaCommand, $.raw)

Keywords: command injection, CWE-77, argument injection, shell flag, bash -c, spawn, execFile, execa, execaCommand, zx, $.raw, ESLint rule, LLM-optimized

CWE: CWE-77 (Command Injection) OWASP: A03:2021 – Injection

Quick Summary

AspectDetails
CWE ReferenceCWE-77
SeverityCritical
Auto-Fix❌ None — the safe form is a different call shape
Detectionstructural-api
Recommendederror

Rule Details

"We use spawn with an argument array, so we're safe from command injection" is true — right up to the two shapes below, both of which pass that review.

1. The shell-flag escape hatch

spawn('bash', ['-c', `kill -9 ${pid}`]);

The array is real, but its second element is handed to bash, which parses it as a command line all over again. ;, &&, backticks and $() in pid all execute. The parameterization is decorative.

The command flags are tracked per interpreter, because they do not mean the same thing everywhere:

InterpreterFlags that make the next argv element a command string
sh, bash, zsh, dash, ksh, busybox-c
cmd, cmd.exe/c, /C, /k, /K
powershell, pwsh-Command, -c, -EncodedCommand, -e, -ec

spawn('bash', ['-e', script]) is therefore not a finding: to a POSIX shell -e is set -e, and the next element is a script path, not a command string.

2. Command-runner libraries that take a whole command line

await execaCommand(`git clone ${url}`);
await $.raw`git clone ${url}`;

execa's and zx's tagged-template forms escape interpolated values — that is their headline feature. execaCommand, execaCommandSync and $.raw are the documented escape hatches that do not.

How this differs from the neighbouring rules

RuleCWEShape
no-shell-injectionCWE-78exec() / execSync() with a concatenated command string
detect-child-processCWE-78broad, medium-confidence net over all child_process usage
no-dynamic-command-stringCWE-77the command string is re-parsed by a shell the argument array was supposed to avoid

Examples

❌ Incorrect

// Interpolated command line behind an argument array
spawn('bash', ['-c', `kill -9 ${pid}`]);
execFile('/bin/sh', ['-c', 'rm -rf ' + target]);
spawnSync('zsh', ['-c', userCommand]);

// Windows interpreters
spawn('cmd.exe', ['/c', `del ${file}`]);
spawn('powershell', ['-Command', `Remove-Item ${file}`]);

// The flag does not have to be first
spawn('bash', ['--login', '-c', `deploy ${env}`]);

// Command-runners that do not escape
await execaCommand(`git clone ${url}`);
execaCommandSync('git clone ' + url);
await $.raw`git clone ${url}`;

✅ Correct

// Invoke the program directly with its own argument array
spawn('kill', ['-9', String(pid)]);
execFile('git', ['clone', repoUrl]);

// A shell with a fully static command line is not a finding
spawn('bash', ['-c', 'ls -la']);

// -e is errexit to a POSIX shell, not a command flag
spawn('bash', ['-e', deployScript]);

// The escaping forms of the same libraries
await $`git clone ${url}`;
await execa`git clone ${url}`;
execa('git', ['clone', url]);

Options

OptionTypeDefaultDescription
extraCommandRunnersstring[][]Extra functions that accept a full command line without escaping
{
  "rules": {
    "node-security/no-dynamic-command-string": [
      "error",
      { "extraCommandRunners": ["runShell"] }
    ]
  }
}

When Not To Use It

Build scripts and local developer tooling that only ever run on a trusted machine with trusted input have no attacker in the loop. Prefer scoping the rule off for those directories (an ESLint config override) over disabling it repo-wide — scripts/ grows into CI, and CI input is not always trusted.

Known False Negatives

The following patterns are not detected due to static analysis limitations:

Command String Built Earlier

Why: The rule checks the shape of the argument in the call. A fully assembled literal-looking variable is caught (an identifier counts as assembled), but a shell binary held in a variable is not.

// ❌ NOT DETECTED — the interpreter is not a literal
const shell = '/bin/bash';
spawn(shell, ['-c', command]);

shell: true On A Safe-Looking Call

Why: The { shell: true } option is covered by detect-child-process, not repeated here.

// ❌ NOT DETECTED BY THIS RULE — enable detect-child-process
spawn('git', ['clone', url], { shell: true });

Runners Outside The Known Set

Why: The command-runner is matched by name.

// ❌ NOT DETECTED — until added to extraCommandRunners
shellRunner.run(`git clone ${url}`);

Further Reading

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.