// 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 ` — 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 }); } }); });