From 889ed789328a2de59fe8cda220fa2e98e756f350 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 29 May 2026 07:07:28 -0700 Subject: [PATCH] feat(redact): opt-in pre-push hook (accident catcher) + safe installer bin/gstack-redact-prepush scans the diff being pushed for HIGH credentials and blocks on a hit, for public AND private repos (a pushed secret is compromised regardless of visibility). Correct git pre-push semantics: scans remote..local (what's being pushed), handles new-branch zero-SHA via merge-base or empty-tree fallback, force-push, and branch-delete skip. MEDIUM warns non-blocking; LOW/WARN silent. GSTACK_REDACT_PREPUSH=skip escape valve logs to prepush-skip.jsonl. bin/gstack-redact gains install-prepush-hook / uninstall-prepush-hook subcommands that chain any pre-existing hook (renamed to pre-push.local, stdin forwarded to both, exit code propagated). Guardrail not enforcement: --no-verify and the env skip both bypass; it scans only the pushed delta, not history/binary/LFS. 9 tests in a throwaway git repo. Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/gstack-redact | 72 +++++++++++++++ bin/gstack-redact-prepush | 146 +++++++++++++++++++++++++++++ test/redact-prepush-hook.test.ts | 153 +++++++++++++++++++++++++++++++ 3 files changed, 371 insertions(+) create mode 100755 bin/gstack-redact-prepush create mode 100644 test/redact-prepush-hook.test.ts diff --git a/bin/gstack-redact b/bin/gstack-redact index 8f61e6580..ccb6e48c5 100755 --- a/bin/gstack-redact +++ b/bin/gstack-redact @@ -27,6 +27,8 @@ * user can always bypass it (direct gh/git). It catches accidents. */ import * as fs from "fs"; +import * as path from "path"; +import { spawnSync } from "child_process"; import { scan, applyRedactions, @@ -38,6 +40,71 @@ import { const MAX_STDIN_BYTES = 16 * 1024 * 1024; // hard ceiling before the engine cap +// ── pre-push hook install/uninstall (chains any existing hook) ──────────────── + +const MANAGED_MARKER = "# gstack-redact pre-push (managed)"; + +function hooksPath(): string { + const r = spawnSync("git", ["rev-parse", "--git-path", "hooks"], { encoding: "utf8" }); + if (r.status !== 0) { + process.stderr.write("gstack-redact: not in a git repo\n"); + process.exit(1); + } + return r.stdout.trim(); +} + +function installPrepushHook(): void { + const dir = hooksPath(); + fs.mkdirSync(dir, { recursive: true }); + const hookPath = path.join(dir, "pre-push"); + const prepushBin = path.join(import.meta.dir, "gstack-redact-prepush"); + + // If a non-managed hook exists, preserve it as pre-push.local and chain it. + if (fs.existsSync(hookPath)) { + const existing = fs.readFileSync(hookPath, "utf8"); + if (existing.includes(MANAGED_MARKER)) { + process.stdout.write("gstack-redact: pre-push hook already installed.\n"); + return; + } + const localPath = path.join(dir, "pre-push.local"); + fs.renameSync(hookPath, localPath); + fs.chmodSync(localPath, 0o755); + process.stdout.write("gstack-redact: preserved existing hook as pre-push.local (chained).\n"); + } + + // stdin is single-consume: capture it once, feed both the chained hook and ours. + const wrapper = `#!/usr/bin/env bash +${MANAGED_MARKER} +set -euo pipefail +_input="$(cat)" +_local="$(git rev-parse --git-path hooks/pre-push.local)" +if [ -x "$_local" ]; then + printf '%s' "$_input" | "$_local" "$@" || exit $? +fi +printf '%s' "$_input" | bun "${prepushBin}" "$@" +`; + fs.writeFileSync(hookPath, wrapper, { mode: 0o755 }); + fs.chmodSync(hookPath, 0o755); + process.stdout.write(`gstack-redact: installed pre-push hook at ${hookPath}\n`); +} + +function uninstallPrepushHook(): void { + const dir = hooksPath(); + const hookPath = path.join(dir, "pre-push"); + const localPath = path.join(dir, "pre-push.local"); + if (!fs.existsSync(hookPath) || !fs.readFileSync(hookPath, "utf8").includes(MANAGED_MARKER)) { + process.stdout.write("gstack-redact: no managed pre-push hook to remove.\n"); + return; + } + if (fs.existsSync(localPath)) { + fs.renameSync(localPath, hookPath); // restore the chained original + process.stdout.write("gstack-redact: removed managed hook, restored pre-push.local.\n"); + } else { + fs.unlinkSync(hookPath); + process.stdout.write("gstack-redact: removed managed pre-push hook.\n"); + } +} + function arg(name: string): string | undefined { const i = process.argv.indexOf(name); return i >= 0 ? process.argv[i + 1] : undefined; @@ -115,6 +182,11 @@ function humanTable(findings: Finding[]): string { } function main() { + // Subcommands (positional, not flags). + const sub = process.argv[2]; + if (sub === "install-prepush-hook") return installPrepushHook(); + if (sub === "uninstall-prepush-hook") return uninstallPrepushHook(); + const opts = buildOpts(); const input = readInput(); diff --git a/bin/gstack-redact-prepush b/bin/gstack-redact-prepush new file mode 100755 index 000000000..25fc8c1d4 --- /dev/null +++ b/bin/gstack-redact-prepush @@ -0,0 +1,146 @@ +#!/usr/bin/env bun +/** + * gstack-redact-prepush — git pre-push hook that scans the diff being pushed for + * HIGH-severity credentials and blocks the push on a hit. + * + * THIS IS A GUARDRAIL, NOT ENFORCEMENT. `git push --no-verify` bypasses it, as + * does `GSTACK_REDACT_PREPUSH=skip`. It catches accidental credential pushes, + * the most common real-world leak. It does NOT scan history, binary/LFS/submodule + * files, or non-added lines. History scanning is /cso's job. + * + * Git pre-push interface: refs are read from STDIN, one per line: + * + * We scan the ADDED lines of .. per ref (what's being + * pushed). Special cases: + * - remote sha all-zeroes → new branch: diff against merge-base with the + * remote's default branch (fallback: scan all commits unique to local ref). + * - local sha all-zeroes → branch delete: nothing to scan, skip. + * - force-push → remote..local still gives the net new content. + * + * Behavior: + * - HIGH finding in added lines → print + exit 1 (block), for public AND private. + * - MEDIUM → warn (non-blocking). LOW/WARN → silent. + * - GSTACK_REDACT_PREPUSH=skip → log + exit 0 (escape valve). + * + * Installed/uninstalled via `gstack-redact install-prepush-hook` (see the + * gstack-redact CLI), which chains any pre-existing hook. + */ +import { spawnSync } from "child_process"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { scan, type Finding } from "../lib/redact-engine"; + +const ZERO = /^0+$/; +// The canonical empty-tree object; diffing against it yields all content as added. +const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; + +function git(args: string[]): string { + const r = spawnSync("git", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); + return r.status === 0 ? (r.stdout ?? "") : ""; +} + +function defaultRemoteBranch(): string { + // origin/HEAD → origin/main, fall back to main/master. + const sym = git(["symbolic-ref", "refs/remotes/origin/HEAD"]).trim(); + if (sym) return sym.replace("refs/remotes/", ""); + for (const b of ["origin/main", "origin/master"]) { + if (git(["rev-parse", "--verify", b]).trim()) return b; + } + return "origin/main"; +} + +/** Return the added-line text for a ref update being pushed. */ +function addedLinesFor(localSha: string, remoteSha: string): string { + let range: string; + if (ZERO.test(remoteSha)) { + // New branch: prefer what's unique to localSha vs the remote default branch. + // With no merge-base (e.g. no remote yet), diff against the empty tree so ALL + // branch content is scanned as added — fail-safe (scans more, never less). + const base = git(["merge-base", localSha, defaultRemoteBranch()]).trim(); + range = base ? `${base}..${localSha}` : `${EMPTY_TREE}..${localSha}`; + } else { + // Existing branch (incl. force-push): net new content remote..local. + range = `${remoteSha}..${localSha}`; + } + // -U0: only changed lines; we keep lines starting with '+' (added), drop the + // +++ file header. Unified diff added lines start with a single '+'. + const diff = git(["diff", "--unified=0", "--no-color", range]); + const added: string[] = []; + for (const line of diff.split("\n")) { + if (line.startsWith("+") && !line.startsWith("+++")) { + added.push(line.slice(1)); + } + } + return added.join("\n"); +} + +function logSkip(reason: string): void { + try { + const home = process.env.GSTACK_HOME || path.join(os.homedir(), ".gstack"); + const dir = path.join(home, "security"); + fs.mkdirSync(dir, { recursive: true }); + fs.appendFileSync( + path.join(dir, "prepush-skip.jsonl"), + JSON.stringify({ ts: new Date().toISOString(), reason }) + "\n", + ); + } catch { + // best-effort; never block a push because logging failed + } +} + +function main() { + if ((process.env.GSTACK_REDACT_PREPUSH || "").toLowerCase() === "skip") { + logSkip(process.env.GSTACK_REDACT_PREPUSH_REASON || "env-skip"); + process.stderr.write("gstack-redact-prepush: skipped via GSTACK_REDACT_PREPUSH=skip\n"); + process.exit(0); + } + + const stdin = fs.readFileSync(0, "utf8"); + const refs = stdin + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + .map((l) => l.split(/\s+/)); + + const allHigh: Finding[] = []; + let mediumCount = 0; + + for (const [, localSha, , remoteSha] of refs) { + if (!localSha || ZERO.test(localSha)) continue; // branch delete → nothing pushed + const added = addedLinesFor(localSha, remoteSha || "0"); + if (!added.trim()) continue; + // Visibility doesn't change HIGH behavior; pass private so nothing is treated + // as public-strict (HIGH blocks regardless either way). + const result = scan(added, { repoVisibility: "private" }); + for (const f of result.findings) { + if (f.severity === "HIGH") allHigh.push(f); + else if (f.severity === "MEDIUM") mediumCount++; + } + } + + if (mediumCount > 0) { + process.stderr.write( + `gstack-redact-prepush: ${mediumCount} MEDIUM finding(s) in pushed diff (PII/internal). ` + + "Not blocking. Review before this becomes public.\n", + ); + } + + if (allHigh.length > 0) { + process.stderr.write( + "\n⛔ gstack-redact-prepush BLOCKED the push — credential(s) in the pushed diff:\n\n", + ); + for (const f of allHigh) { + process.stderr.write(` HIGH ${f.id} ${f.preview}\n`); + } + process.stderr.write( + "\nRotate the credential (a pushed secret is compromised) and remove it from the diff.\n" + + "This is a guardrail: `git push --no-verify` or `GSTACK_REDACT_PREPUSH=skip git push` bypass it.\n", + ); + process.exit(1); + } + + process.exit(0); +} + +main(); diff --git a/test/redact-prepush-hook.test.ts b/test/redact-prepush-hook.test.ts new file mode 100644 index 000000000..8447cf6d5 --- /dev/null +++ b/test/redact-prepush-hook.test.ts @@ -0,0 +1,153 @@ +/** + * Pre-push hook tests (T9). Builds a throwaway local "remote" + working repo, + * drives the hook with realistic stdin ref-lines, and checks: HIGH blocks, + * MEDIUM warns (non-blocking), correct remote..local diff direction, new-branch + * zero-SHA handling, branch-delete skip, escape valve, and hook chaining. + * + * We invoke bin/gstack-redact-prepush directly with the git pre-push stdin + * protocol rather than going through `git push`, which keeps the test fast and + * deterministic while exercising the exact code path git would. + */ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { spawnSync } from "child_process"; + +const PREPUSH = path.resolve(import.meta.dir, "..", "bin", "gstack-redact-prepush"); +const REDACT = path.resolve(import.meta.dir, "..", "bin", "gstack-redact"); + +let repo: string; + +function git(args: string[], cwd = repo): string { + const r = spawnSync("git", args, { cwd, encoding: "utf8" }); + return r.stdout?.trim() ?? ""; +} + +function commit(file: string, content: string, msg: string): string { + fs.writeFileSync(path.join(repo, file), content); + git(["add", file]); + git(["commit", "-q", "-m", msg]); + return git(["rev-parse", "HEAD"]); +} + +function runHook( + stdinLines: string, + env: Record = {}, +): { code: number; stderr: string } { + const r = spawnSync("bun", [PREPUSH], { + cwd: repo, + input: Buffer.from(stdinLines), + encoding: "utf8", + env: { ...process.env, ...env }, + }); + return { code: r.status ?? 0, stderr: r.stderr ?? "" }; +} + +const ZERO = "0000000000000000000000000000000000000000"; + +beforeEach(() => { + repo = fs.mkdtempSync(path.join(os.tmpdir(), "prepush-")); + git(["init", "-q", "-b", "main"]); + git(["config", "user.email", "t@example.com"]); + git(["config", "user.name", "T"]); + commit("README.md", "hello\n", "init"); +}); + +afterEach(() => { + fs.rmSync(repo, { recursive: true, force: true }); +}); + +describe("pre-push hook gating", () => { + test("HIGH credential in pushed diff blocks (exit 1)", () => { + const base = git(["rev-parse", "HEAD"]); + const head = commit("config.txt", "key AKIA1234567890ABCDEF\n", "add key"); + const { code, stderr } = runHook(`refs/heads/main ${head} refs/heads/main ${base}\n`); + expect(code).toBe(1); + expect(stderr).toContain("BLOCKED"); + expect(stderr).toContain("aws.access_key"); + }); + + test("clean diff passes (exit 0)", () => { + const base = git(["rev-parse", "HEAD"]); + const head = commit("doc.md", "just documentation\n", "add doc"); + const { code } = runHook(`refs/heads/main ${head} refs/heads/main ${base}\n`); + expect(code).toBe(0); + }); + + test("MEDIUM warns but does not block", () => { + const base = git(["rev-parse", "HEAD"]); + const head = commit("notes.md", "contact bob@corp.io\n", "add note"); + const { code, stderr } = runHook(`refs/heads/main ${head} refs/heads/main ${base}\n`); + expect(code).toBe(0); + expect(stderr).toContain("MEDIUM"); + }); +}); + +describe("diff direction + special refs", () => { + test("only NEW content is scanned (remote..local), not pre-existing", () => { + // Put a secret in the FIRST commit (already on remote), then push a clean commit. + const withSecret = commit("old.txt", "AKIA1234567890ABCDEF\n", "old secret already pushed"); + const clean = commit("new.txt", "totally clean\n", "new clean commit"); + // remote already has withSecret; we push only the clean commit on top. + const { code } = runHook(`refs/heads/main ${clean} refs/heads/main ${withSecret}\n`); + expect(code).toBe(0); // pre-existing secret is not in the pushed delta + }); + + test("new branch (zero remote sha) scans commits unique to the branch", () => { + const head = commit("feature.txt", "ghp_" + "a".repeat(36) + "\n", "feature with token"); + const { code, stderr } = runHook(`refs/heads/feat ${head} refs/heads/feat ${ZERO}\n`); + expect(code).toBe(1); + expect(stderr).toContain("github.pat"); + }); + + test("branch delete (zero local sha) is skipped", () => { + const { code } = runHook(`(delete) ${ZERO} refs/heads/old ${git(["rev-parse", "HEAD"])}\n`); + expect(code).toBe(0); + }); +}); + +describe("escape valve", () => { + test("GSTACK_REDACT_PREPUSH=skip bypasses + logs", () => { + const base = git(["rev-parse", "HEAD"]); + const head = commit("config.txt", "key AKIA1234567890ABCDEF\n", "add key"); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "ghome-")); + const { code } = runHook(`refs/heads/main ${head} refs/heads/main ${base}\n`, { + GSTACK_REDACT_PREPUSH: "skip", + GSTACK_HOME: home, + }); + expect(code).toBe(0); + const log = fs.readFileSync(path.join(home, "security", "prepush-skip.jsonl"), "utf8"); + expect(log).toContain("env-skip"); + fs.rmSync(home, { recursive: true, force: true }); + }); +}); + +describe("install / chaining", () => { + test("install creates a managed hook; existing hook preserved + chained", () => { + const hookDir = path.join(repo, ".git", "hooks"); + fs.mkdirSync(hookDir, { recursive: true }); + const existing = path.join(hookDir, "pre-push"); + fs.writeFileSync(existing, "#!/usr/bin/env bash\necho mine\n", { mode: 0o755 }); + + const r = spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, encoding: "utf8" }); + expect(r.status).toBe(0); + const installed = fs.readFileSync(existing, "utf8"); + expect(installed).toContain("gstack-redact pre-push (managed)"); + expect(fs.existsSync(path.join(hookDir, "pre-push.local"))).toBe(true); + expect(fs.readFileSync(path.join(hookDir, "pre-push.local"), "utf8")).toContain("echo mine"); + }); + + test("uninstall restores the chained original", () => { + const hookDir = path.join(repo, ".git", "hooks"); + fs.mkdirSync(hookDir, { recursive: true }); + fs.writeFileSync(path.join(hookDir, "pre-push"), "#!/usr/bin/env bash\necho mine\n", { + mode: 0o755, + }); + spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo }); + spawnSync("bun", [REDACT, "uninstall-prepush-hook"], { cwd: repo }); + const restored = fs.readFileSync(path.join(hookDir, "pre-push"), "utf8"); + expect(restored).toContain("echo mine"); + expect(restored).not.toContain("managed"); + }); +});