#!/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"; import { mkdirpSync } from "../lib/fs-utils"; const ZERO = /^0+$/; // The canonical empty-tree object under SHA-1; diffing against it yields all // content as added. Only a FALLBACK — see emptyTree(). const EMPTY_TREE_SHA1 = "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 ?? ""; } /** * The empty-tree OID for THIS repository's hash algorithm. * * The SHA-1 constant does not exist in a SHA-256 repository, so the fallback * range `..local` makes git error out, gitStrict throws, and * every legitimate first push of a new branch is hard-blocked with a diff * error. Asking git to hash the empty tree returns the right OID under either * algorithm and needs no table of constants. */ let _emptyTree: string | undefined; function emptyTree(): string { if (_emptyTree === undefined) { // --stdin with empty input rather than /dev/null: portable to Windows. const r = spawnSync("git", ["hash-object", "-t", "tree", "--stdin"], { encoding: "utf8", input: "", }); const oid = r.status === 0 ? (r.stdout ?? "").trim() : ""; _emptyTree = /^[0-9a-f]{40,64}$/i.test(oid) ? oid : EMPTY_TREE_SHA1; } return _emptyTree; } /** 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; } /** * What git told us about the push target, as three distinct cases. The * distinction matters: "we were not told" and "we were told, and it has no * tracking namespace" must not be collapsed, because only the first one can * safely fall back to origin-shaped behavior. * * { kind: "remote", name } $1 names a CONFIGURED remote. * { kind: "url" } $1 was given but is not a configured remote — * a URL push. There is no / to * anchor on, for ANY remote. * { kind: "unknown" } $1 absent (stdin / CLI invocation). */ type PushTarget = { kind: "remote"; name: string } | { kind: "url" } | { kind: "unknown" }; let _pushTarget: PushTarget | undefined; function pushTarget(): PushTarget { if (_pushTarget === undefined) { const name = process.argv[2]; if (!name) { _pushTarget = { kind: "unknown" }; } else { const configured = git(["remote"]).split("\n").map((s) => s.trim()).filter(Boolean).includes(name); _pushTarget = configured ? { kind: "remote", name } : { kind: "url" }; } } return _pushTarget; } /** The configured remote this push targets, or null for a URL/unknown target. */ function pushRemote(): string | null { const t = pushTarget(); return t.kind === "remote" ? t.name : null; } /** * The remote-tracking exclusion used when narrowing to "commits new to the * remote" (#2592 catch-up merges, #2573 rebased force-pushes). * * Narrowed to the PUSH TARGET's namespace (S1): a bare `--remotes` excludes * commits reachable from ANY remote-tracking ref, so a secret that had only * ever been fetched from (or pushed to) a private/local-path remote was never * scanned when later pushed to a PUBLIC remote — "already left this machine" * is not "already reached THIS remote". * * ⚠ A URL PUSH EXCLUDES NOTHING. * * The bare `--remotes` fallback was a fail-open for a URL target, measured: the * credential sat on origin/main, the local commit added only harmless content, * and a first push to a different empty URL remote had origin/main excluded as * "already on the remote" — so rev-list reported only the harmless commit as * new, the range anchored at its parent, and the credential shipped with exit 0. * A URL we were handed is not described by ANY remote-tracking ref, so there is * nothing legitimately excludable: every commit reachable from the pushed tip * counts as new, and the range is anchored below all of them. * * An UNKNOWN target (no argv, stdin/CLI invocation) keeps the historical bare * form: we were told nothing, so we cannot single out a namespace. * * Returns the rev-list arguments, empty when nothing may be excluded — the * callers must not hand `--not` an empty operand. */ function remotesExclusionArgs(): string[] { const t = pushTarget(); if (t.kind === "remote") return ["--not", `--remotes=${t.name}/*`]; if (t.kind === "url") return []; return ["--not", "--remotes"]; } /** * The push target's default branch, or null when it has none we can resolve. * * ⚠ SCOPED TO THE PUSH TARGET, NOT HARDCODED TO origin. * * remotesExclusionArgs() above already reasons this way for the rev-list exclusion, * but this probe kept asking origin regardless of where the push was going, and * the two together are what pick the scan range. The consequence was a * fail-OPEN, measured: with HEAD == origin/main, `git push publish HEAD:main` * to a brand-new second remote resolved a merge-base of HEAD against * origin/main, producing the range HEAD..HEAD — an empty diff — so the entire * tree, credentials included, shipped to an unknown (possibly public) remote * with exit 0 and nothing scanned. That is the exact moment a scan matters most. * * Returning null when the target remote has no resolvable default branch drops * the caller through to the rev-list step, which is already scoped to the push * target and correctly reports "all of this is new" for a never-fetched remote. * Scanning more is the safe direction here and matches the S1 reasoning above. */ function defaultRemoteBranch(): string | null { const t = pushTarget(); // ⚠ A URL PUSH HAS NO ANCHOR — DO NOT BORROW origin's. // // Falling back to origin here was itself a fail-open, measured: with the // credential already on origin/main and the local commit adding only harmless // content, the first push to a different, empty URL remote resolved the range // merge-base(HEAD, origin/main)..HEAD, scanned the harmless commit, exited 0, // and sent the whole history — credential included — to that remote. origin's // tip says nothing about what a DIFFERENT remote already has. if (t.kind === "url") return null; const remote = t.kind === "remote" ? t.name : "origin"; // /HEAD → /main, fall back to main/master. const sym = git(["symbolic-ref", `refs/remotes/${remote}/HEAD`]).trim(); if (sym) return sym.replace("refs/remotes/", ""); for (const b of [`${remote}/main`, `${remote}/master`]) { if (git(["rev-parse", "--verify", b]).trim()) return b; } return null; } /** * 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. Null // means the PUSH TARGET has no default branch we can see (never fetched), // in which case there is nothing legitimate to anchor on — fall through. const def = defaultRemoteBranch(); const base = def ? git(["merge-base", localSha, def]).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". // // The exclusion is scoped to the PUSH TARGET's tracking refs (see // remotesExclusionArgs): content on some OTHER remote has left this machine, // but it has not reached the remote being pushed to — a secret that only // ever hit a private remote must still be scanned on its way to a public // one (S1). const newCommits = git(["rev-list", "--reverse", localSha, ...remotesExclusionArgs()]).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; } /** * The commits this push actually adds — reachable from localSha and from NO * remote-tracking ref. * * ⚠ WHY THIS EXISTS RATHER THAN A TWO-DOT RANGE. * * `remoteSha..localSha` is "everything new on this branch", which is NOT the * same as "everything new to the remote". Merge origin/main into a feature * branch and every commit main gained since the branch's last push becomes an * added line — content that is already published, already scanned, and not * this push's doing. * * Two things follow, and both were observed: * * · FALSE HIGH FINDINGS. A placeholder connection string in a test fixture, * already merged to main by someone else, blocked an unrelated push as * `db.url_with_password` — telling the operator to rotate a credential * over a fixture they had never touched. A * guard that cries wolf on catch-up merges is a guard people learn to * bypass reflexively — which is exactly how a real secret gets through. * · OVERSIZED SCANS. The comment on SCAN_CHUNK_BYTES below records a * 1,146,782-byte diff from "a feature branch catching up to a busy main" * blowing the engine's 1 MiB cap. Same root cause, treated there as a size * problem and solved by slicing. Narrowing the range fixes the size too. * * A two-dot range cannot express this: after merging main, neither the remote * tip nor the merge-base with main is an ancestor of the other, so no single * base excludes both. `rev-list --not --remotes=/*` is the * operation that does, and this file already reasons that way in * `unknownRemoteTipBase` step 2. The exclusion is scoped to the push target's * tracking namespace (see remotesExclusionArgs): the upstream commits a catch-up * merge brings in came from the SAME remote being pushed to, so scoping keeps * the #2592 fix intact while a secret known only to some OTHER (private) * remote is still scanned on its way to this one (S1). * * Each commit is diffed alone. `--cc` on a merge shows only the conflict * RESOLUTION — content that exists in no parent — so a secret introduced while * resolving a merge is still caught, while an ordinary merge contributes * nothing. Returns null when the notion does not apply, so callers fall back. */ function addedLinesFromNewCommits(localSha: string, remoteSha: string): string | null { // remoteSha is what git TELLS us the remote has, and it is authoritative in a // way `--remotes` is not: remote-tracking refs can be absent (a fresh clone // that never fetched, a push to a remote with no tracking ref) or stale. Drop // it and a repo with no tracking refs excludes NOTHING — every commit ever // made reads as "new", which re-introduces the false positives from the other // direction. So it stays the base; `--remotes` only ADDS exclusions on top. if (ZERO.test(remoteSha) || !objectExists(remoteSha)) return null; // `--not remoteSha` always applies; the remote-tracking exclusion is added // only when one is legitimate for this target (see remotesExclusionArgs). const extra = remotesExclusionArgs().filter((a) => a !== "--not"); const narrowed = git(["rev-list", localSha, "--not", remoteSha, ...extra]).trim(); if (!narrowed) return null; // If excluding remote-tracking refs changes nothing, this push has no // catch-up commits and the plain range already describes it exactly. Defer to // it. That is not just an optimization: it keeps every push that ISN'T a // catch-up merge on the original gitStrict diff path, so the fail-closed // guarantee (#1946) and its regression test keep exercising the code they // were written for. A narrowing that silently retired that test would be a // worse trade than the false positives it set out to fix. const plain = git(["rev-list", `${remoteSha}..${localSha}`]).trim(); const asSet = (s: string) => s.split("\n").filter(Boolean).sort().join("\n"); if (asSet(narrowed) === asSet(plain)) return null; const shas = narrowed.split("\n").filter(Boolean); // A rewrite of long history should fall back rather than shell out per commit. if (shas.length > 500) return null; const out: string[] = []; for (const sha of shas) { // gitStrict: a failed diff must never read as "nothing added" (#1946). out.push(gitStrict([ "show", "--unified=0", "--no-color", "--no-ext-diff", "--no-textconv", "--cc", "--format=", sha, ])); } return out.join("\n"); } /** Return the added-line text for a ref update being pushed. */ function addedLinesFor(localSha: string, remoteSha: string): string { // Preferred ONLY when this push carries catch-up commits: scanning them again // is the bug. Every other shape falls through to the range logic below. const fromNew = addedLinesFromNewCommits(localSha, remoteSha); if (fromNew !== null) return collectAddedLines(fromNew); let range: string; // Set when git NAMED a remote tip we do not have. Any base we derive locally // is then a guess about content we cannot see — see the empty-result check // at the end of this function. let guessedFromUnresolvableTip = false; 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). guessedFromUnresolvableTip = !ZERO.test(remoteSha); if (guessedFromUnresolvableTip) { // ⚠ AN ABSENT NAMED TIP INVALIDATES OUR TRACKING REFS FOR THIS REF. // // git told us the remote is at a sha we do not have, which is itself // proof that our remote-tracking refs do not describe this ref — so // narrowing by them is not conservative, it is a guess. Blocking only // the EMPTY guess was not enough: measured, a stale origin/main holding // the credential plus one harmless local commit produced a NON-empty, // credential-free diff, the guard passed, and the force-push republished // the credential with exit 0. A guess that scanned something is still a // guess about the wrong thing. // // So this shape gets no local narrowing at all: the range is // emptyTree..localSha, i.e. the full CONTENT of the pushed tip is scanned. // That is affordable now only because every scan is sliced under the // engine's cap (see scanAddedLines), so a whole-tree range can no longer // degrade into engine.input_too_large — which is exactly why the older // code avoided this path. // // Note what this is not: a `git diff` range is a NET diff, so it scans the // final tree, not each commit. A credential added in one commit and // removed in a later one within the same push is not in that diff at all, // on this path and on every other range this hook computes. Closing that // means scanning each commit's own diff — a separate change with its own // cost profile, not part of this one. // // A NEW BRANCH (zero remote sha) is deliberately NOT treated this way: // there git named no tip, our tracking refs are not contradicted, and // narrowing keeps the first push of a feature branch proportionate. range = `${emptyTree()}..${localSha}`; } else { const base = unknownRemoteTipBase(localSha); range = base ? `${base}..${localSha}` : `${emptyTree()}..${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 = collectAddedLines(diff); // ⚠ A GUESS THAT SCANNED NOTHING IS NOT EVIDENCE OF NOTHING. // // git told us the remote is at a sha we do not have, which is itself proof // that our remote-tracking refs do not describe this ref. Every base above is // then a guess, and when the guess yields an EMPTY diff we have not concluded // "this push adds nothing" — we have concluded nothing at all, while git is // about to send whatever the real difference is. // // Measured fail-open: a four-field ref line with a valid local sha and a // well-shaped but absent remote sha, on a repo where HEAD == origin/main, // resolved to HEAD..HEAD and allowed a push carrying a live credential with // exit 0. The shape check in main() rejects junk like "not-a-sha"; it cannot // reject 40 plausible hex digits. // // Narrow by construction: the legitimate cases this path exists for (shallow // clone, CI checkout, force-push without a prior fetch) all produce a // NON-empty range and are untouched. Only "we guessed and saw nothing" blocks, // and it blocks with the fix in the message: fetch the remote. if (guessedFromUnresolvableTip && !added.trim()) { throw new Error( `remote tip ${remoteSha.slice(0, 12)} is not in the local object database and no ` + "local base narrowed this push, so nothing was scanned — run `git fetch` for this " + "remote and push again", ); } return added; } /** * Added-line text from a unified diff. Shared by both range strategies so the * hunk-aware header handling below cannot drift between them. */ function collectAddedLines(diff: string): string { 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 "+++" — the old blanket startsWith("+++") skip // silently dropped exactly those lines from the scan. let inHunk = false; for (const line of diff.split("\n")) { // `diff --` rather than `diff --git`: a merge scanned with --cc emits // `diff --cc `, so a --git-only reset left inHunk true across file // boundaries and read the next file's `+++ b/...` header as content. Only // noise (it over-scans, never under-scans), but the boundary is real. if (line.startsWith("diff --")) { 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; /** * Bytes replayed from the end of one slice into the start of the next. * * ⚠ WHY SLICING WITHOUT OVERLAP IS NOT SAFE. * * The reasoning this replaced said overlap was unnecessary because "every * pattern is single-line, so a line boundary cannot bisect a detectable * secret". True of the MATCH, false of the PROXIMITY requirement: five * patterns in redact-patterns.ts carry a `nearRegex` and only fire when their * qualifying label appears within `nearWindow` characters — aws.secret_key * (HIGH) needs `aws_secret_access_key` within 100, gcp.service_account (HIGH) * needs `"private_key_id"` within 300. Put the label at the end of one slice * and the secret at the start of the next and the pattern never fires at all. * * Measured, not hypothetical: a 40-char AWS secret with its label straddling * the cut produced ZERO HIGH findings and exit 0 — the guard allowed a push it * would have blocked had the same bytes sat 41 bytes earlier. * * 16 KiB is ~55x the largest nearWindow, so the qualifying context always * survives the seam with room to spare. */ const SCAN_OVERLAP_BYTES = 16 * 1024; /** * Per-line budget. Kept a full overlap below the slice budget so that * `carried overlap + one unit` can never exceed SCAN_CHUNK_BYTES, which keeps * every scan() call provably under the engine's 1 MiB cap. */ const LINE_BUDGET_BYTES = SCAN_CHUNK_BYTES - SCAN_OVERLAP_BYTES; /** * Zero-width characters, matching redact-engine's ZERO_WIDTH set exactly. * * The engine strips these before matching, so they cost bytes here while * contributing nothing to the text a pattern actually sees. Budgeting slices in * RAW bytes therefore lets invisible padding decide where the seam falls: * ~900 KB of U+200B between a label and its secret pushed them into different * slices while their NORMALIZED distance stayed a couple of characters, and the * push went out with exit 0. Stripping on ingest makes this file's budget and * overlap measure the same thing the engine measures. * * Finding-preserving by construction — the engine would have removed exactly * these characters anyway, so no match is created or destroyed. */ const ZERO_WIDTH = /[\u200B\u200C\u200D\u2060\uFEFF]/g; /** Never cut between the halves of a surrogate pair. */ function safeCut(s: string, i: number): number { if (i <= 0 || i >= s.length) return i; const prev = s.charCodeAt(i - 1); return prev >= 0xd800 && prev <= 0xdbff ? i - 1 : i; } /** * Cut one over-budget line into byte-bounded pieces that overlap. * * A minified bundle or a single-line JSON blob is one "line" of megabytes. Handed * to the engine whole it trips input_too_large: the push is blocked, which is * safe, but blocked WITHOUT the content ever being read — so the operator is * told a size error where a credential may be sitting, and learns to bypass. * Slicing with overlap reads it for real; the 2 MiB one-liner that used to * report `engine.input_too_large` now names the aws.access_key inside it. */ function sliceLongLine(line: string, budget: number, overlap: number): string[] { const pieces: string[] = []; let start = 0; while (start < line.length) { // One char is never fewer than one byte, so `budget` chars is never short // of the byte budget; shrink proportionally until it fits. let end = Math.min(line.length, start + budget); let bytes = Buffer.byteLength(line.slice(start, end), "utf8"); while (end > start + 1 && bytes > budget) { const scaled = start + Math.max(1, Math.floor((end - start) * (budget / bytes))); end = scaled < end ? scaled : end - 1; bytes = Buffer.byteLength(line.slice(start, end), "utf8"); } end = Math.max(start + 1, safeCut(line, end)); pieces.push(line.slice(start, end)); if (end >= line.length) break; // Step back by the overlap so a secret sitting on the cut — and the label // that qualifies it — land together in the next piece. let back = 0; let backBytes = 0; while (end - back - 1 > start && backBytes < overlap) { backBytes += Buffer.byteLength(line[end - back - 1]!, "utf8"); back++; } // start strictly increases: the loop guarantees end - back > start. start = Math.max(start + 1, safeCut(line, end - back)); } return pieces; } /** * 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. * * Slices overlap by SCAN_OVERLAP_BYTES and are budgeted in zero-width-stripped * bytes, so a seam cannot separate a proximity-qualified secret from its label; * see those two constants for the fail-open each one closes. * * 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[1]): Finding[] { const findings: Finding[] = []; // Overlap deliberately re-scans the seam, so one occurrence can surface // twice. Dedup on id+preview: line/col are slice-relative and cannot identify // an occurrence. Worst case two DIFFERENT occurrences share a preview and are // counted once — that lowers a count, never a block. const seen = new Set(); let slice: string[] = []; let sliceBytes = 0; const flush = (carryOverlap: boolean) => { if (slice.length === 0) return; for (const f of scan(slice.join("\n"), opts).findings) { const key = `${f.id}\u0000${f.preview}`; if (seen.has(key)) continue; seen.add(key); findings.push(f); } if (!carryOverlap) { slice = []; sliceBytes = 0; return; } // Re-seed the next slice with up to one overlap's worth of trailing content. // // ⚠ FILL THE OVERLAP, DO NOT MERELY COLLECT WHOLE LINES. // // Walk back from the seam taking whole lines while they fit, and when one // does not, take as much of ITS END as the remaining budget allows. Carrying // whole lines only lets a line longer than the overlap contribute nothing, so // a label at its end never met the secret after the seam. Carrying that // suffix only when the tail was otherwise empty was not enough either: a // short line between the long label line and the secret landed in the tail // first, the tail was no longer empty, and the label was dropped again // (measured, gate rows 19 and 21). Budget is exact: a character is added only // if it still fits, so `overlap + unit <= SCAN_CHUNK_BYTES` keeps holding. const tail: string[] = []; let tailBytes = 0; for (let i = slice.length - 1; i >= 0; i--) { const line = slice[i]!; const b = Buffer.byteLength(line, "utf8") + 1; if (tailBytes + b <= SCAN_OVERLAP_BYTES) { tail.unshift(line); tailBytes += b; continue; } // One byte of the remaining room is the newline that rejoins the piece. const room = SCAN_OVERLAP_BYTES - tailBytes - 1; let back = 0; let backBytes = 0; while (back < line.length) { const ch = Buffer.byteLength(line[line.length - back - 1]!, "utf8"); if (backBytes + ch > room) break; backBytes += ch; back++; } const suffix = line.slice(safeCut(line, line.length - back)); if (suffix) { tail.unshift(suffix); tailBytes += Buffer.byteLength(suffix, "utf8") + 1; } break; } slice = tail; sliceBytes = tailBytes; }; // unitBytes counts the newline that rejoins the unit. const push = (unit: string, unitBytes: number) => { // Close the current slice BEFORE overflowing it, carrying the overlap. if (sliceBytes > 0 && sliceBytes + unitBytes > SCAN_CHUNK_BYTES) flush(true); slice.push(unit); sliceBytes += unitBytes; }; for (const raw of added.split("\n")) { const line = raw.replace(ZERO_WIDTH, ""); const lineBytes = Buffer.byteLength(line, "utf8") + 1; if (lineBytes > LINE_BUDGET_BYTES) { // An over-budget line cannot share a slice with anything else. Close the // current slice first (carrying its tail so context leading INTO the long // line survives), then feed the pieces one slice at a time. flush(true); for (const piece of sliceLongLine(line, LINE_BUDGET_BYTES - 1, SCAN_OVERLAP_BYTES)) { push(piece, Buffer.byteLength(piece, "utf8") + 1); flush(true); } continue; } push(line, lineBytes); } flush(false); 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"); // mkdirpSync, not bare mkdirSync: bun-on-Windows EEXIST (#2635). This site // is try-wrapped by the caller, so the old failure was a silent skip-log // loss rather than a crash — the fix makes the log survive, not un-crash. mkdirpSync(dir); 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 " " — 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 ..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();