mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* 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>
791 lines
37 KiB
TypeScript
Executable File
791 lines
37 KiB
TypeScript
Executable File
#!/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();
|