git-forensics ran execSync(`git ${cmd}`), interpolating attacker-controlled
filenames from the SCANNED repo (git ls-files / git log --name-only) into a
shell string. A hostile repo containing a file named `commands/$(touch X).md`
achieved zero-interaction RCE on `/security scan <url>`: gitScan is in the
default scanner array and runs OUTSIDE the git-clone OS sandbox (which wraps
only the clone), so it executed unsandboxed on all platforms.
Convert the git() helper to spawnSync('git', [...args]) with no shell; every
call site now passes discrete tokens (shell quoting removed — git does its own
pathspec globbing). The helper throws on non-zero exit, preserving existing
per-category/per-file try/catch semantics.
TDD: adds a failing-first regression (tests/scanners/git-injection.test.mjs)
that builds a hostile-filename fixture repo and asserts the injected command
never runs. RED against execSync, GREEN after the fix.
Also removes two stray committed root artifacts (F-5/F-6): `--json`
(0-byte redirect husk) and .orphaned_at.
Closing gates: full node --test suite 1860/0; gitleaks clean. F-2/F-3 follow
in Session B.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3s6WnubSSrFjAQTLQdVbG
81 lines
3.5 KiB
JavaScript
81 lines
3.5 KiB
JavaScript
// git-injection.test.mjs — Security regression for F-1 (CRITICAL, zero-interaction RCE).
|
|
//
|
|
// The git-forensics scanner historically ran `execSync(`git ${cmd}`)`, interpolating
|
|
// attacker-controlled filenames (from `git ls-files` / `git log --name-only` of the
|
|
// SCANNED repo) into a shell string. A repo containing a file named
|
|
// `commands/$(touch INJECTED).md` therefore executed arbitrary code on the analyst's
|
|
// machine during `/security scan <url>` — before any confirmation, outside the
|
|
// git-clone OS sandbox.
|
|
//
|
|
// This test builds such a hostile fixture repo and asserts the injected `touch`
|
|
// never runs. It must FAIL against the vulnerable execSync() helper and PASS once
|
|
// `git()` uses spawnSync('git', [...args]) with no shell.
|
|
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { resetCounter } from '../../scanners/lib/output.mjs';
|
|
import { scan } from '../../scanners/git-forensics.mjs';
|
|
|
|
const gitAvailable = spawnSync('git', ['--version'], { encoding: 'utf-8' }).status === 0;
|
|
|
|
/** Initialise a hermetic git repo (no global hooks/signing) at `dir`. */
|
|
function gitInit(dir) {
|
|
const run = (...args) =>
|
|
spawnSync('git', args, { cwd: dir, encoding: 'utf-8', env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1' } });
|
|
run('init', '-q');
|
|
run('config', 'user.email', 'test@example.com');
|
|
run('config', 'user.name', 'Test');
|
|
run('config', 'commit.gpgsign', 'false');
|
|
run('config', 'core.hooksPath', '/dev/null');
|
|
return run;
|
|
}
|
|
|
|
describe('git-forensics shell-injection regression (F-1)', () => {
|
|
it('does not execute injected commands embedded in scanned filenames', { skip: !gitAvailable }, async () => {
|
|
const repo = mkdtempSync(join(tmpdir(), 'git-forensics-injection-'));
|
|
// The injected `touch INJECTED` runs with cwd = repo, so the sentinel lands here.
|
|
const sentinel = join(repo, 'INJECTED');
|
|
try {
|
|
const run = gitInit(repo);
|
|
|
|
// A benign first commit so the repo has history the scanner will walk.
|
|
writeFileSync(join(repo, 'README.md'), '# fixture\n');
|
|
run('add', '-A');
|
|
run('commit', '-q', '-m', 'initial');
|
|
|
|
// The payload: a file under commands/ whose NAME is a shell command
|
|
// substitution. It matches the scanner's `commands/*.md` pathspec, so the
|
|
// name is fed back into git show/log for description-drift analysis.
|
|
mkdirSync(join(repo, 'commands'), { recursive: true });
|
|
const evilName = 'commands/$(touch INJECTED).md';
|
|
writeFileSync(
|
|
join(repo, evilName),
|
|
'---\ndescription: original description text for drift baseline\n---\nbody\n',
|
|
);
|
|
run('add', '-A');
|
|
run('commit', '-q', '-m', 'add command');
|
|
|
|
assert.ok(!existsSync(sentinel), 'precondition: sentinel must not exist before scan');
|
|
|
|
resetCounter();
|
|
const result = await scan(repo, {});
|
|
|
|
// The scanner must complete gracefully on a hostile repo...
|
|
assert.ok(
|
|
['ok', 'skipped', 'error'].includes(result.status),
|
|
`scan should complete, got status '${result.status}'`,
|
|
);
|
|
// ...and crucially must NOT have executed the injected command.
|
|
assert.ok(
|
|
!existsSync(sentinel),
|
|
'SHELL INJECTION: scanning a repo with a $(...) filename executed the injected command',
|
|
);
|
|
} finally {
|
|
rmSync(repo, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|