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) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-29 07:07:28 -07:00
parent b5ff65c9fd
commit 889ed78932
3 changed files with 371 additions and 0 deletions
+72
View File
@@ -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();
+146
View File
@@ -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:
* <local ref> <local sha> <remote ref> <remote sha>
* We scan the ADDED lines of <remote sha>..<local sha> 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();
+153
View File
@@ -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<string, string> = {},
): { 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");
});
});