mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-29 01:10:50 +02:00
* test(wireup): make gbrain-missing PATH fixture hermetic The gbrain-missing test appended the host PATH (and a hardcoded /opt/homebrew/bin) to the fixture PATH, so on any machine with a real gbrain installed the 'missing' case saw it, exited 0 instead of 2, and could never fail where the bug exists — a false green for a whole machine class. The fixture now keeps only root-owned OS dirs on the child PATH, and a new determinism check plants a host-like gbrain to prove it is unreachable. Absorbed from PR #2615 with authorship preserved; the PR-thread liveness screenshot (docs/images/gstack-pr-liveness-2255.png) is dropped — referenced by nothing in the tree. Fixes #2255 Co-authored-by: CommandCodeBot <noreply@commandcode.ai> * fix(evidence): stop bun's dotenv autoload from reaching the spawned command `bin/gstack-evidence` has a `#!/usr/bin/env bun` shebang, and bun AUTO-LOADS `.env`, `.env.<NODE_ENV>` and `.env.local` from the cwd into `process.env`. The wrapper then spawned the command with no `env` override, so every command run through it inherited those variables — and a repo `.env.local` routinely holds production credentials. Two things go wrong, and the second is worse than the leak: 1. Secrets reach a child that would not otherwise have them. `npm test` run by hand in the same shell sees none of them; the same command through the wrapper sees all of them. 2. THE COMMAND UNDER TEST BEHAVES DIFFERENTLY, so the ledger certifies a run that is not the run CI performs. Observed in a Next.js repo on 2026-08-20: four tests failed 4/4 through the wrapper and passed 5/5 without it, because app code branched on env vars only the wrapper supplied. Nearly an hour went into chasing a "flake" that was the measuring instrument. The wrapper exists to record trustworthy evidence, so silently altering the environment defeats its purpose. The fix builds the child env from `process.env` minus the keys bun injected, and detection is exact rather than heuristic: verified on bun 1.3.11, a dotenv file does NOT override a variable the shell already exported (the shell's value wins). So a key whose live value equals the dotenv file's value was injected by bun, and dropping it restores the environment the user's own shell would have given the command. A key whose live value differs is genuinely the caller's and survives. `BUN_DOTENV_FILES()` mirrors bun's precedence, including that `.env.local` is skipped when NODE_ENV is "test" — scrubbing a key bun never loaded would strip a variable the caller legitimately provided. Escape hatch: GSTACK_EVIDENCE_KEEP_DOTENV=1 keeps the old behaviour. When keys are scrubbed the wrapper warns with the KEY NAMES ONLY, so the diagnostic cannot become the leak it prevents. Tests: 6 cases, mutation-verified — removing `env: spawnEnv` reddens exactly the two leak tests and restoring it gives 30/30. Every leak test asserts the scrub warning fired, because `bun test` sets NODE_ENV=test and the first version of these tests passed vacuously against a `.env.local` bun had never loaded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Absorbed from PR #2652 with authorship preserved. Wave additions: a doc-comment on the ${VAR}-expansion limitation (bun expands refs, the reader compares raw text — those keys are left in the child env, failing open) and a regression pin for the unreadable-.env fail-open path with a functional DAC-override skip guard. Fixes #2624 * fix(setup): reap dangling skill dirs when the payload is gone cleanup_old_claude_symlinks derived its work list from the payload directory, so when the payload was gone — precisely when orphans exist — the glob matched nothing and the loop never ran; the -f guard also followed symlinks, hiding dangling SKILL.md links even with a payload present. The cleanup now scans the DESTINATION skills dir (-e/-L, so dangling symlinks are visible) and anchors SKILL.md provenance to path segments (gstack/*, */gstack/*, */.gstack/render/claude/*) instead of a bare *gstack* substring that would eat a user skill under ~/tools/gstack-fork/. The Windows real-file arm stays payload-gated: a real file has no provable owner. Absorbed from PR #2634 (2 commits squashed) with authorship preserved. The symmetric cleanup_prefixed_claude_symlinks hole is filed as a TODOS.md residual in this wave. Fixes #2204 * fix(redact): tolerate EEXIST from recursive mkdir in install-prepush-hook on bun/Windows (#2635) fs.mkdirSync(dir, { recursive: true }) is a no-op on an existing directory in Node, but bun on Windows throws EEXIST - crashing hook install on any repo whose .git/hooks already existed, leaving the repo unprotected. Add lib/fs-utils.ts mkdirpSync: swallow EEXIST only when statSync confirms the path is an existing directory; a regular file occupying the path, a stat failure, or any other errno still rethrows. Use it in installPrepushHook(). The regression test emulates the Windows bun fs semantics via a bun --preload fixture, so the exact crash path runs (and fails on the old code) on any platform, including CI Linux. Absorbed from PR #2641 with authorship preserved. Fixes #2635 * fix(bin): route remaining Windows-reachable mkdirSync sites through mkdirpSync Sweep follow-up to #2641's lib/fs-utils.ts helper: bun on Windows throws EEXIST from a recursive mkdir on an existing dir, so every unguarded recursive mkdirSync on a Windows-reachable path is a latent crash. Converted: bin/gstack-decision-log (unguarded, runs on every decision log — the second call on any machine hits the pre-existing projects dir), bin/gstack-evidence logsDir + ledger dir sites, and bin/gstack-redact-prepush's skip-log site (already try-wrapped, so its failure mode was a silent skip-log loss rather than a crash — the fix makes the log survive). The ~15 remaining gbrain/mac-lane sites are deliberately left alone. Regression: fs-utils.test.ts drives gstack-decision-log twice, the second run under the bun-Windows EEXIST preload fixture — the pre-sweep code exits 1 with EEXIST there; verified red against v1.68.3.0. * fix(setup-gbrain): warn about the ZeroEntropy sunset before Sept 4 ZeroEntropy was acquired by Notion and sunsets its hosted API on September 4, 2026. A gbrain configured with the zeroentropyai embedding recipe keeps importing pages after that date but embedding silently fails — pages land structurally with no semantic search, this repo's tracker P1 (TODOS.md NEXT PRIORITY). Nothing in gstack ever recommended ZeroEntropy (the dependency is gbrain-internal), so the gstack side is detection + advisory: the wireup helper warns when ~/.gbrain/config.json names the recipe (fail-open grep — a missing, unreadable, or other-provider config stays silent and never blocks a working setup), the setup-gbrain provider-default comments say never to select the legacy recipe for a new brain, and USING_GBRAIN_WITH_GSTACK.md gains a troubleshooting entry. The gbrain-side provider migration stays open upstream. Refs #2365 * fix(gbrain-source-wireup): first sync targets the registered source, not --repo The wireup registered a federated source by id, then ran 'gbrain sync --repo $WORKTREE' — which resolves against the brain's DEFAULT source and (on gbrain 0.46.x) rewrites that source's local_path anchor to our worktree. Net effect: the user's primary knowledge source silently repointed at the gstack brain worktree while the just-registered source got zero pages, and pages_synced still reported success. The sync now targets the registered id ('gbrain sync --source $id', the same form the repo's own troubleshooting documents). Because the script's stated floor is gbrain >= 0.18.0 and nothing proves --source exists there, support is probed via 'gbrain sync --help' first: an older gbrain keeps the wrong-but-working --repo call with an upgrade warning instead of converting it into a hard failure. The probe sits after the GSTACK_BRAIN_NO_SYNC early-exit and is unreachable in --probe mode. Regression tests (fail on v1.68.3.0): a no-skip sync case asserting the call log shows 'sync --source gstack-brain-<id>' and never 'sync --repo', and an old-gbrain fallback case (fake sync --help without --source) asserting --repo plus the upgrade warning. Fixes #2662 * fix(setup): --host slate exits informatively instead of silently installing nothing slate passed --host validation (added to the accept-list in v1.64.1.0) but never got a dispatch arm, and the all-INSTALL_*-zero fallback lives inside the auto branch — so './setup --host slate' configured nothing and exited 0, a silent no-op strictly worse than the original hard rejection. slate is now an informational arm (per docs/designs/SLATE_HOST.md it is blocked on the host-config refactor; Slate reads .claude/skills as a compatibility fallback, so the arm points at './setup --host claude'), and a defensive guard after the dispatch chain errors loudly (naming the host, the missing arm, and the valid targets, exit 1) if a future host is ever accepted without being wired. Regression tests (fail on v1.68.3.0): a dispatch-arm ratchet asserting every accept-listed install target has a matching dispatch branch — the exact drift class; a registry cross-check deriving both sides from hosts/index.ts and setup's case arms; a behavioral slate probe (exit 0, points at --host claude, never reaches the installer — on unfixed code it fell through into the installer); and a static pin on the guard's shape. Fixes #2361 * fix(make-pdf): resolve the sibling browse binary from execPath, not argv[0] In a bun-compiled binary process.argv[0] is the raw invocation string — often relative ('./pdf', 'pdf') — so dirname(argv[0]) yielded '.' and the sibling candidates (../browse/dist/browse etc.) resolved against the CWD instead of the install dir. Resolution was cwd-dependent: correct-by-luck when the fallbacks rescued it, wrong when a cwd-relative path matched. process.execPath is always the absolute binary path. The resolution step takes an injectable selfPath (defaulted) because under bun test the process path is the bun runtime and the compiled-binary shapes are otherwise unreachable. The issue's other half — pdf setup failing on newtab('about:blank') — was already fixed on main in v1.64.0.0 (browse/src/url-validation.ts exact-match allows about:blank; its comment names this exact smoke). This commit closes what remains. Regression tests (the sibling-via-selfPath case fails on v1.68.3.0 — pre-fix code ignores the seam and either resolves the global install or throws): sibling resolution from an install-shaped tree, and a decoy-browse-DIRECTORY case pinning that a directory never wins resolution. Fixes #2156 * fix(memory-ingest): store the normalized git_remote so unattributed pages hit the policy filter buildTranscriptPage wrote the normalized '_unattributed' sentinel into the page FRONTMATTER but stored the raw resolved remote ('' when unresolvable) on the page object. The policy filter fast-paths !p.git_remote, so under --include-unattributed an explicit '_unattributed → deny' (or read-only) policy never applied to exactly the pages it names — they ingested unpoliced. The stored value now matches the frontmatter. Regression test (fails on v1.68.3.0): seeds the REAL bin/gstack-gbrain-repo-policy store with '_unattributed → deny' through its own set verb, ingests an unresolvable-remote session with --include-unattributed, and asserts nothing reaches gbrain — pre-fix the '' remote bypassed the filter and the import ran. A fake echoing tiers would pass on both sides of the fix; the real helper prints 'none' for unknown keys, so only a genuinely applied deny distinguishes the two. Fixes #2353 * fix(land-and-deploy): MERGED recovery reconciles and reports remote-branch cleanup Step 4's merge commands carry --delete-branch, and the success path tells the user 'The branch has been cleaned up.' When gh exits non-zero AFTER GitHub already merged (routine in worktree layouts: gh's local cleanup runs git checkout <base> and fails), the §4a-postfail MERGED recovery re-established everything EXCEPT the branch deletion — and said nothing about it, so the discrepancy was invisible. The MERGED path now reconciles: git ls-remote --heads distinguishes branch-already-gone (exit 0, empty → 'already cleaned up', idempotent on re-runs) from branch-survived (offer confirm-first deletion, matching the section's worktree posture; -d not -D for any local branch) from check-itself-failed (non-zero exit → 'couldn't verify', skip the offer — never read a failed check as a clean branch). Template + regenerated SKILL.md + test extensions land in one commit (the md-sync assertion goes red otherwise). Regression assertions (fail on v1.68.3.0: no delete-branch reconciliation existed in test/ at all) pin the ls-remote check, the confirm-first delete, and the absent-vs-failed distinction. Fixes #2656 * fix(scripts): stop heredoc bodies deadlocking under Homebrew bash `./setup --help` can hang forever on macOS, printing nothing, with no way to tell it apart from a slow install. Eleven scripts carry the same latent hang, `setup` itself being the one every user hits first. bash 5.2+ delivers a heredoc body of 64KiB or less through a pipe: the forked child writes the entire body before exec, and nothing reads the other end until the command starts. Under macOS pipe-KVA pressure the kernel hands a fresh pipe a 512-byte buffer instead of the usual 16-64KiB, so any body of 512 bytes or more blocks write() permanently. The capacity check bash would need to notice (F_GETPIPE_SZ) is Linux-only, so it never fires here. It is pressure-dependent, which is why it reads as "worked on my machine" — the same script runs fine all day and then wedges. Homebrew bash is what `#!/usr/bin/env bash` resolves to on a Mac with brew on PATH, which is most of them. Apple's /bin/bash 3.2 predates the pipe path and is unaffected, so the bug is invisible to anyone testing with the system shell. The fix is `BASH_COMPAT=50` in each affected script, which restores the pre-5.2 tempfile path: $ bash -c 'probe() { [ -p /dev/stdin ] && echo PIPE || echo TEMPFILE; } probe <<EOF $(printf "x%.0s" $(seq 1 1000)) EOF' PIPE $ BASH_COMPAT=50 bash -c '...same...' TEMPFILE - Not a `#!/bin/bash` shebang swap: that pins the script to whatever bash lives at /bin (3.2 on macOS, absent on some Linux distributions) and is bypassed entirely by `bash script.sh` call sites. The variable survives both. - Not exported, so child processes keep their own compat level. - Placed below any `--help` sed range that reads $0, so usage output is unchanged (verified on all eleven). - Every guarded script is bash-3.2-clean — no associative arrays, case conversion, or mapfile — so compat level 50 costs them nothing. test/heredoc-pipe-deadlock.test.ts scans every tracked shell script for a heredoc body in the 512B-64KiB window and fails without the guard, and proves the mechanism at runtime on bash 5.2+ by asserting the body moves from PIPE to TEMPFILE. On older bash the runtime half is skipped, since the pipe path does not exist there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Absorbed from PR #2640 with authorship preserved. Wave adaptations: the pipe-probe test skips on minimal-/dev environments without /dev/stdin (it would report OTHER for an unobservable fd), and one caveat verified during review: on bash 4.3/4.4 (e.g. Git Bash), assigning BASH_COMPAT=50 prints a non-fatal 'invalid value' warning to stderr — those bashes are already on tempfiles, so the guard is a no-op there; windows-setup-e2e exercises this empirically. * docs: TODOS.md v1.69 wave close-out Move the slate P4 entry and the ZeroEntropy P1's gstack-side half to Completed (v1.69.0.0); reframe the ZeroEntropy NEXT PRIORITY entry around the remaining gbrain-side work; file the wave's four residuals with rationale — the prefixed-cleanup symmetric conversion, the #2163 legacy-slug checkpoint heal, the invited #2657 --reconcile contribution, and the table-driven setup host dispatch behind the new cross-check ratchet. * chore: bump version and changelog (v1.69.0.0) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Som Samantray <som.samantray@gmail.com> Co-authored-by: CommandCodeBot <noreply@commandcode.ai> Co-authored-by: Connex Client Access <paul@paulkortman.com> Co-authored-by: y$un_ <forrest.sun527@gmail.com> Co-authored-by: Lockyer <135391289+Lockyer228@users.noreply.github.com> Co-authored-by: Benjamin D. Smith <benjamin.smith@binarysword.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
486 lines
22 KiB
TypeScript
Executable File
486 lines
22 KiB
TypeScript
Executable File
#!/usr/bin/env bun
|
|
/**
|
|
* gstack-redact-prepush — git pre-push hook that scans the diff being pushed for
|
|
* HIGH-severity credentials and blocks the push on a hit.
|
|
*
|
|
* THIS IS A GUARDRAIL, NOT ENFORCEMENT. `git push --no-verify` bypasses it, as
|
|
* does `GSTACK_REDACT_PREPUSH=skip`. It catches accidental credential pushes,
|
|
* the most common real-world leak. It does NOT scan history, binary/LFS/submodule
|
|
* files, or non-added lines. History scanning is /cso's job.
|
|
*
|
|
* Git pre-push interface: refs are read from STDIN, one per line:
|
|
* <local ref> <local sha> <remote ref> <remote sha>
|
|
* We scan the ADDED lines of <remote sha>..<local sha> per ref (what's being
|
|
* pushed). Special cases:
|
|
* - remote sha all-zeroes → new branch: diff against merge-base with the
|
|
* remote's default branch (fallback: scan all commits unique to local ref).
|
|
* - local sha all-zeroes → branch delete: nothing to scan, skip.
|
|
* - force-push → remote..local still gives the net new content.
|
|
*
|
|
* Behavior:
|
|
* - HIGH finding in added lines → print + exit 1 (block), for public AND private.
|
|
* - MEDIUM → warn (non-blocking). LOW/WARN → silent.
|
|
* - GSTACK_REDACT_PREPUSH=skip → log + exit 0 (escape valve).
|
|
*
|
|
* Installed/uninstalled via `gstack-redact install-prepush-hook` (see the
|
|
* gstack-redact CLI), which chains any pre-existing hook.
|
|
*/
|
|
import { spawnSync } from "child_process";
|
|
import * as fs from "fs";
|
|
import * as os from "os";
|
|
import * as path from "path";
|
|
import { scan, type Finding } from "../lib/redact-engine";
|
|
import { mkdirpSync } from "../lib/fs-utils";
|
|
|
|
const ZERO = /^0+$/;
|
|
// The canonical empty-tree object; diffing against it yields all content as added.
|
|
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
|
|
/**
|
|
* Permissive git for legitimately-fallible PROBES (symbolic-ref, rev-parse,
|
|
* merge-base) where a non-zero exit is normal control flow. The DIFF call
|
|
* must NOT use this — see gitStrict (#1946 fail-closed).
|
|
*/
|
|
function git(args: string[]): string {
|
|
const r = spawnSync("git", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
return r.status === 0 ? (r.stdout ?? "") : "";
|
|
}
|
|
|
|
/**
|
|
* Fail-closed git for the diff that decides whether the push is scanned
|
|
* (#1946). status !== 0 covers repo errors; status === null covers a killed
|
|
* process AND maxBuffer overflow — the oversized-diff case is exactly where
|
|
* a large secret-bearing blob is most likely, so "couldn't read the diff"
|
|
* must block, not silently allow.
|
|
*/
|
|
function gitStrict(args: string[]): string {
|
|
const r = spawnSync("git", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
// status !== 0 covers BOTH a non-zero exit AND null (process killed by a
|
|
// signal or maxBuffer overflow — null !== 0 is true).
|
|
if (r.status !== 0) {
|
|
throw new Error(
|
|
`git ${args[0]} failed (status=${r.status ?? "killed/overflow"}): ${(r.stderr ?? "").slice(0, 300)}`,
|
|
);
|
|
}
|
|
return r.stdout ?? "";
|
|
}
|
|
|
|
/** 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 "$@".
|
|
* Fallbacks keep the historical all-remotes behavior when the name is
|
|
* unavailable (stdin/CLI invocation) or is not a configured remote (URL
|
|
* pushes have no remote-tracking namespace) — falling back scans LESS than
|
|
* the narrowed form would, but never less than the hook historically did.
|
|
*/
|
|
let _remotesExclusion: string | undefined;
|
|
function remotesExclusion(): string {
|
|
if (_remotesExclusion === undefined) {
|
|
const name = process.argv[2];
|
|
const configured = name
|
|
? git(["remote"]).split("\n").map((s) => s.trim()).filter(Boolean).includes(name)
|
|
: false;
|
|
_remotesExclusion = configured ? `--remotes=${name}/*` : "--remotes";
|
|
}
|
|
return _remotesExclusion;
|
|
}
|
|
|
|
function defaultRemoteBranch(): string {
|
|
// origin/HEAD → origin/main, fall back to main/master.
|
|
const sym = git(["symbolic-ref", "refs/remotes/origin/HEAD"]).trim();
|
|
if (sym) return sym.replace("refs/remotes/", "");
|
|
for (const b of ["origin/main", "origin/master"]) {
|
|
if (git(["rev-parse", "--verify", b]).trim()) return b;
|
|
}
|
|
return "origin/main";
|
|
}
|
|
|
|
/**
|
|
* Base commit for a push whose remote tip we cannot use directly, ordered from
|
|
* most precise to most conservative. Returns null when nothing can anchor the
|
|
* range, i.e. the whole history really is new content.
|
|
*/
|
|
function unknownRemoteTipBase(localSha: string): string | null {
|
|
// 1. The common case: a merge-base with the remote's default branch.
|
|
const base = git(["merge-base", localSha, defaultRemoteBranch()]).trim();
|
|
if (base) return base;
|
|
|
|
// 2. No merge-base. defaultRemoteBranch() guessed a ref that does not exist
|
|
// (default branch named trunk/develop, origin/HEAD unset), or history is
|
|
// disjoint. Anything reachable from localSha but from NO remote-tracking
|
|
// branch is what this push actually adds; the parent of its oldest commit
|
|
// is the real base.
|
|
//
|
|
// Without this we drop straight to EMPTY_TREE and re-scan content that is
|
|
// already on the remote. That is not merely wasteful, it is wrong in two
|
|
// ways: a secret pushed long ago gets re-reported as if THIS push
|
|
// introduced it (telling the operator to rotate a key over someone else's
|
|
// old commit), and on any real repository the input overshoots the
|
|
// engine's byte cap, so `engine.input_too_large` blocks having scanned
|
|
// NOTHING — "scans more, never less" inverted into "scans nothing".
|
|
//
|
|
// The exclusion is scoped to the PUSH TARGET's tracking refs (see
|
|
// remotesExclusion): content on some OTHER remote has left this machine,
|
|
// but it has not reached the remote being pushed to — a secret that only
|
|
// ever hit a private remote must still be scanned on its way to a public
|
|
// one (S1).
|
|
const newCommits = git(["rev-list", "--reverse", localSha, "--not", remotesExclusion()]).trim();
|
|
if (newCommits) {
|
|
const oldest = newCommits.split("\n")[0];
|
|
const parent = git(["rev-parse", "--verify", `${oldest}^`]).trim();
|
|
if (parent) return parent;
|
|
// Oldest new commit is a root commit: there is no parent to anchor on.
|
|
}
|
|
|
|
// 3. Nothing to anchor on — a genuinely fresh repository with no remote refs.
|
|
// Every commit IS new content, so scanning it all is the correct answer.
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* The commits this push actually adds — reachable from localSha and from NO
|
|
* remote-tracking ref.
|
|
*
|
|
* ⚠ WHY THIS EXISTS RATHER THAN A TWO-DOT RANGE.
|
|
*
|
|
* `remoteSha..localSha` is "everything new on this branch", which is NOT the
|
|
* same as "everything new to the remote". Merge origin/main into a feature
|
|
* branch and every commit main gained since the branch's last push becomes an
|
|
* added line — content that is already published, already scanned, and not
|
|
* this push's doing.
|
|
*
|
|
* Two things follow, and both were observed:
|
|
*
|
|
* · FALSE HIGH FINDINGS. A placeholder connection string in a test fixture,
|
|
* already merged to main by someone else, blocked an unrelated push as
|
|
* `db.url_with_password` — telling the operator to rotate a credential
|
|
* over a fixture they had never touched. A
|
|
* guard that cries wolf on catch-up merges is a guard people learn to
|
|
* bypass reflexively — which is exactly how a real secret gets through.
|
|
* · OVERSIZED SCANS. The comment on SCAN_CHUNK_BYTES below records a
|
|
* 1,146,782-byte diff from "a feature branch catching up to a busy main"
|
|
* blowing the engine's 1 MiB cap. Same root cause, treated there as a size
|
|
* problem and solved by slicing. Narrowing the range fixes the size too.
|
|
*
|
|
* A two-dot range cannot express this: after merging main, neither the remote
|
|
* tip nor the merge-base with main is an ancestor of the other, so no single
|
|
* base excludes both. `rev-list --not --remotes=<push-remote>/*` is the
|
|
* operation that does, and this file already reasons that way in
|
|
* `unknownRemoteTipBase` step 2. The exclusion is scoped to the push target's
|
|
* tracking namespace (see remotesExclusion): the upstream commits a catch-up
|
|
* merge brings in came from the SAME remote being pushed to, so scoping keeps
|
|
* the #2592 fix intact while a secret known only to some OTHER (private)
|
|
* remote is still scanned on its way to this one (S1).
|
|
*
|
|
* Each commit is diffed alone. `--cc` on a merge shows only the conflict
|
|
* RESOLUTION — content that exists in no parent — so a secret introduced while
|
|
* resolving a merge is still caught, while an ordinary merge contributes
|
|
* nothing. Returns null when the notion does not apply, so callers fall back.
|
|
*/
|
|
function addedLinesFromNewCommits(localSha: string, remoteSha: string): string | null {
|
|
// remoteSha is what git TELLS us the remote has, and it is authoritative in a
|
|
// way `--remotes` is not: remote-tracking refs can be absent (a fresh clone
|
|
// that never fetched, a push to a remote with no tracking ref) or stale. Drop
|
|
// it and a repo with no tracking refs excludes NOTHING — every commit ever
|
|
// made reads as "new", which re-introduces the false positives from the other
|
|
// direction. So it stays the base; `--remotes` only ADDS exclusions on top.
|
|
if (ZERO.test(remoteSha) || !objectExists(remoteSha)) return null;
|
|
|
|
const narrowed = git(["rev-list", localSha, "--not", remoteSha, remotesExclusion()]).trim();
|
|
if (!narrowed) return null;
|
|
|
|
// If excluding remote-tracking refs changes nothing, this push has no
|
|
// catch-up commits and the plain range already describes it exactly. Defer to
|
|
// it. That is not just an optimization: it keeps every push that ISN'T a
|
|
// catch-up merge on the original gitStrict diff path, so the fail-closed
|
|
// guarantee (#1946) and its regression test keep exercising the code they
|
|
// were written for. A narrowing that silently retired that test would be a
|
|
// worse trade than the false positives it set out to fix.
|
|
const plain = git(["rev-list", `${remoteSha}..${localSha}`]).trim();
|
|
const asSet = (s: string) => s.split("\n").filter(Boolean).sort().join("\n");
|
|
if (asSet(narrowed) === asSet(plain)) return null;
|
|
|
|
const shas = narrowed.split("\n").filter(Boolean);
|
|
// A rewrite of long history should fall back rather than shell out per commit.
|
|
if (shas.length > 500) return null;
|
|
const out: string[] = [];
|
|
for (const sha of shas) {
|
|
// gitStrict: a failed diff must never read as "nothing added" (#1946).
|
|
out.push(gitStrict([
|
|
"show", "--unified=0", "--no-color", "--no-ext-diff", "--no-textconv",
|
|
"--cc", "--format=", sha,
|
|
]));
|
|
}
|
|
return out.join("\n");
|
|
}
|
|
|
|
/** Return the added-line text for a ref update being pushed. */
|
|
function addedLinesFor(localSha: string, remoteSha: string): string {
|
|
// Preferred ONLY when this push carries catch-up commits: scanning them again
|
|
// is the bug. Every other shape falls through to the range logic below.
|
|
const fromNew = addedLinesFromNewCommits(localSha, remoteSha);
|
|
if (fromNew !== null) return collectAddedLines(fromNew);
|
|
|
|
let range: string;
|
|
if (ZERO.test(remoteSha) || !objectExists(remoteSha)) {
|
|
// Either a new branch (zero remote sha), or the remote tip object is absent
|
|
// locally (shallow clone, force-push without a prior fetch, CI checkout) so
|
|
// remote..local cannot resolve. Both need a base derived locally; scan MORE
|
|
// rather than hard-blocking a legitimate push (adversarial review finding 8).
|
|
const base = unknownRemoteTipBase(localSha);
|
|
range = base ? `${base}..${localSha}` : `${EMPTY_TREE}..${localSha}`;
|
|
} else {
|
|
// Existing branch (incl. force-push): net new content remote..local.
|
|
range = `${remoteSha}..${localSha}`;
|
|
}
|
|
// -U0: only changed lines; we keep lines starting with '+' (added), drop the
|
|
// +++ file header. Unified diff added lines start with a single '+'.
|
|
// Strict (#1946): a failed diff used to return "" and the push sailed
|
|
// through unscanned — fail open on the exact path the guard exists for.
|
|
//
|
|
// --no-ext-diff: a user's `diff.external` driver replaces the entire diff
|
|
// with its own output — with one set, `git diff` emits zero '+' lines, so an
|
|
// unhardened scanner reads an empty diff and exits 0 on a push full of
|
|
// secrets. Reachable from ordinary user config, not hypothetical. (#2498)
|
|
// --no-textconv: a .gitattributes textconv driver can likewise rewrite
|
|
// content before we ever see it. (#2498)
|
|
const diff = gitStrict([
|
|
"diff", "--unified=0", "--no-color", "--no-ext-diff", "--no-textconv",
|
|
range,
|
|
]);
|
|
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): string {
|
|
const added: string[] = [];
|
|
// Hunk-aware header skip (#2498): `+++ ` is only a FILE HEADER outside a
|
|
// hunk. Inside a hunk, an added content line whose text begins with "++"
|
|
// renders as "+++<content>" — the old blanket startsWith("+++") skip
|
|
// silently dropped exactly those lines from the scan.
|
|
let inHunk = false;
|
|
for (const line of diff.split("\n")) {
|
|
// `diff --` rather than `diff --git`: a merge scanned with --cc emits
|
|
// `diff --cc <path>`, so a --git-only reset left inHunk true across file
|
|
// boundaries and read the next file's `+++ b/...` header as content. Only
|
|
// noise (it over-scans, never under-scans), but the boundary is real.
|
|
if (line.startsWith("diff --")) { inHunk = false; continue; }
|
|
if (line.startsWith("@@")) { inHunk = true; continue; }
|
|
if (!inHunk && (line.startsWith("+++") || line.startsWith("---"))) continue;
|
|
if (line.startsWith("+")) added.push(line.slice(1));
|
|
}
|
|
return added.join("\n");
|
|
}
|
|
|
|
/**
|
|
* Byte budget per scan() call. Kept comfortably under redact-engine's
|
|
* DEFAULT_MAX_BYTES (1 MiB) so a slice never trips its oversize guard.
|
|
*/
|
|
const SCAN_CHUNK_BYTES = 768 * 1024;
|
|
|
|
/**
|
|
* Scan added lines in line-aligned slices, unioning the findings.
|
|
*
|
|
* Why: the engine refuses input over its byte cap and fails closed, which is
|
|
* right for one scan() call but wrong as a push policy — a feature branch
|
|
* catching up to a busy main legitimately produces more added lines than the
|
|
* cap (1,146,782 bytes against the 1 MiB default in the push that prompted
|
|
* this, and only ~7% of that was the lockfile). The push then blocked on
|
|
* `engine.input_too_large` — a size error naming no credential — which trains
|
|
* people to reach for --no-verify, defeating the guardrail far more thoroughly
|
|
* than a large diff does.
|
|
*
|
|
* Slicing loses NO detection coverage, because every pattern is single-line:
|
|
* none in redact-patterns.ts carries the `m` or `s` flag, the
|
|
* BEGIN-PRIVATE-KEY patterns capture only the header line rather than the key
|
|
* body, and the engine itself iterates line by line. A line boundary therefore
|
|
* cannot bisect a detectable secret, so no inter-slice overlap is needed.
|
|
*
|
|
* 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.
|
|
*
|
|
* Findings' line/col are slice-relative, which is fine here — this hook only
|
|
* reads severity, id and preview. Do not lift this into the engine, where
|
|
* callers rely on absolute line numbers.
|
|
*/
|
|
function scanAddedLines(added: string, opts: Parameters<typeof scan>[1]): Finding[] {
|
|
const findings: Finding[] = [];
|
|
let slice: string[] = [];
|
|
let sliceBytes = 0;
|
|
|
|
const flush = () => {
|
|
if (slice.length === 0) return;
|
|
findings.push(...scan(slice.join("\n"), opts).findings);
|
|
slice = [];
|
|
sliceBytes = 0;
|
|
};
|
|
|
|
for (const line of added.split("\n")) {
|
|
// +1 for the newline that rejoins it.
|
|
const lineBytes = Buffer.byteLength(line, "utf8") + 1;
|
|
// Close the current slice BEFORE overflowing it. A single oversized line
|
|
// lands in a slice of its own and is handed to the engine as-is.
|
|
if (sliceBytes > 0 && sliceBytes + lineBytes > SCAN_CHUNK_BYTES) flush();
|
|
slice.push(line);
|
|
sliceBytes += lineBytes;
|
|
}
|
|
flush();
|
|
|
|
return findings;
|
|
}
|
|
|
|
function logSkip(reason: string): void {
|
|
try {
|
|
const home = process.env.GSTACK_HOME || path.join(os.homedir(), ".gstack");
|
|
const dir = path.join(home, "security");
|
|
// mkdirpSync, not bare mkdirSync: bun-on-Windows EEXIST (#2635). This site
|
|
// is try-wrapped by the caller, so the old failure was a silent skip-log
|
|
// loss rather than a crash — the fix makes the log survive, not un-crash.
|
|
mkdirpSync(dir);
|
|
fs.appendFileSync(
|
|
path.join(dir, "prepush-skip.jsonl"),
|
|
JSON.stringify({ ts: new Date().toISOString(), reason }) + "\n",
|
|
);
|
|
} catch {
|
|
// best-effort; never block a push because logging failed
|
|
}
|
|
}
|
|
|
|
function main() {
|
|
if ((process.env.GSTACK_REDACT_PREPUSH || "").toLowerCase() === "skip") {
|
|
logSkip(process.env.GSTACK_REDACT_PREPUSH_REASON || "env-skip");
|
|
process.stderr.write("gstack-redact-prepush: skipped via GSTACK_REDACT_PREPUSH=skip\n");
|
|
process.exit(0);
|
|
}
|
|
|
|
const stdin = fs.readFileSync(0, "utf8");
|
|
const refs = stdin
|
|
.split("\n")
|
|
.map((l) => l.trim())
|
|
.filter(Boolean)
|
|
.map((l) => l.split(/\s+/));
|
|
|
|
const allHigh: Finding[] = [];
|
|
let mediumCount = 0;
|
|
|
|
for (const fields of refs) {
|
|
// Fail CLOSED on a ref line we cannot parse (#2498): git hands pre-push
|
|
// exactly "<local ref> <local sha> <remote ref> <remote sha>" — anything
|
|
// else means we cannot tell WHAT is being pushed, and silently skipping
|
|
// it would leave that ref unscanned.
|
|
const [, localSha, , remoteSha] = fields;
|
|
const shaShaped = (s: string | undefined) => !!s && /^[0-9a-f]{40,64}$/i.test(s);
|
|
if (fields.length !== 4 || !shaShaped(localSha) || !shaShaped(remoteSha)) {
|
|
process.stderr.write(
|
|
"\n⛔ gstack-redact-prepush BLOCKED the push — could not parse a pre-push ref line, " +
|
|
"so its content cannot be scanned.\n" +
|
|
` line: ${JSON.stringify(fields.join(" "))}\n` +
|
|
"Bypass if you're sure: GSTACK_REDACT_PREPUSH=skip git push (or git push --no-verify)\n",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
if (ZERO.test(localSha!)) continue; // branch delete → nothing pushed
|
|
let added: string;
|
|
try {
|
|
added = addedLinesFor(localSha, remoteSha || "0");
|
|
} catch (err) {
|
|
// Fail CLOSED (#1946): if we can't compute the pushed diff we can't
|
|
// scan it, and unscanned-but-allowed is the failure mode this hook
|
|
// exists to prevent.
|
|
process.stderr.write(
|
|
"\n⛔ gstack-redact-prepush BLOCKED the push — could not compute the pushed diff, " +
|
|
"so it cannot be scanned for credentials.\n" +
|
|
` (${err instanceof Error ? err.message.split("\n")[0] : String(err)})\n` +
|
|
"Bypass if you're sure: GSTACK_REDACT_PREPUSH=skip git push (or git push --no-verify)\n",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
if (!added.trim()) continue;
|
|
// Visibility doesn't change HIGH behavior; pass private so nothing is treated
|
|
// as public-strict (HIGH blocks regardless either way).
|
|
// Sliced (see scanAddedLines) so a large-but-legitimate diff is actually
|
|
// scanned rather than blocked unscanned on the engine's size cap.
|
|
for (const f of scanAddedLines(added, { repoVisibility: "private" })) {
|
|
if (f.severity === "HIGH") allHigh.push(f);
|
|
else if (f.severity === "MEDIUM") mediumCount++;
|
|
}
|
|
}
|
|
|
|
if (mediumCount > 0) {
|
|
process.stderr.write(
|
|
`gstack-redact-prepush: ${mediumCount} MEDIUM finding(s) in pushed diff (PII/internal). ` +
|
|
"Not blocking. Review before this becomes public.\n",
|
|
);
|
|
}
|
|
|
|
if (allHigh.length > 0) {
|
|
// A scan that could not RUN is not a scan that FOUND something. Reporting
|
|
// "credential(s) in the pushed diff — rotate the credential" for an
|
|
// `engine.*` finding tells the operator to rotate a secret that was never
|
|
// detected, on a diff that was never read. Blocking is still right (fail
|
|
// closed), but the reason must be the true one: a guardrail that cries wolf
|
|
// is a guardrail that gets bypassed by reflex, which is worse than none.
|
|
// Seen live 2026-07-30: a diff of a few hundred bytes reported HIGH
|
|
// engine.input_too_large, because an unresolvable base branch made the hook
|
|
// fall back to EMPTY_TREE..local — i.e. the WHOLE repo (~7 MiB) as "added
|
|
// lines". The size the operator sees and the size the hook measures can
|
|
// therefore differ by four orders of magnitude.
|
|
const unscanned = allHigh.filter((f) => f.id.startsWith("engine."));
|
|
const secrets = allHigh.filter((f) => !f.id.startsWith("engine."));
|
|
|
|
if (secrets.length > 0) {
|
|
process.stderr.write(
|
|
"\n⛔ gstack-redact-prepush BLOCKED the push — credential(s) in the pushed diff:\n\n",
|
|
);
|
|
for (const f of secrets) {
|
|
process.stderr.write(` HIGH ${f.id} ${f.preview}\n`);
|
|
}
|
|
process.stderr.write(
|
|
"\nRotate the credential (a pushed secret is compromised) and remove it from the diff.\n",
|
|
);
|
|
}
|
|
|
|
if (unscanned.length > 0) {
|
|
process.stderr.write(
|
|
"\n⛔ gstack-redact-prepush BLOCKED the push — the diff could NOT be scanned.\n" +
|
|
" No credential was found; none was looked for. Blocking fail-closed.\n\n",
|
|
);
|
|
for (const f of unscanned) {
|
|
process.stderr.write(` ${f.id}: ${f.description}\n`);
|
|
}
|
|
process.stderr.write(
|
|
"\nLikely cause: the base branch could not be resolved, so the whole repo was\n" +
|
|
"treated as added lines. Check `git rev-parse --abbrev-ref origin/HEAD` and\n" +
|
|
"`git merge-base HEAD origin/main`, then push again. Scan the diff yourself\n" +
|
|
"before bypassing: `git diff <base>..HEAD | grep -inE \'password|secret|token|api.?key\'`.\n",
|
|
);
|
|
}
|
|
|
|
process.stderr.write(
|
|
"This is a guardrail: `git push --no-verify` or `GSTACK_REDACT_PREPUSH=skip git push` bypass it.\n",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
process.exit(0);
|
|
}
|
|
|
|
main();
|