Files
gstack/bin/gstack-redact-prepush
T
Lubos BuracinskyandClaude Opus 5 11734707df v1.87.7.0 fix(redact-prepush): four paths where the hook exits 0 on a real credential
Four paths made the pre-push credential scanner exit 0 with the secret going
out anyway, and two adjacent defects in the same functions had to land with
them.

Range resolution: defaultRemoteBranch() asked origin regardless of the push
target, so pushing to a second remote while HEAD matched origin/main resolved
HEAD..HEAD and scanned nothing; and a well-shaped but absent remote sha let a
guessed base's empty diff read as "nothing to scan". The probe is now scoped to
the push target and a guess that scanned nothing blocks with a fetch hint.

Slicing: the no-overlap argument holds for a pattern's match but not for its
proximity requirement, so a label at the end of one slice and its secret at the
start of the next never fired; and budgeting in raw bytes let zero-width
padding decide the seam using bytes the engine strips before matching. Slices
now overlap by 16 KiB and are budgeted in zero-width-stripped bytes.

Adjacent: the fallback range's hardcoded SHA-1 empty-tree id does not exist in
a SHA-256 repository and hard-blocked every legitimate first push there, which
the remote scoping makes reachable more often; and an over-budget single line
was handed to the engine whole, blocking without the content ever being read.

test/redact-prepush-fail-open.sh is the gate: 26 scenarios against real
repositories with synthetic credentials, PASS here and FAIL on the four rows
against the scanner this branch forks from.

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

703 lines
32 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;
}
/**
* The remote this push targets, as git hands it to pre-push in $1 (and its URL
* as $2); the installed hook wrapper forwards "$@". Null when the name is
* unavailable (stdin/CLI invocation) or does not name a CONFIGURED remote — a
* URL push has no remote-tracking namespace at all — and every caller then
* falls back to the historical origin-shaped behavior.
*/
let _pushRemote: string | null | undefined;
function pushRemote(): string | null {
if (_pushRemote === undefined) {
const name = process.argv[2];
const configured = name
? git(["remote"]).split("\n").map((s) => s.trim()).filter(Boolean).includes(name)
: false;
_pushRemote = configured ? name! : null;
}
// `?? null` only narrows away the not-yet-computed sentinel; by here the
// cache is always set, and null legitimately means "no tracking namespace".
return _pushRemote ?? 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". Falling back to the bare form scans
* LESS than the narrowed form would, but never less than the hook historically
* did.
*/
function remotesExclusion(): string {
const name = pushRemote();
return name ? `--remotes=${name}/*` : "--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.
*
* remotesExclusion() 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 remote = pushRemote() ?? "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
// remotesExclusion): 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, "--not", remotesExclusion()]).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 remotesExclusion): 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;
const narrowed = git(["rev-list", localSha, "--not", remoteSha, remotesExclusion()]).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);
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 the trailing lines worth up to one overlap.
const tail: string[] = [];
let tailBytes = 0;
for (let i = slice.length - 1; i >= 0; i--) {
const b = Buffer.byteLength(slice[i]!, "utf8") + 1;
if (tailBytes + b > SCAN_OVERLAP_BYTES) break;
tail.unshift(slice[i]!);
tailBytes += b;
}
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();