Files
gstack/bin/gstack-redact-prepush
Garry Tanandcapy-ai[bot] 94165fff39 v1.91.311.0 feat(pregate): sealed.allow_paths exceptions and opt-in case_insensitive deny globs (GSTA-145) (#3556)
* feat(pregate): sealed.allow_paths exceptions and opt-in case_insensitive deny globs (GSTA-145)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(changelog): sealed allow_paths and case_insensitive (Unreleased)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(pregate): state exactly which allow_paths file names are refused

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(troubleshooting): sealed allow_paths exemption and newer-gstack sealed keys

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-29 08:26:30 +00:00

791 lines
37 KiB
TypeScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* 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), 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).
* - A repo with a `sealed` key in .gstack/pregate.json (base branch ∪ pushed
* commit): lib/sealed-guard.ts checks every commit the push publishes for
* sealed or eval output; a public (or unknown) destination refuses
* (SEALED_OUTPUT_REFUSED), a private one warns.
*
* 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";
import { evaluateSealed, publishedCommits, renderSealed, sealedErrorReport, type SealedReport } from "../lib/sealed-guard";
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=<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): 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 "+++<content>" — 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 <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 --")) { 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<typeof scan>[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<ScanOptions, "selfEmail" | "repoPublicEmails">;
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 <address>"
: "remove the value before it becomes public";
return ` MEDIUM ${f.id} ${where} fix: ${fix}\n`;
}
/** The sealed-output guard over every commit this push publishes (lib/sealed-guard.ts); null when nothing is pushed. */
function sealedForPush(refs: Array<{ localSha: string; remoteSha: string }>): SealedReport | null {
if (!refs.length) return null;
const target = pushTarget();
const remoteName = target.kind === "remote" ? target.name : target.kind === "unknown" && git(["remote"]).split("\n").includes("origin") ? "origin" : null;
const remoteUrl = process.argv[3] || (remoteName ? git(["remote", "get-url", remoteName]).trim() : "") || null;
const targets: Array<{ commits: string[]; pushedSha: string }> = [];
for (const ref of refs) {
const published = publishedCommits(".", { localSha: ref.localSha, remoteSha: ref.remoteSha, remoteName });
if ("error" in published) return sealedErrorReport(published.error, remoteUrl ?? "the push target");
targets.push({ commits: published.commits, pushedSha: ref.localSha });
}
return evaluateSealed({ repoRoot: ".", targets, baseRef: defaultRemoteBranch(), remoteUrl, target: remoteUrl ?? remoteName ?? "the push target" });
}
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<string>();
const historyRevs = new Set<string>();
let trackingRemote: string | null = null;
const pushedRefs: Array<{ localSha: string; remoteSha: string }> = [];
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
pushedRefs.push({ localSha: localSha!, remoteSha: remoteSha! });
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",
);
}
}
const sealed = sealedForPush(pushedRefs);
const sealedText = sealed ? renderSealed(sealed, "gstack sealed-output guard") : "";
if (sealedText) process.stderr.write(sealedText);
const sealedBlocks = !!sealed && (sealed.status === "refused" || sealed.status === "error");
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 <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(sealedBlocks ? 1 : 0);
}
main();