mirror of https://github.com/garrytan/gstack.git
361 lines
16 KiB
Plaintext
Executable File
361 lines
16 KiB
Plaintext
Executable File
#!/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";
|
|
|
|
/**
|
|
* Permissive git for legitimately-fallible PROBES (symbolic-ref, rev-parse,
|
|
* merge-base) where a non-zero exit is normal control flow. The DIFF call
|
|
* must NOT use this — see gitStrict (#1946 fail-closed).
|
|
*/
|
|
function git(args: string[]): string {
|
|
const r = spawnSync("git", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
return r.status === 0 ? (r.stdout ?? "") : "";
|
|
}
|
|
|
|
/**
|
|
* Fail-closed git for the diff that decides whether the push is scanned
|
|
* (#1946). status !== 0 covers repo errors; status === null covers a killed
|
|
* process AND maxBuffer overflow — the oversized-diff case is exactly where
|
|
* a large secret-bearing blob is most likely, so "couldn't read the diff"
|
|
* must block, not silently allow.
|
|
*/
|
|
function gitStrict(args: string[]): string {
|
|
const r = spawnSync("git", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
// status !== 0 covers BOTH a non-zero exit AND null (process killed by a
|
|
// signal or maxBuffer overflow — null !== 0 is true).
|
|
if (r.status !== 0) {
|
|
throw new Error(
|
|
`git ${args[0]} failed (status=${r.status ?? "killed/overflow"}): ${(r.stderr ?? "").slice(0, 300)}`,
|
|
);
|
|
}
|
|
return r.stdout ?? "";
|
|
}
|
|
|
|
/** True when the object exists in the local odb (cat-file -e signals via exit code). */
|
|
function objectExists(sha: string): boolean {
|
|
const r = spawnSync("git", ["cat-file", "-e", sha], { encoding: "utf8" });
|
|
return r.status === 0;
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
/**
|
|
* Base commit for a push whose remote tip we cannot use directly, ordered from
|
|
* most precise to most conservative. Returns null when nothing can anchor the
|
|
* range, i.e. the whole history really is new content.
|
|
*/
|
|
function unknownRemoteTipBase(localSha: string): string | null {
|
|
// 1. The common case: a merge-base with the remote's default branch.
|
|
const base = git(["merge-base", localSha, defaultRemoteBranch()]).trim();
|
|
if (base) return base;
|
|
|
|
// 2. No merge-base. defaultRemoteBranch() guessed a ref that does not exist
|
|
// (default branch named trunk/develop, origin/HEAD unset), or history is
|
|
// disjoint. Anything reachable from localSha but from NO remote-tracking
|
|
// branch is what this push actually adds; the parent of its oldest commit
|
|
// is the real base.
|
|
//
|
|
// Without this we drop straight to EMPTY_TREE and re-scan content that is
|
|
// already on the remote. That is not merely wasteful, it is wrong in two
|
|
// ways: a secret pushed long ago gets re-reported as if THIS push
|
|
// introduced it (telling the operator to rotate a key over someone else's
|
|
// old commit), and on any real repository the input overshoots the
|
|
// engine's byte cap, so `engine.input_too_large` blocks having scanned
|
|
// NOTHING — "scans more, never less" inverted into "scans nothing".
|
|
//
|
|
// `--remotes` covers every remote, not just the push target: content
|
|
// already published anywhere has already left this machine, so treating it
|
|
// as pre-existing is deliberate. Git hands the remote name to pre-push in
|
|
// argv, which this hook does not read; narrowing to it would only matter
|
|
// for a repo that pushes secrets to one remote but not another.
|
|
const newCommits = git(["rev-list", "--reverse", localSha, "--not", "--remotes"]).trim();
|
|
if (newCommits) {
|
|
const oldest = newCommits.split("\n")[0];
|
|
const parent = git(["rev-parse", "--verify", `${oldest}^`]).trim();
|
|
if (parent) return parent;
|
|
// Oldest new commit is a root commit: there is no parent to anchor on.
|
|
}
|
|
|
|
// 3. Nothing to anchor on — a genuinely fresh repository with no remote refs.
|
|
// Every commit IS new content, so scanning it all is the correct answer.
|
|
return null;
|
|
}
|
|
|
|
/** 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) || !objectExists(remoteSha)) {
|
|
// Either a new branch (zero remote sha), or the remote tip object is absent
|
|
// locally (shallow clone, force-push without a prior fetch, CI checkout) so
|
|
// remote..local cannot resolve. Both need a base derived locally; scan MORE
|
|
// rather than hard-blocking a legitimate push (adversarial review finding 8).
|
|
const base = unknownRemoteTipBase(localSha);
|
|
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 '+'.
|
|
// Strict (#1946): a failed diff used to return "" and the push sailed
|
|
// through unscanned — fail open on the exact path the guard exists for.
|
|
//
|
|
// --no-ext-diff: a user's `diff.external` driver replaces the entire diff
|
|
// with its own output — with one set, `git diff` emits zero '+' lines, so an
|
|
// unhardened scanner reads an empty diff and exits 0 on a push full of
|
|
// secrets. Reachable from ordinary user config, not hypothetical. (#2498)
|
|
// --no-textconv: a .gitattributes textconv driver can likewise rewrite
|
|
// content before we ever see it. (#2498)
|
|
const diff = gitStrict([
|
|
"diff", "--unified=0", "--no-color", "--no-ext-diff", "--no-textconv",
|
|
range,
|
|
]);
|
|
const added: string[] = [];
|
|
// Hunk-aware header skip (#2498): `+++ ` is only a FILE HEADER outside a
|
|
// hunk. Inside a hunk, an added content line whose text begins with "++"
|
|
// renders as "+++<content>" — the old blanket startsWith("+++") skip
|
|
// silently dropped exactly those lines from the scan.
|
|
let inHunk = false;
|
|
for (const line of diff.split("\n")) {
|
|
if (line.startsWith("diff --git")) { inHunk = false; continue; }
|
|
if (line.startsWith("@@")) { inHunk = true; continue; }
|
|
if (!inHunk && (line.startsWith("+++") || line.startsWith("---"))) continue;
|
|
if (line.startsWith("+")) added.push(line.slice(1));
|
|
}
|
|
return added.join("\n");
|
|
}
|
|
|
|
/**
|
|
* Byte budget per scan() call. Kept comfortably under redact-engine's
|
|
* DEFAULT_MAX_BYTES (1 MiB) so a slice never trips its oversize guard.
|
|
*/
|
|
const SCAN_CHUNK_BYTES = 768 * 1024;
|
|
|
|
/**
|
|
* Scan added lines in line-aligned slices, unioning the findings.
|
|
*
|
|
* Why: the engine refuses input over its byte cap and fails closed, which is
|
|
* right for one scan() call but wrong as a push policy — a feature branch
|
|
* catching up to a busy main legitimately produces more added lines than the
|
|
* cap (1,146,782 bytes against the 1 MiB default in the push that prompted
|
|
* this, and only ~7% of that was the lockfile). The push then blocked on
|
|
* `engine.input_too_large` — a size error naming no credential — which trains
|
|
* people to reach for --no-verify, defeating the guardrail far more thoroughly
|
|
* than a large diff does.
|
|
*
|
|
* Slicing loses NO detection coverage, because every pattern is single-line:
|
|
* none in redact-patterns.ts carries the `m` or `s` flag, the
|
|
* BEGIN-PRIVATE-KEY patterns capture only the header line rather than the key
|
|
* body, and the engine itself iterates line by line. A line boundary therefore
|
|
* cannot bisect a detectable secret, so no inter-slice overlap is needed.
|
|
*
|
|
* Fail-closed is preserved: a SINGLE line over the budget is still passed to
|
|
* the engine intact, so a genuinely unscannable blob (minified bundle,
|
|
* embedded base64) trips input_too_large and blocks exactly as before.
|
|
*
|
|
* Findings' line/col are slice-relative, which is fine here — this hook only
|
|
* reads severity, id and preview. Do not lift this into the engine, where
|
|
* callers rely on absolute line numbers.
|
|
*/
|
|
function scanAddedLines(added: string, opts: Parameters<typeof scan>[1]): Finding[] {
|
|
const findings: Finding[] = [];
|
|
let slice: string[] = [];
|
|
let sliceBytes = 0;
|
|
|
|
const flush = () => {
|
|
if (slice.length === 0) return;
|
|
findings.push(...scan(slice.join("\n"), opts).findings);
|
|
slice = [];
|
|
sliceBytes = 0;
|
|
};
|
|
|
|
for (const line of added.split("\n")) {
|
|
// +1 for the newline that rejoins it.
|
|
const lineBytes = Buffer.byteLength(line, "utf8") + 1;
|
|
// Close the current slice BEFORE overflowing it. A single oversized line
|
|
// lands in a slice of its own and is handed to the engine as-is.
|
|
if (sliceBytes > 0 && sliceBytes + lineBytes > SCAN_CHUNK_BYTES) flush();
|
|
slice.push(line);
|
|
sliceBytes += lineBytes;
|
|
}
|
|
flush();
|
|
|
|
return findings;
|
|
}
|
|
|
|
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 fields of refs) {
|
|
// Fail CLOSED on a ref line we cannot parse (#2498): git hands pre-push
|
|
// exactly "<local ref> <local sha> <remote ref> <remote sha>" — anything
|
|
// else means we cannot tell WHAT is being pushed, and silently skipping
|
|
// it would leave that ref unscanned.
|
|
const [, localSha, , remoteSha] = fields;
|
|
const shaShaped = (s: string | undefined) => !!s && /^[0-9a-f]{40,64}$/i.test(s);
|
|
if (fields.length !== 4 || !shaShaped(localSha) || !shaShaped(remoteSha)) {
|
|
process.stderr.write(
|
|
"\n⛔ gstack-redact-prepush BLOCKED the push — could not parse a pre-push ref line, " +
|
|
"so its content cannot be scanned.\n" +
|
|
` line: ${JSON.stringify(fields.join(" "))}\n` +
|
|
"Bypass if you're sure: GSTACK_REDACT_PREPUSH=skip git push (or git push --no-verify)\n",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
if (ZERO.test(localSha!)) continue; // branch delete → nothing pushed
|
|
let added: string;
|
|
try {
|
|
added = addedLinesFor(localSha, remoteSha || "0");
|
|
} catch (err) {
|
|
// Fail CLOSED (#1946): if we can't compute the pushed diff we can't
|
|
// scan it, and unscanned-but-allowed is the failure mode this hook
|
|
// exists to prevent.
|
|
process.stderr.write(
|
|
"\n⛔ gstack-redact-prepush BLOCKED the push — could not compute the pushed diff, " +
|
|
"so it cannot be scanned for credentials.\n" +
|
|
` (${err instanceof Error ? err.message.split("\n")[0] : String(err)})\n` +
|
|
"Bypass if you're sure: GSTACK_REDACT_PREPUSH=skip git push (or git push --no-verify)\n",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
if (!added.trim()) continue;
|
|
// Visibility doesn't change HIGH behavior; pass private so nothing is treated
|
|
// as public-strict (HIGH blocks regardless either way).
|
|
// Sliced (see scanAddedLines) so a large-but-legitimate diff is actually
|
|
// scanned rather than blocked unscanned on the engine's size cap.
|
|
for (const f of scanAddedLines(added, { repoVisibility: "private" })) {
|
|
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) {
|
|
// A scan that could not RUN is not a scan that FOUND something. Reporting
|
|
// "credential(s) in the pushed diff — rotate the credential" for an
|
|
// `engine.*` finding tells the operator to rotate a secret that was never
|
|
// detected, on a diff that was never read. Blocking is still right (fail
|
|
// closed), but the reason must be the true one: a guardrail that cries wolf
|
|
// is a guardrail that gets bypassed by reflex, which is worse than none.
|
|
// Seen live 2026-07-30: a diff of a few hundred bytes reported HIGH
|
|
// engine.input_too_large, because an unresolvable base branch made the hook
|
|
// fall back to EMPTY_TREE..local — i.e. the WHOLE repo (~7 MiB) as "added
|
|
// lines". The size the operator sees and the size the hook measures can
|
|
// therefore differ by four orders of magnitude.
|
|
const unscanned = allHigh.filter((f) => f.id.startsWith("engine."));
|
|
const secrets = allHigh.filter((f) => !f.id.startsWith("engine."));
|
|
|
|
if (secrets.length > 0) {
|
|
process.stderr.write(
|
|
"\n⛔ gstack-redact-prepush BLOCKED the push — credential(s) in the pushed diff:\n\n",
|
|
);
|
|
for (const f of secrets) {
|
|
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",
|
|
);
|
|
}
|
|
|
|
if (unscanned.length > 0) {
|
|
process.stderr.write(
|
|
"\n⛔ gstack-redact-prepush BLOCKED the push — the diff could NOT be scanned.\n" +
|
|
" No credential was found; none was looked for. Blocking fail-closed.\n\n",
|
|
);
|
|
for (const f of unscanned) {
|
|
process.stderr.write(` ${f.id}: ${f.description}\n`);
|
|
}
|
|
process.stderr.write(
|
|
"\nLikely cause: the base branch could not be resolved, so the whole repo was\n" +
|
|
"treated as added lines. Check `git rev-parse --abbrev-ref origin/HEAD` and\n" +
|
|
"`git merge-base HEAD origin/main`, then push again. Scan the diff yourself\n" +
|
|
"before bypassing: `git diff <base>..HEAD | grep -inE \'password|secret|token|api.?key\'`.\n",
|
|
);
|
|
}
|
|
|
|
process.stderr.write(
|
|
"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();
|