mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-18 15:50:11 +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();
|
||||
Reference in New Issue
Block a user