mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-17 07:10:12 +02:00
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:
@@ -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();
|
||||
|
||||
|
||||
Executable
+146
@@ -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();
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user