fix(3587)(security): argv-based subprocess for check.ship-ready

`gsd-sdk query check.ship-ready <phase>` built a git command as a shell
string with the current branch name interpolated. Git branch names can
legally contain shell metacharacters, so a repo checked out on a
malicious branch like `foo;touch${IFS}INJ;bar` executed arbitrary shell
commands.

Vulnerability site (pre-fix):

  sdk/src/query/check-ship-ready.ts:50
    runSyncSafe(`git config --get branch.${current_branch}.merge`, cwd)
  → execSync('git config --get branch.foo;touch${IFS}INJ;bar.merge')
  → /bin/sh -c parses three commands; the middle one runs `touch INJ`
    in the project dir and creates the sentinel file.

Manually reproduced on git 2.53.0:
  - refname `foo;touch${IFS}INJ;bar` is accepted by `git check-ref-format`
    and by `git checkout -b`.
  - `current_branch` returned from `git rev-parse --abbrev-ref HEAD`
    contains the metacharacters verbatim.
  - Interpolation into the buggy execSync call creates the sentinel.

Fix:

- Replace `runSyncSafe(cmd: string, cwd)` (execSync, shell-string) with
  `runArgvSafe(file, args: readonly string[], cwd)` (execFileSync,
  argv-based, no shell).
- Same shape for the boolean wrapper: `boolArgvSafe`.
- Convert all 7 subprocess sites in the module to argv form:
  - `git status --porcelain`
  - `git rev-parse --abbrev-ref HEAD`
  - `git config --get branch.<name>.merge`   ← the interpolation site
  - `git rev-parse --verify main`
  - `git remote`
  - `gh --version`
  - `which gh`
- Shell is never invoked. Branch names — even ones with `;`, `$IFS`,
  backticks, `$()` — are passed as a single argv element and treated
  as opaque data.

Regression test (`sdk/src/query/check-ship-ready.test.ts`):

- `#3587: branch name with shell-injection payload does not execute
  injected command` — creates a real git repo, checks out the proven
  exploit branch `foo;touch${IFS}INJECTED_BY_3587;bar`, runs
  checkShipReady, and asserts the sentinel file does NOT exist. This
  test FAILS on the unfixed code (verified pre-implementation) and
  PASSES on the fixed code — true red→green TDD.
- `#3587: round-trips a metacharacter branch name verbatim in
  current_branch` — positive proof the branch name survives argv as
  data (would fail if a future change re-introduces shell quoting).
- `#3587: gh probe does not invoke a shell` — locks the gh path
  against a future regression that might add an interpolation site.

Validation:
- SDK unit suite via vitest: 1,869/1,869 pass.
- Full root suite via gsd-test-both (per CLAUDE.md): 10,676/10,676
  on Mac AND 10,676/10,676 on Linux Docker, zero cross-platform diff.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-15 20:42:52 -04:00
parent 823b4ece0a
commit 4e7e83bf58
3 changed files with 185 additions and 15 deletions

View File

@@ -0,0 +1,5 @@
---
type: Security
pr: 3587
---
**Fixed shell-injection in `check.ship-ready`** — a maliciously-named git branch (e.g. `foo;touch${IFS}INJ;bar`) could execute arbitrary shell commands when `gsd-sdk query check.ship-ready <phase>` ran from the repository. `sdk/src/query/check-ship-ready.ts` interpolated the current branch name into a shell-string `git config --get branch.${current_branch}.merge` and ran it via `execSync`. Every subprocess call in the module now uses argv-based `execFileSync` — the shell is never invoked, branch names are passed as opaque data, and no interpolation site exists.