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

high

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

A high-severity command injection vulnerability was discovered in `server.js` where user-controlled file paths were passed directly to shell commands via `exec()`. By migrating from `exec()` to `execFile()` and using argument arrays instead of string concatenation, the fix eliminates the attack surface while preserving the intended trash/delete functionality across macOS, Windows, and Linux.

high

How Command Injection happens in Node.js and how to fix it

A semgrep scan flagged `scripts/postinstall.js` for calling `child_process.execSync` in a way that could become a command injection primitive if the script's execution context ever changed. The fix hardens the script by guarding its side effects behind a `require.main === module` check, introducing the safer `execFileSync` API, and adding automated tests to lock in the safe behavior.

high

How command injection happens in Node.js child_process and how to fix it

A critical command injection vulnerability in `scripts/check-links.js` was fixed by replacing `execSync()` with `execFileSync()`, eliminating shell interpretation of user-controlled repository names. This proactive hardening prevents potential remote code execution in the GitHub CLI integration workflow.

critical

How Command Injection happens in Node.js and how to fix it

A critical command injection vulnerability in `scripts/sync-skill.mjs` allowed attackers to execute arbitrary commands through malicious command-line arguments. The fix implements strict whitelist validation on `process.argv` inputs, ensuring only the `--check` flag is accepted before any shell interaction occurs.

high

How Shell Injection Happens in GitHub Actions and How to Fix It

A high-severity shell injection vulnerability was discovered in `action.yml` where direct variable interpolation with GitHub context data in `run:` steps could allow attackers to inject arbitrary code into the runner. The fix uses environment variables with proper quoting to safely separate untrusted input from shell execution, eliminating the exploit primitive while preserving legitimate functionality.

high

How command injection happens in JavaScript child_process and how to fix it

A high-severity command injection vulnerability in Claude Code's `prepare-native.js` could have allowed attackers to execute arbitrary shell commands through malicious npm package tarball URLs. The fix adds strict URL scheme validation and proper curl argument termination to neutralize injection vectors.