Back to Blog
high SEVERITY8 min read

How Command Injection Vulnerabilities Happen in Node.js Child Process Calls and How to Fix Them

A critical command injection vulnerability in `scripts/setup-harness.js` used a generic `run()` function that accepted user-controlled command strings passed directly to `spawnSync()`, creating an exploit primitive. The fix refactors this into hardened, command-specific functions (`runGit()` and `runNode()`) that eliminate the attack surface by pre-selecting the executable and disabling shell interpretation.

O
By Orbis AppSec
Published August 31, 2026Reviewed August 31, 2026

Answer Summary

Command injection in Node.js occurs when user-controlled input is passed as the command argument to `child_process.spawnSync()` without validation, allowing attackers to inject malicious shell commands. The vulnerability in `setup-harness.js` line 18-26 used a generic `run(command, args, cwd)` function that accepted any command string, creating a code pattern exploitable via automated attack tools. The fix replaces this with dedicated `runGit()` and `runNode()` functions that hardcode the executable path (using `process.execPath` for Node), disable shell interpretation with `shell: false`, and pass command arguments through the safe array parameter, eliminating the injection vector entirely.

Vulnerability at a Glance

cweCWE-78 (Improper Neutralization of Special Elements used in an OS Command)
fixRefactor into hardened command-specific functions (runGit, runNode) that hardcode executables, disable shell, and sanitize arguments
riskExecution of arbitrary commands with application privileges; supply-chain attack primitive
languageJavaScript (Node.js)
root causeGeneric run() function accepted untrusted command strings passed directly to spawnSync() without validation or sandboxing
vulnerabilityCommand Injection via Unsafe Child Process Execution

How Command Injection Vulnerabilities Happen in Node.js Child Process Calls and How to Fix Them

Introduction

In the private Node.js application repository, a critical command injection vulnerability was discovered in scripts/setup-harness.js — the setup script responsible for cloning vendor dependencies and building the test harness. The vulnerability was flagged by Semgrep on line 18, within a generic run() function that accepted user-controlled command strings as function arguments and passed them directly to Node.js's child_process.spawnSync().

The problematic code was deceptively simple:

function run(command, args, cwd) {
  console.log(`> ${command} ${args.join(' ')}`);
  const result = spawnSync(command, args, {
    cwd,
    stdio: 'inherit',
    env: harnessEnv(),
  });
  if (result.status !== 0) {
    process.exit(result.status || 1);
  }
}

At first glance, this looks benign — it's just a wrapper around spawnSync(). But here's the critical flaw: the command parameter is user-controlled. If any caller passed an untrusted value as command, an attacker could inject arbitrary shell commands. While this application is private and not directly exposed to internet-facing input, the vulnerability represents an "exploit primitive" — a code pattern that automated attack tools chain together to bypass security defenses. This was the exact pattern that led to the trivy-action and kics-github-action supply-chain compromises in GitHub Actions.

The Vulnerability Explained

What makes setup-harness.js vulnerable?

The vulnerability stems from three critical misconfigurations in the original run() function:

  1. Untrusted command parameter: The function accepts command as a variable argument passed directly to spawnSync(). If any code path supplies this argument from untrusted input, command injection becomes possible.

  2. Shell interpretation enabled implicitly: The original code does not explicitly set shell: false. While spawnSync() without shell: true is safer than execSync(), the lack of explicit shell: false creates ambiguity and leaves the door open for misconfigurations in future refactors.

  3. Generic function design: By creating a one-size-fits-all run() function, the code encourages casual usage patterns. Callers might eventually use this function with dynamic input without thinking through security implications.

The attack scenario:

Imagine a hypothetical scenario where an environment variable (e.g., REPO_CLONE_COMMAND) or a configuration file parameter could influence the command argument. An attacker could inject:

command = "git; rm -rf /"

The function would then execute:

spawnSync("git; rm -rf /", ["clone", "--depth", "1", "--branch", pin.ref, pin.repo, vendor], ...)

While spawnSync() without shell: true treats the entire string as a single command name (not a shell pipeline), this is fragile and creates a dangerous exploit primitive that automated tooling can chain into larger attack chains.

Why does this matter?

According to the PR description, this pattern was specifically used in the recent compromises of trivy-action and kics-github-action — both GitHub Actions that were supply-chain compromised by attackers who detected these exploit primitives and weaponized them. Removing the primitive proactively hardens the codebase against:

  • Automated exploit development: Tools like Semgrep's SAST engine or GPT-based code analysis can now longer generate exploits based on this pattern.
  • Future refactors: If this code is refactored in six months without security review, the new implementer won't be tempted to pass dynamic input to a generic run() function.
  • Dependency confusion attacks: If a third-party module ever needs to call this setup script, it can't inject malicious commands.

The Fix

The PR replaces the generic, unsafe run() function with two hardened, command-specific functions: runGit() and runNode().

Before (vulnerable):

function run(command, args, cwd) {
  console.log(`> ${command} ${args.join(' ')}`);
  const result = spawnSync(command, args, {
    cwd,
    stdio: 'inherit',
    env: harnessEnv(),
  });
  if (result.status !== 0) {
    process.exit(result.status || 1);
  }
}

// Calls to run():
run('git', ['clone', '--depth', '1', '--branch', pin.ref, pin.repo, vendor], root);
run(process.execPath, [pnpm, 'install', '--frozen-lockfile'], vendor);
run(process.execPath, [pnpm, 'run', 'build'], vendor);

After (hardened):

function runGit(args, cwd) {
  console.log(`> git ${args.join(' ')}`);
  const result = spawnSync('git', args, {
    cwd,
    stdio: 'inherit',
    env: harnessEnv(),
    shell: false,
  });
  if (result.status !== 0) {
    process.exit(result.status || 1);
  }
}

function runNode(args, cwd) {
  console.log(`> node ${args.join(' ')}`);
  const result = spawnSync(process.execPath, args, {
    cwd,
    stdio: 'inherit',
    env: harnessEnv(),
    shell: false,
  });
  if (result.status !== 0) {
    process.exit(result.status || 1);
  }
}

// Calls now use specific functions:
runGit(['clone', '--depth', '1', '--branch', pin.ref, pin.repo, vendor], root);
runNode([pnpm, 'install', '--frozen-lockfile'], vendor);
runNode([pnpm, 'run', 'build'], vendor);

Key security improvements:

Aspect Before After
Executable selection User-controlled command string Hardcoded to 'git' or process.execPath
Shell interpretation Implicit (undefined) Explicit shell: false
Function design Generic accept-all Purpose-specific functions
Attack surface Large — any caller could pass malicious command Eliminated — executables are fixed at code-write time

The fix achieves defense in depth by combining three hardening techniques:

  1. Hardcoding the executable: The command parameter is eliminated entirely. runGit() always uses 'git', and runNode() always uses process.execPath. This removes the injection point.

  2. Explicit shell: false: This ensures that spawnSync() treats the executable name as a literal program name, not a shell command string. Even if an attacker somehow manipulates arguments, no shell metacharacters are interpreted.

  3. Command-specific functions: By creating separate runGit() and runNode() functions, the code communicates intent clearly and prevents accidental misuse. Future developers can't "just call run() with a dynamic command" because run() no longer exists.

Prevention & Best Practices

To avoid command injection vulnerabilities in Node.js applications:

  1. Never pass user input as the command argument to child_process functions:
    ```javascript
    // UNSAFE
    spawnSync(userInput, args); // command injection!

// SAFE
spawnSync('/usr/bin/git', args); // hardcoded executable
```

  1. Always set shell: false explicitly:
    javascript spawnSync('git', ['clone', repo], { shell: false }); // Recommended

  2. Use the array form of arguments, not shell strings:
    ``javascript // UNSAFE spawnSync('sh', ['-c',git clone ${repo}`]); // vulnerable to injection

// SAFE
spawnSync('git', ['clone', repo]); // arguments as array
```

  1. Use allowlists for any dynamic command selection:
    javascript const commands = { git: '/usr/bin/git', npm: '/usr/bin/npm' }; const cmd = commands[userChoice]; // Validate against allowlist if (!cmd) throw new Error('Invalid command'); spawnSync(cmd, args);

  2. Apply the principle of least privilege:
    - Run child processes with minimal required permissions.
    - Use uid and gid options in spawnSync() to drop privileges if possible.

  3. Use static analysis tools to catch these patterns:
    - Semgrep: Rule javascript.lang.security.detect-child-process detects child_process calls with untrusted input.
    - CodeQL: Query js/command-injection identifies potential command injection sinks.
    - ESLint: Plugin eslint-plugin-security flags suspicious child_process patterns.

CWE Reference:
- CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
- CWE-94: Improper Control of Generation of Code ('Code Injection')

Key Takeaways

  • Never accept command names as function parameters. The run(command, args) pattern in setup-harness.js was fundamentally unsafe because attackers could inject arbitrary executables. Hardcoding the executable eliminates this risk entirely.

  • Explicit shell: false is not optional. While spawnSync() defaults to safe behavior, explicitly setting shell: false prevents future refactors from accidentally enabling shell interpretation, and it documents security intent for code reviewers.

  • Command-specific wrapper functions prevent misuse. By replacing the generic run() function with runGit() and runNode(), the codebase makes it impossible to accidentally pass untrusted input to child_process, because the executable is selected by the function name, not the caller.

  • Exploit primitives matter in supply-chain security. The PR description specifically notes that this code pattern was weaponized in the trivy-action and kics-github-action compromises. Removing exploit primitives proactively hardens defenses against automated attack tooling.

  • Static analysis catches these patterns reliably. Semgrep's detect-child-process rule flagged this vulnerability automatically, demonstrating that SAST tools should be integrated into CI/CD pipelines to detect similar issues before they reach production.

How Orbis AppSec Detected This

  • Source: The command parameter passed to the run() function (line 18 in the original code), which could accept untrusted values from callers unaware of security implications.

  • Sink: The spawnSync(command, args, ...) call on line 20, where the untrusted command string is passed directly as the executable argument.

  • Missing control: No validation of the command parameter, no explicit shell: false configuration, and no architectural barrier preventing untrusted input from reaching spawnSync().

  • CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command).

  • Fix: Replaced the generic run() function with hardened, command-specific functions (runGit() and runNode()) that hardcode the executable path, explicitly set shell: false, and accept only arguments as an array, eliminating the attack surface entirely.

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.

Conclusion

The command injection vulnerability in setup-harness.js demonstrates a critical security principle: generic wrapper functions around dangerous APIs create exploit primitives. By refactoring the unsafe run() function into hardened, purpose-specific functions (runGit() and runNode()), the fix eliminates the injection vector while maintaining code readability and functionality.

This is not a theoretical risk. The trivy-action and kics-github-action compromises were built on exactly this pattern. Proactive removal of exploit primitives — even in private codebases — hardens defenses against increasingly sophisticated automated attack tools that can chain multiple vulnerabilities into complete exploits.

As developers, adopt this mindset: avoid generic "run anything" functions, hardcode executables at code-write time, and use static analysis tools to catch these patterns before they reach production.


References

Frequently Asked Questions

What is command injection in Node.js?

Command injection occurs when untrusted input is concatenated into shell commands or passed as the command argument to child_process functions, allowing attackers to inject arbitrary commands that execute with the application's privileges.

How do you prevent command injection in Node.js?

Never pass user input as the command argument to child_process functions; instead, hardcode the executable path, use the array form of arguments (not shell strings), disable shell interpretation with `shell: false`, and validate/sanitize any dynamic arguments before passing them.

What CWE is this vulnerability?

CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection'). This is one of the most critical injection vulnerabilities affecting production systems.

Is input validation alone enough to prevent command injection?

No. While input validation helps, the most effective defense is architectural: hardcode the executable, disable the shell, and use the array argument form. Validation is an additional layer, not the primary defense.

Can static analysis detect command injection in setup-harness.js?

Yes. Semgrep, CodeQL, and similar SAST tools detect calls to child_process functions that accept untrusted input. This vulnerability was flagged by Semgrep rule `javascript.lang.security.detect-child-process.detect-child-process`.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #22

Related Articles

critical

How Arbitrary Code Execution Via Command Injection happens in Node.js and how to fix it

A critical arbitrary code execution flaw in the `shell-quote` npm package (CVE-2026-9277) allowed attackers to break out of shell quoting using unescaped Unicode line terminator characters, turning ordinary command-line arguments into injected shell commands. The fix locks `shell-quote` to the patched `1.8.4` release via a `resolutions` override in `package.json`/`yarn.lock`, closing off a transitive dependency path that could otherwise pull in a vulnerable version.

critical

How command injection happens in Kotlin/Android and how to fix it

V2rayNG's RootShell.kt built root shell commands by concatenating an unescaped file path directly into a string passed to `su -c`, creating a critical command injection risk (CWE-78). The fix restricts the `exec()` API to internal use only and single-quote-escapes the file path before it ever reaches the root shell.

high

How shell command injection happens in Ruby and how to fix it

A critical command injection vulnerability was discovered in Fastlane's deliver module where `system("open '#{html_path}'")` allowed shell metacharacters in file paths to execute arbitrary commands. The fix replaces vulnerable string interpolation with array-based argument passing, eliminating the shell entirely.

critical

How Command Injection Happens in Node.js Dependencies and How to Fix It

CVE-2026-9277 is a critical command injection vulnerability in shell-quote versions prior to 1.8.4 that allows attackers to execute arbitrary code by injecting unescaped line terminators into shell commands. This vulnerability affects any Node.js application that uses the vulnerable shell-quote package to construct shell commands from untrusted input. The fix upgrades shell-quote to version 1.8.4, which properly escapes line terminators and neutralizes the injection vector.

high

How command injection happens in Ruby and how to fix it

A Fastlane helper used a Ruby backtick subshell to clone a plugin's git repository, interpolating `self.homepage` directly into a shell command string. Even with `shellescape` applied, the pattern was flagged as a dangerous subshell that could be chained into a command injection primitive; the fix replaces it with `system()` using an argument array, eliminating shell interpretation entirely.

critical

How command injection happens in JavaScript dependency trees and how to fix it

A critical command injection vulnerability in websocket-driver 0.7.4 allowed attackers to execute arbitrary shell commands through unescaped line terminators in WebSocket protocol handling. The automated fix upgrades to version 0.7.5 and adds an explicit override in package.json to prevent dependency resolution from reverting to the vulnerable version.