#!/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), one line per finding naming its rule, * file line and fix, never the matched value. LOW/WARN → silent. * - `pii.email` skips the pusher's own address, `gstack.redact.allowEmail` * entries, and addresses already in the destination's commit metadata * (see emailScanOptions). * - `.gstack-redact-allowlist`, read from each pushed commit (never the * working tree), lists exact matched spans reviewed as benign, one per * line, `#` comments. Only a finding whose matched text equals an entry is * suppressed; every push that carries entries lists what they suppressed. * - 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 path from "path"; import { normalizeWithMap, scan, type Finding, type ScanOptions } from "../lib/redact-engine"; import { mkdirpSync } from "../lib/fs-utils"; import { resolveStateRoot } from "../lib/state-root"; const ZERO = /^0+$/; let emptyTree: string | undefined; function emptyTreeOid(): string { if (emptyTree) return emptyTree; const oid = gitStrict(["hash-object", "-w", "-t", "tree", "--stdin"]).trim(); if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(oid)) throw new Error("git could not resolve the empty tree"); emptyTree = oid; return oid; } /** * Permissive git for legitimately-fallible PROBES (symbolic-ref, rev-parse, * merge-base) where a non-zero exit is normal control flow. The DIFF call * must NOT use this — see gitStrict (#1946 fail-closed). */ function git(args: string[]): string { const r = spawnSync("git", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); return r.status === 0 ? (r.stdout ?? "") : ""; } /** * Fail-closed git for the diff that decides whether the push is scanned * (#1946). status !== 0 covers repo errors; status === null covers a killed * process AND maxBuffer overflow — the oversized-diff case is exactly where * a large secret-bearing blob is most likely, so "couldn't read the diff" * must block, not silently allow. */ function gitStrict(args: string[]): string { const r = spawnSync("git", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); // status !== 0 covers BOTH a non-zero exit AND null (process killed by a // signal or maxBuffer overflow — null !== 0 is true). if (r.status !== 0) { throw new Error( `git ${args[0]} failed (status=${r.status ?? "killed/overflow"}): ${(r.stderr ?? "").slice(0, 300)}`, ); } return r.stdout ?? ""; } /** True when the object exists in the local odb (cat-file -e signals via exit code). */ function objectExists(sha: string): boolean { const r = spawnSync("git", ["cat-file", "-e", sha], { encoding: "utf8" }); return r.status === 0; } /** * 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". Git hands pre-push the push remote's * name as $1 (and its URL as $2); the installed hook wrapper forwards "$@". * A URL push has no tracking namespace, even if origin happens to contain * the same commits. Direct invocations without Git's argv keep the historical * all-remotes behavior for compatibility. */ type PushTarget = { kind: "remote"; name: string } | { kind: "url" } | { kind: "unknown" }; let cachedPushTarget: PushTarget | undefined; function pushTarget(): PushTarget { if (cachedPushTarget) return cachedPushTarget; const name = process.argv[2]; const url = process.argv[3]; if (!name && !url) return cachedPushTarget = { kind: "unknown" }; if (!name || !url) return cachedPushTarget = { kind: "url" }; const remotes = git(["remote"]).split("\n"); if (!remotes.includes(name)) return cachedPushTarget = { kind: "url" }; const pushUrls = git(["remote", "get-url", "--push", "--all", name]).trim().split("\n"); const fetchUrls = git(["remote", "get-url", "--all", name]).trim().split("\n"); return cachedPushTarget = pushUrls.includes(url) && fetchUrls[0] === url ? { kind: "remote", name } : { kind: "url" }; } function remotesExclusionArgs(): string[] { const target = pushTarget(); if (target.kind === "remote") return [`--remotes=${target.name}/*`]; return target.kind === "unknown" ? ["--remotes"] : []; } function defaultRemoteBranch(): string | null { const target = pushTarget(); if (target.kind === "url") return null; const remote = target.kind === "remote" ? target.name : "origin"; const sym = git(["symbolic-ref", `refs/remotes/${remote}/HEAD`]).trim(); if (sym.startsWith(`refs/remotes/${remote}/`) && git(["rev-parse", "--verify", `${sym}^{commit}`]).trim()) return sym; for (const b of [`${remote}/main`, `${remote}/master`]) { const qualified = `refs/remotes/${b}`; if (git(["rev-parse", "--verify", `${qualified}^{commit}`]).trim()) return qualified; } return null; } /** * Base commit for a new remote ref, ordered from most precise to most * conservative. Returns null when nothing can anchor the range. */ function unknownRemoteTipBase(localSha: string): string | null { // 1. The common case: a merge-base with the remote's default branch. const defaultBranch = defaultRemoteBranch(); const base = defaultBranch ? git(["merge-base", localSha, defaultBranch]).trim() : ""; if (base) return base; // 2. No merge-base. The target's default branch may be unavailable // (named trunk/develop, remote/HEAD unset), or history may be 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 exclusions = remotesExclusionArgs(); const newCommits = git(["rev-list", "--reverse", localSha, ...(exclusions.length ? ["--not", ...exclusions] : [])]).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): AddedFile[] | 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, ...remotesExclusionArgs()]).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: AddedFile[] = []; for (const sha of shas) { // gitStrict: a failed diff must never read as "nothing added" (#1946). // Collected per commit so a finding names the commit whose file line it is. out.push(...collectAddedLines(gitStrict([ "show", "--unified=0", "--no-color", "--no-ext-diff", "--no-textconv", "--src-prefix=a/", "--dst-prefix=b/", "--cc", "--format=", sha, ]), sha)); } return out; } /** * Added lines grouped by the file they were added to (`+++ b/` path; empty * when unknown). `lines[i]` is the file line number of the i-th line of * `text`, read from the hunk headers; `commit` is set when the lines came from * one commit's diff rather than the net diff of the pushed range. */ interface AddedFile { file: string; text: string; lines: number[]; commit?: string } /** Return the added lines for a ref update being pushed. */ function addedLinesFor(localSha: string, remoteSha: string): AddedFile[] { // 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 fromNew; let range: string; if (ZERO.test(remoteSha) || !objectExists(remoteSha)) { const base = ZERO.test(remoteSha) ? unknownRemoteTipBase(localSha) : null; range = base ? `${base}..${localSha}` : `${emptyTreeOid()}..${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", "--src-prefix=a/", "--dst-prefix=b/", range, ]); return collectAddedLines(diff); } /** * 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, commit?: string): AddedFile[] { const added: AddedFile[] = []; // 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; let file = ""; let fileLines: string[] = []; let lineNumbers: number[] = []; // File line tracking from hunk headers. A combined (--cc) header has one // more `@` than it has parents, and each body line one prefix column per // parent; a line belongs to the resulting file unless a column says `-`. let parents = 1; let nextLine = 0; const flush = () => { const lines = releaseVersionExempt(file, fileLines); if (lines.length) added.push({ file, text: lines.join("\n"), lines: lineNumbers, ...(commit ? { commit } : {}) }); fileLines = []; lineNumbers = []; }; 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 --")) { flush(); file = ""; inHunk = false; continue; } if (line.startsWith("@@")) { inHunk = true; parents = (/^@+/.exec(line)?.[0].length ?? 2) - 1; nextLine = Number(/ \+(\d+)(?:,\d+)? @+/.exec(line)?.[1] ?? 0); continue; } if (!inHunk && line.startsWith("+++ ")) { file = line.startsWith("+++ b/") ? line.slice(6) : ""; continue; } if (!inHunk && (line.startsWith("+++") || line.startsWith("---"))) continue; const inResult = inHunk && !line.startsWith("\\") && !line.slice(0, parents).includes("-"); const lineNumber = inResult ? nextLine++ : 0; if (line.startsWith("+")) { // A combined (--cc) body line carries one prefix column per parent. fileLines.push(line.slice(inHunk ? parents : 1)); lineNumbers.push(lineNumber); } } flush(); return added; } const VERSION_FILE_CONTENT = /^\d+(?:\.\d+)+$/; const CHANGELOG_RELEASE_VERSION = /^(#{1,6}[\t ]*\[?v?)\d+(?:\.\d+){3}(?=[\]\t ]|$)/; /** * #2856: a 4-part release version (MAJOR.MINOR.PATCH.MICRO) is shaped exactly like a * public IPv4 address, so /ship's own VERSION bump and CHANGELOG heading * raised pii.ip_public on every release. Exempt only those two release * artifacts, decided from the pushed diff alone (works in shallow clones): * a root VERSION whose entire added content is one version string, and the * version token of a root CHANGELOG.md release heading (the rest of the * heading is still scanned). Every other file — and any other position in * these two — is scanned unchanged; the engine's global allowlist is untouched. */ function releaseVersionExempt(file: string, lines: string[]): string[] { if (file === "VERSION" && lines.length === 1 && VERSION_FILE_CONTENT.test(lines[0].trim())) return []; if (file === "CHANGELOG.md") return lines.map((l) => l.replace(CHANGELOG_RELEASE_VERSION, "$1VERSION")); return lines; } /** * 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; const SCAN_OVERLAP_CHARS = 16 * 1024; const MAX_SCAN_BYTES = 1024 * 1024; /** * Scan complete-line core slices with bounded left and right context. Admit * only findings whose original captured start lies inside that core. * * 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. * * A match may need context on either adjacent line (`nearWindow` reaches 300 * normalized characters). The full core lines keep opaque spans intact; the * engine's normalization map selects context in normalized units without * changing the raw input. A partial context may create an artificial boundary, * but its findings are never admitted as core findings. * * Fail-closed is preserved: a SINGLE line over the budget is still passed to * the engine intact, so a genuinely unscannable blob (minified bundle, * embedded base64) trips input_too_large and blocks exactly as before. * * The engine reports line/col relative to each slice; an admitted finding's * `line` is rewritten to its 1-based line within `added`, which the caller * maps to the file line. 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[] = []; const cores: Array<{ start: number; end: number }> = []; const lines = added.split("\n"); let start = 0; let end = 0; let bytes = 0; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const newline = i < lines.length - 1 ? 1 : 0; const lineBytes = Buffer.byteLength(line, "utf8") + newline; if (bytes && bytes + lineBytes > SCAN_CHUNK_BYTES) { cores.push({ start, end }); start = end; bytes = 0; } end += line.length + newline; bytes += lineBytes; if (lineBytes > SCAN_CHUNK_BYTES) { cores.push({ start, end }); start = end; bytes = 0; } } if (bytes) cores.push({ start, end }); const contextStart = (boundary: number): number => { if (!boundary) return 0; let length = Math.min(boundary, SCAN_OVERLAP_CHARS * 2); while (true) { const from = boundary - length; const raw = added.slice(from, boundary); if (Buffer.byteLength(raw, "utf8") > MAX_SCAN_BYTES) return from; const { normalized, map } = normalizeWithMap(raw); if (!from || normalized.length > SCAN_OVERLAP_CHARS) { return from + (map[Math.max(0, normalized.length - SCAN_OVERLAP_CHARS - 1)] ?? 0); } length = Math.min(boundary, length * 2); } }; const contextEnd = (boundary: number): number => { if (boundary === added.length) return boundary; let length = Math.min(added.length - boundary, SCAN_OVERLAP_CHARS * 2); while (true) { const to = boundary + length; const raw = added.slice(boundary, to); if (Buffer.byteLength(raw, "utf8") > MAX_SCAN_BYTES) return to; const { normalized, map } = normalizeWithMap(raw); if (to === added.length || normalized.length > SCAN_OVERLAP_CHARS) { return boundary + (map[Math.min(normalized.length, SCAN_OVERLAP_CHARS + 1)] ?? length); } length = Math.min(added.length - boundary, length * 2); } }; const addedLineStarts = [0]; for (let i = 0; i < added.length; i++) if (added[i] === "\n") addedLineStarts.push(i + 1); const lineAt = (offset: number): number => { let lo = 0; let hi = addedLineStarts.length - 1; while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (addedLineStarts[mid] <= offset) lo = mid; else hi = mid - 1; } return lo + 1; }; for (const core of cores) { const left = contextStart(core.start); const text = added.slice(left, contextEnd(core.end)); const result = scan(text, opts); if (result.oversize) { findings.push(...result.findings); return findings; } const starts = [0]; for (let i = 0; i < text.length; i++) if (text[i] === "\n") starts.push(i + 1); for (const finding of result.findings) { const offset = starts[finding.line - 1] + finding.col - 1 + left; if (offset >= core.start && offset < core.end) findings.push({ ...finding, line: lineAt(offset) }); } } return findings; } function logSkip(reason: string): void { try { const home = resolveStateRoot(); 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 } } const HISTORY_MAX_COMMITS = 50_000; const HISTORY_TIMEOUT_MS = 5_000; function configValues(args: string[]): string[] { return git(["config", ...args]).split("\n").map((v) => v.trim()).filter(Boolean); } /** * Addresses `pii.email` treats as not a new leak (#3060): the pusher's own * (`git config user.email`), the ones listed in `gstack.redact.allowEmail`, * and the author and committer addresses (raw and mailmapped) the destination * will hold after this push. That last set is the history of every pushed tip * (pushed commits become public with the push, third-party authors included), * of each remote tip being replaced, and, for a ref whose remote tip is new or * unknown, the push remote's own tracking refs. A URL or unknown destination * never borrows a remote's tracking refs; another remote's history never counts. * * Each input fails on its own and a failure omits only that input, which is * the behavior before these exemptions existed. The history read is bounded: * past the commit cap it uses the newest authors it read; an error or the * timeout passes no history, and the reason is returned so the report can say * suppression was limited. */ function emailScanOptions(historyRevs: string[], trackingRemote: string | null): { options: Pick; limited?: string; } { const selfEmail = configValues(["--get", "user.email"])[0]; const allowed = configValues(["--get-all", "gstack.redact.allowEmail"]); const r = spawnSync("git", [ "log", "--no-show-signature", "--no-color", `--max-count=${HISTORY_MAX_COMMITS}`, "--format=%ae%n%aE%n%ce%n%cE", ...historyRevs, ...(trackingRemote ? [`--remotes=${trackingRemote}/*`] : []), "--", ], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, timeout: HISTORY_TIMEOUT_MS, killSignal: "SIGKILL" }); const timedOut = (r.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT"; const history = r.status === 0 && !r.error ? (r.stdout ?? "").split("\n").map((e) => e.trim()).filter((e) => e.includes("@")) : null; return { options: { ...(selfEmail ? { selfEmail } : {}), repoPublicEmails: [...new Set([...(history ?? []), ...allowed])], }, ...(history ? {} : { limited: timedOut ? `reading commit history took over ${HISTORY_TIMEOUT_MS / 1000} s` : "reading commit history failed" }), }; } const ALLOWLIST_PATH = ".gstack-redact-allowlist"; const ALLOWLIST_MAX_BYTES = 64 * 1024; /** * Entries of the allowlist committed in `sha`. Read from the pushed commit so * a suppression exists only as a reviewable commit. Absent, unreadable or * oversized means no entries: the file can record a reviewed exception, never * loosen the scan by accident. */ function committedAllowlist(sha: string): string[] { const spec = `${sha}:${ALLOWLIST_PATH}`; const size = Number(git(["cat-file", "-s", spec]).trim()); if (!size) return []; if (size > ALLOWLIST_MAX_BYTES) { process.stderr.write( `gstack-redact-prepush: ${ALLOWLIST_PATH} in ${sha.slice(0, 12)} is over ${ALLOWLIST_MAX_BYTES} bytes; ignoring it.\n`, ); return []; } return git(["cat-file", "blob", spec]).split("\n") .map((l) => l.trim()) .filter((l) => l && !l.startsWith("#")); } /** File line (and commit) of a finding. Never the matched value. */ function findingWhere(f: Finding, part: AddedFile): string { const line = part.lines[f.line - 1]; return `${part.file || "(unknown file)"}${line ? `:${line}` : ""}` + (part.commit ? ` in commit ${part.commit.slice(0, 12)}` : ""); } /** One MEDIUM report line: rule id, file line (and commit), and the fix. Never the matched value. */ function mediumLine(f: Finding, part: AddedFile): string { const where = findingWhere(f, part); const fix = f.id === "pii.email" ? "remove the address, or allow it: git config --add gstack.redact.allowEmail
" : "remove the value before it becomes public"; return ` MEDIUM ${f.id} ${where} fix: ${fix}\n`; } 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 parts: Array<{ part: AddedFile; allowlist: string[] }> = []; const allowEntries = new Set(); const historyRevs = new Set(); let trackingRemote: string | null = null; 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: AddedFile[]; 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); } const allowlist = committedAllowlist(localSha); for (const entry of allowlist) allowEntries.add(entry); for (const part of added) if (part.text.trim()) parts.push({ part, allowlist }); historyRevs.add(localSha); if (!ZERO.test(remoteSha) && objectExists(remoteSha)) historyRevs.add(remoteSha); else { const target = pushTarget(); if (target.kind === "remote") trackingRemote = target.name; } } const allHigh: Finding[] = []; const mediums: string[] = []; let emailMedium = false; const emails = parts.length ? emailScanOptions([...historyRevs], trackingRemote) : { options: {} }; // 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. // Scanned per file so a pattern can use the file's type (#3048). // An allowlisted span is found by scanning with and without the entries, // so the report names exactly what they suppressed. const allowed: string[] = []; const findingKey = (f: Finding) => `${f.id}:${f.line}:${f.col}`; for (const { part, allowlist } of parts) { const opts = { repoVisibility: "private" as const, ...emails.options, ...(part.file ? { sourcePath: part.file } : {}) }; const findings = scanAddedLines(part.text, opts); const kept = allowlist.length ? new Set(scanAddedLines(part.text, { ...opts, allowlist }).map(findingKey)) : null; for (const f of findings) { if (kept && !kept.has(findingKey(f))) { if (f.severity === "HIGH" || f.severity === "MEDIUM") allowed.push(` ${f.severity} ${f.id} ${findingWhere(f, part)}\n`); continue; } if (f.severity === "HIGH") allHigh.push(f); else if (f.severity === "MEDIUM") { mediums.push(mediumLine(f, part)); if (f.id === "pii.email") emailMedium = true; } } } if (allowEntries.size > 0) { process.stderr.write( `gstack-redact-prepush: ${ALLOWLIST_PATH} (${allowEntries.size} entr${allowEntries.size === 1 ? "y" : "ies"}) ` + `suppressed ${allowed.length} finding(s) in this push${allowed.length ? ":" : "."}\n`, ); for (const line of allowed) process.stderr.write(line); } if (mediums.length > 0) { process.stderr.write( `gstack-redact-prepush: ${mediums.length} MEDIUM finding(s) in pushed diff (PII/internal). ` + "Not blocking. Review before this becomes public.\n", ); for (const line of mediums) process.stderr.write(line); if (emailMedium && "limited" in emails && emails.limited) { process.stderr.write( `gstack-redact-prepush: existing-email suppression was limited for this push (${emails.limited}), ` + "so an address already in the history may be listed above.\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. // The computed range can be much larger than the visible tip diff, and a // single over-cap line is deliberately withheld rather than partly scanned. 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 the value is a known-benign sample, commit its exact matched text as a line of ${ALLOWLIST_PATH}\n` + "at the repo root instead of reshaping code to dodge the pattern; every push lists what that file suppresses.\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( "\nA long line, large first push, or unavailable remote tip may exceed the\n" + "per-slice safety cap. Check the destination and range; 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();