Files
gstack/bin/gstack-redact-prepush
T
Lubos BuracinskyandClaude Opus 5 c3c013da72 fix(redact-prepush): fill the slice overlap to its budget, not only when empty
The overlap carried a long line's suffix only when the tail was otherwise
empty. One 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, so aws.secret_key never fired. The overlap is now filled from the
seam backwards to its exact budget, taking a partial line wherever a whole
one no longer fits; a character is added only if it still fits, so the
slice budget invariant keeps holding.

Gate rows 21 (one short line) and 22 (several) pin it; both fail on the
scanner this branch forks from.

The absent-tip comment claimed the range scans everything reachable. It
scans the net diff of emptyTree..localSha, i.e. the final tree, not each
commit, and now says so, including that a credential added and later
removed inside one push is not in any diff this hook computes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 18:38:55 +02:00

808 lines
37 KiB
TypeScript
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";
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 `<sha1-empty-tree>..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 <remote>/<branch> 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";
// <remote>/HEAD → <remote>/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=<push-remote>/*` 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 "+++<content>" — 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 <path>`, 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<typeof scan>[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<string>();
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 "<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();