Skip to content

fix(proc): pin shell:false after opts spread in run() and dockerExec() - #2

Open
anupamme wants to merge 1 commit into
rahmanef63:mainfrom
anupamme:hardening/pin-shell-false-proc
Open

fix(proc): pin shell:false after opts spread in run() and dockerExec()#2
anupamme wants to merge 1 commit into
rahmanef63:mainfrom
anupamme:hardening/pin-shell-false-proc

Conversation

@anupamme

@anupamme anupamme commented Aug 9, 2026

Copy link
Copy Markdown

What and why

Two places in lib/proc.js built the options object for child_process calls with safety-critical flags before the caller-supplied opts spread:

// run() — before
execFileSync(file, args, { encoding: 'utf8', ...opts });

// tryRun() inside dockerExec() — before
spawnSync(file, args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, ...opts });

This meant a caller could pass { shell: true } (or override encoding/maxBuffer) and silently defeat the no-shell contract of both helpers. No current caller does this, but the invariant was unenforced at the call site.

Moving the pinned flags after the spread closes the gap:

// run() — after
execFileSync(file, args, { ...opts, encoding: 'utf8', shell: false });

// tryRun() inside dockerExec() — after
spawnSync(file, args, { ...opts, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, shell: false });

Legitimate caller opts (cwd, env, stdio, etc.) still pass through; only the safety-critical keys are pinned. (shell: false is inert on execFileSync, which ignores it, but makes the intent explicit and consistent with spawnSync.)

What this is not

This is not a known-exploitable injection vulnerability. PR #1 was a false positive — Semgrep flagged ...opts reaching exec, but no untrusted data flows through opts in any current caller, and all call sites already use argv-array form with no shell: true. This follow-up addresses the defensive-programming gap you identified: that the shell override avenue existed at all.

Testing

Added test/proc.test.js — Node built-in node:test + node:assert/strict, no new dependencies. Uses the same require.cache pre-seeding stub pattern already in test/lib.test.js.

The suite covers:

  • Regression tests for the hardening: shell: false is the effective value even when a caller passes { shell: true } (both run() and dockerExec()); same for encoding and maxBuffer
  • Behavior paths: plain docker, SC_DOCKER_SUDO=1 forced sudo, SC_DOCKER_SUDO=0 no-fallback, auto-fallback on permission denied and cannot connect to the docker daemon
  • Error paths: containerName validation, res.error rethrow, non-zero exit throw

All 134 tests pass (node --test "test/**/*.test.js").

Closes the hardening gap identified in #1. Thanks again for the clear review.

Move safety-critical options (encoding, shell:false, maxBuffer) after the
caller-supplied ...opts spread in both helpers so no caller can silently
override them. Adds test/proc.test.js with regression coverage for the
shell invariant and full path coverage for dockerExec().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant