diff --git a/scripts/codex-git-hooks/pre-commit b/scripts/codex-git-hooks/pre-commit index b4c608c76..e5bdab661 100644 --- a/scripts/codex-git-hooks/pre-commit +++ b/scripts/codex-git-hooks/pre-commit @@ -36,10 +36,21 @@ scan_added_lines() { return 0 fi - if hits="$(printf '%s\n' "$added_lines" | rg -n --pcre2 "$regex" 2>/dev/null)"; then + if ! command -v rg >/dev/null 2>&1; then + printf '[ECC pre-commit] ripgrep (rg) is required for secret scanning; install it and ensure it is on PATH when Git runs the hook.\n' >&2 + exit 1 + fi + + if hits="$(printf '%s\n' "$added_lines" | rg -n --pcre2 -- "$regex" 2>/dev/null)"; then printf '\n[ECC pre-commit] Potential secret detected (%s) in %s\n' "$name" "$file" >&2 printf '%s\n' "$hits" | head -n 3 >&2 has_findings=1 + else + local scan_status=$? + if [[ "$scan_status" -ne 1 ]]; then + printf '[ECC pre-commit] Secret scanner failed (rg exit %s) for %s; commit blocked because scanning could not complete.\n' "$scan_status" "$file" >&2 + exit 1 + fi fi } @@ -56,7 +67,7 @@ while IFS= read -r file; do scan_added_lines "$file" "GitHub classic token" 'ghp_[A-Za-z0-9]{36}' scan_added_lines "$file" "GitHub fine-grained token" 'github_pat_[A-Za-z0-9_]{20,}' scan_added_lines "$file" "AWS access key" 'AKIA[0-9A-Z]{16}' - scan_added_lines "$file" "private key block" '-----BEGIN (RSA|EC|OPENSSH|DSA|PRIVATE) KEY-----' + scan_added_lines "$file" "private key block" '-----BEGIN (RSA |EC |OPENSSH |DSA |ENCRYPTED )?PRIVATE KEY-----' scan_added_lines "$file" "generic credential assignment" "(?i)\\b(api[_-]?key|secret|password|token)\\b\\s*[:=]\\s*['\\\"][^'\\\"]{12,}['\\\"]" done <<< "$staged_files" diff --git a/tests/scripts/codex-hooks.test.js b/tests/scripts/codex-hooks.test.js index 50d0cf5f0..7889fa5d6 100644 --- a/tests/scripts/codex-hooks.test.js +++ b/tests/scripts/codex-hooks.test.js @@ -11,6 +11,7 @@ const TOML = require('@iarna/toml'); const repoRoot = path.join(__dirname, '..', '..'); const installScript = path.join(repoRoot, 'scripts', 'codex', 'install-global-git-hooks.sh'); +const preCommitHook = path.join(repoRoot, 'scripts', 'codex-git-hooks', 'pre-commit'); const prePushHook = path.join(repoRoot, 'scripts', 'codex-git-hooks', 'pre-push'); const pluginCacheCheckScript = path.join(repoRoot, 'scripts', 'codex', 'check-plugin-cache.js'); const mergeCodexConfigScript = path.join(repoRoot, 'scripts', 'codex', 'merge-codex-config.js'); @@ -164,6 +165,86 @@ if ( passed++; else failed++; +function runHermeticPreCommit({ content = 'safe text\n', filename = 'sample.txt', scanner = false, env = {} } = {}) { + const tempDir = createTempDir('codex-pre-commit-'); + try { + const bashEnv = path.join(tempDir, 'bash-env'); + const binDir = path.join(tempDir, 'bin'); + const callsPath = path.join(tempDir, 'scanner-calls'); + fs.mkdirSync(binDir); + const hookEnv = makeHermeticCodexEnv(tempDir, path.join(tempDir, '.codex'), { + PATH: process.env.PATH, + GIT_CONFIG_NOSYSTEM: '1', + MSYS_NO_PATHCONV: '1', + ECC_SKIP_GIT_HOOKS: '0', + ECC_SKIP_PRECOMMIT: '0', + ...env, + }); + const initialized = spawnSync('git', ['init', '--quiet'], { cwd: tempDir, env: hookEnv }); + assert.strictEqual(initialized.status, 0, initialized.stderr?.toString()); + if (content !== null) { + fs.writeFileSync(path.join(tempDir, filename), content); + const staged = spawnSync('git', ['add', '--', filename], { cwd: tempDir, env: hookEnv }); + assert.strictEqual(staged.status, 0, staged.stderr?.toString()); + } + // Keep real Git/awk available, while hiding every installed ripgrep binary. + fs.writeFileSync(bashEnv, ` +git_path="$(command -v git)" +awk_path="$(command -v awk)" +head_path="$(command -v head)" +cat_path="$(command -v cat)" +rg_path="$(command -v rg || true)" +git() { "$git_path" "$@"; } +awk() { "$awk_path" "$@"; } +head() { "$head_path" "$@"; } +cat() { "$cat_path" "$@"; } +unset -f rg +PATH="${toBashPath(binDir)}" +${scanner ? `rg() { +printf '%s\\n' "$*" >> "${toBashPath(callsPath)}" +${scanner === 'real' ? '"$rg_path" "$@"' : scanner === 'finding' ? "printf '1:synthetic-scanner-finding\\n'; return 0;" : scanner === 'error' ? 'return 2;' : 'return 1;'} +}` : ''} +`); + const result = runBash(preCommitHook, { + env: { ...hookEnv, BASH_ENV: toBashPath(bashEnv) }, + cwd: tempDir, + preservePath: false, + }); + result.scannerCalls = fs.existsSync(callsPath) ? fs.readFileSync(callsPath, 'utf8').trim().split('\n') : []; + return result; + } finally { + cleanup(tempDir); + } +} + +for (const [name, options, status, diagnostic] of [ + ['blocks safe staged text when ripgrep is missing', {}, 1, /ripgrep.*required/i], + ['blocks a staged token when ripgrep is missing', { content: `token = "ghp_${'a'.repeat(36)}"\n` }, 1, /ripgrep.*required/i], + ['allows a successful scan with no findings', { scanner: true }, 0, null], + ['blocks a finding reported by an invoked scanner', { scanner: 'finding' }, 1, /Potential secret detected/], + ['blocks an invoked scanner error', { scanner: 'error' }, 1, /scanner failed.*exit 2/i], + ['allows safe text through the real scanner', { scanner: 'real' }, 0, null], + ['blocks a token through the real scanner', { scanner: 'real', content: `token = "ghp_${'a'.repeat(36)}"\n` }, 1, /Potential secret detected/], + ['blocks a private key through the real scanner', { scanner: 'real', content: '-----BEGIN PRIVATE KEY-----\n' }, 1, /private key block/], + ['blocks an RSA private key through the real scanner', { scanner: 'real', content: '-----BEGIN RSA PRIVATE KEY-----\n' }, 1, /private key block/], + ['allows an empty index without ripgrep', { content: null }, 0, null], + ['allows excluded lockfiles without ripgrep', { filename: 'yarn.lock' }, 0, null], + ['preserves the pre-commit bypass warning', { env: { ECC_SKIP_PRECOMMIT: '1' } }, 0, /hook bypassed via env/], + ['preserves the global bypass warning', { env: { ECC_SKIP_GIT_HOOKS: '1' } }, 0, /hook bypassed via env/], +]) { + if (test(`pre-commit ${name}`, () => { + const result = runHermeticPreCommit(options); + assert.strictEqual(result.status, status, `${result.stdout}\n${result.stderr}`); + if (diagnostic) assert.match(result.stderr, diagnostic); + else assert.strictEqual(result.stderr, ''); + if (options.scanner) { + assert.ok(result.scannerCalls.length > 0, 'The scanner must actually execute'); + assert.ok(result.scannerCalls.every(call => call.startsWith('-n --pcre2 -- ')), 'Regex must follow the option terminator'); + } + })) passed++; + else failed++; +} + function runHermeticPrePush({ failScript = null, includeCorepack = true,