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.

View File

@@ -3,11 +3,31 @@
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdir, writeFile, rm } from 'node:fs/promises';
import { mkdir, writeFile, rm, stat as fsStat } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { execFileSync } from 'node:child_process';
import { checkShipReady } from './check-ship-ready.js';
/**
* Initialize a fresh git repo in `dir` with one empty initial commit on
* branch `main`. Uses argv-based execFileSync so the test harness itself
* never shell-interpolates anything. Returns silently if git is unavailable
* on the host; callers check for that and skip.
*/
function initGitRepoOrSkip(dir: string): boolean {
try {
execFileSync('git', ['init', '--initial-branch=main', '--quiet'], { cwd: dir, stdio: ['pipe', 'pipe', 'pipe'] });
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir, stdio: ['pipe', 'pipe', 'pipe'] });
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: ['pipe', 'pipe', 'pipe'] });
execFileSync('git', ['config', 'commit.gpgsign', 'false'], { cwd: dir, stdio: ['pipe', 'pipe', 'pipe'] });
execFileSync('git', ['commit', '--allow-empty', '-m', 'init', '--quiet'], { cwd: dir, stdio: ['pipe', 'pipe', 'pipe'] });
return true;
} catch {
return false;
}
}
describe('checkShipReady', () => {
let projectDir: string;
@@ -86,6 +106,126 @@ describe('checkShipReady', () => {
expect(d.blockers).toContain('verification status is not passed');
});
// ─── #3587: shell-injection regression guard ─────────────────────────────
//
// Branch names in git can legally contain shell metacharacters (`;`, `$()`,
// backticks, etc.). The pre-fix implementation interpolated current_branch
// into a shell-string `git config --get branch.${current_branch}.merge`
// and ran it through `execSync()`, allowing a maliciously-named branch to
// execute arbitrary shell commands. These tests prove the fix uses argv
// execution so branch names are passed as literal data, never parsed by
// the shell.
it('#3587: branch name with shell-injection payload does not execute injected command', async () => {
if (!initGitRepoOrSkip(projectDir)) {
// git not available in this environment — the regression is git-specific
// and the production code is unreachable without git, so skipping is the
// correct disposition rather than a false-positive pass.
return;
}
// Proven exploit payload. `$IFS` (shell-expanded to a space) lets us
// smuggle a multi-token command into a refname that git accepts.
// Manually verified on git 2.53.0: refname `foo;touch${IFS}INJ;bar` is
// valid AND, when interpolated into `execSync('git config --get
// branch.${branch}.merge')`, /bin/sh parses three commands —
// `git config --get branch.foo`, `touch INJ`, `bar.merge` — and the
// middle `touch` creates the sentinel file inside projectDir.
const injectedFile = 'INJECTED_BY_3587';
const branchName = `foo;touch\${IFS}${injectedFile};bar`;
let exploitBranch: string | null = null;
try {
execFileSync('git', ['checkout', '-q', '-b', branchName], {
cwd: projectDir,
stdio: ['pipe', 'pipe', 'pipe'],
});
exploitBranch = branchName;
} catch {
// If git refuses this exact refname (e.g. older or stricter
// platform), the canonical exploit shape isn't reachable here;
// skipping the assertion is the correct disposition rather than
// a false-positive pass.
exploitBranch = null;
}
await mkdir(join(projectDir, '.planning', 'phases', '01-test'), { recursive: true });
await checkShipReady(['1'], projectDir);
if (exploitBranch !== null) {
// Negative proof: the injected `touch INJECTED_BY_3587` MUST NOT
// have run. Buggy code (shell-string execSync) creates the file
// as a side-effect of interpolating the malicious branch name into
// the command. Fixed code (argv-based execFileSync) passes the
// branch name as a single argv element, so the shell never sees
// the metacharacters.
let injectedExists = false;
try {
await fsStat(join(projectDir, injectedFile));
injectedExists = true;
} catch { /* missing — desired */ }
expect(injectedExists).toBe(false);
}
});
it('#3587: round-trips a metacharacter branch name verbatim in current_branch', async () => {
if (!initGitRepoOrSkip(projectDir)) return;
// Positive proof: the branch name (including metacharacters) is
// returned as data, not consumed by shell parsing. If the
// implementation lost the metacharacters during shell quoting, this
// assertion would fail; if the implementation passes the value as an
// argv element (`execFileSync('git', ['config', ...])`) it survives
// verbatim.
const branchName = 'feat/data$with(parens)`and-backticks`';
let actualBranch = branchName;
try {
execFileSync('git', ['checkout', '-q', '-b', branchName], {
cwd: projectDir,
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch {
// Some git versions reject certain refname shapes — fall back to a
// simpler metacharacter combination that all gits accept.
actualBranch = 'feat/data-$dollar-and-(paren)';
execFileSync('git', ['checkout', '-q', '-b', actualBranch], {
cwd: projectDir,
stdio: ['pipe', 'pipe', 'pipe'],
});
}
await mkdir(join(projectDir, '.planning', 'phases', '01-test'), { recursive: true });
const { data } = await checkShipReady(['1'], projectDir);
const d = data as Record<string, unknown>;
expect(d.current_branch).toBe(actualBranch);
// on_feature_branch must still flag a non-main/master branch correctly
// even when the name contains metacharacters.
expect(d.on_feature_branch).toBe(true);
});
it('#3587: gh probe does not invoke a shell — gh argv runs even when PATH globs are present', async () => {
if (!initGitRepoOrSkip(projectDir)) return;
// The pre-fix code ran `gh --version` and `which gh` as shell strings.
// No interpolation site exists for those today, but locking the
// contract here ensures a future change that interpolates a value
// (e.g. `which ${candidate}`) cannot regress silently. We assert the
// module returns a structured boolean for gh_available without throwing
// on a project dir whose name contains characters a shell would treat
// specially.
await mkdir(join(projectDir, '.planning', 'phases', '01-test'), { recursive: true });
const { data } = await checkShipReady(['1'], projectDir);
const d = data as Record<string, unknown>;
expect(typeof d.gh_available).toBe('boolean');
expect(d.gh_authenticated).toBe(false);
});
it('blocks shipping when verification status is human_needed', async () => {
const phaseDir = join(projectDir, '.planning', 'phases', '02-core');
await mkdir(phaseDir, { recursive: true });

View File

@@ -4,24 +4,41 @@
* Consolidates git/gh checks from `ship.md` into a single structured query.
* All subprocess calls are wrapped in try/catch — never throws on git/gh failures.
* See `.planning/research/decision-routing-audit.md` §3.9.
*
* #3587: every subprocess call uses argv-based execFileSync — never a
* shell-string execSync. Git branch names are repository-controlled data
* and can legally contain metacharacters (`;`, `$`, backticks, etc.); a
* shell-string `git config --get branch.${current_branch}.merge` allowed
* arbitrary command injection from a malicious branch name. Passing args
* as argv elements means the shell is never invoked.
*/
import { execSync } from 'node:child_process';
import { execFileSync } from 'node:child_process';
import { GSDError, ErrorClassification } from '../errors.js';
import { normalizePhaseName } from './helpers.js';
import { checkVerificationStatus } from './check-verification-status.js';
import type { QueryHandler } from './utils.js';
function runSyncSafe(cmd: string, cwd: string): string | null {
/**
* Run a subprocess via argv (NEVER a shell string). Returns trimmed stdout
* on success or null on any failure (non-zero exit, missing binary, etc.).
* The pre-#3587 helper used `execSync(cmd, …)` which spawned `/bin/sh -c`
* and parsed `cmd` as shell syntax — that path is gone.
*/
function runArgvSafe(file: string, args: readonly string[], cwd: string): string | null {
try {
return execSync(cmd, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
return execFileSync(file, args, {
cwd,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
} catch {
return null;
}
}
function boolSyncSafe(cmd: string, cwd: string): boolean {
return runSyncSafe(cmd, cwd) !== null;
function boolArgvSafe(file: string, args: readonly string[], cwd: string): boolean {
return runArgvSafe(file, args, cwd) !== null;
}
export const checkShipReady: QueryHandler = async (args, projectDir) => {
@@ -34,11 +51,11 @@ export const checkShipReady: QueryHandler = async (args, projectDir) => {
const blockers: string[] = [];
// git checks — all wrapped in try/catch via helpers
const porcelain = runSyncSafe('git status --porcelain', projectDir);
// git checks — all wrapped in try/catch via helpers, all argv-based.
const porcelain = runArgvSafe('git', ['status', '--porcelain'], projectDir);
const clean_tree = porcelain !== null && porcelain === '';
const current_branch = runSyncSafe('git rev-parse --abbrev-ref HEAD', projectDir);
const current_branch = runArgvSafe('git', ['rev-parse', '--abbrev-ref', 'HEAD'], projectDir);
const on_feature_branch =
current_branch !== null &&
current_branch !== 'main' &&
@@ -47,23 +64,31 @@ export const checkShipReady: QueryHandler = async (args, projectDir) => {
// Determine base branch
let base_branch: string | null = null;
if (current_branch) {
const mergeRef = runSyncSafe(`git config --get branch.${current_branch}.merge`, projectDir);
// #3587: branch name passed as a single argv element — git treats it
// as data, the shell is never invoked, no interpolation possible.
const mergeRef = runArgvSafe(
'git',
['config', '--get', `branch.${current_branch}.merge`],
projectDir,
);
if (mergeRef) {
base_branch = mergeRef.replace('refs/heads/', '');
} else {
// Fallback: check if 'main' branch exists, else 'master'
const mainExists = boolSyncSafe('git rev-parse --verify main', projectDir);
const mainExists = boolArgvSafe('git', ['rev-parse', '--verify', 'main'], projectDir);
base_branch = mainExists ? 'main' : 'master';
}
}
const remoteOut = runSyncSafe('git remote', projectDir);
const remoteOut = runArgvSafe('git', ['remote'], projectDir);
const remote_configured = remoteOut !== null && remoteOut.trim().length > 0;
// gh availability
// gh availability — argv as well so a future change that interpolates
// a user-controlled value into the probe cannot silently introduce a
// new injection seam.
const gh_available =
boolSyncSafe('gh --version', projectDir) ||
boolSyncSafe('which gh', projectDir);
boolArgvSafe('gh', ['--version'], projectDir) ||
boolArgvSafe('which', ['gh'], projectDir);
// gh_authenticated: advisory — skip actual auth check to avoid slow network call
const gh_authenticated = false;