mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-12 16:08:59 +02:00
fix(redact): env.kv stops flagging cacheKey-style names; prepush exclusion scoped to the push remote
Two calibration/coverage fixes in the redaction guard: - env.kv's zero-or-more-prefix regex fired on ANY identifier ending in a credential suffix, so ordinary code (cacheKey:, sortKey:, partitionKey:, hotkey:, even monkey:) with an 8+-char entropic value hit a MEDIUM confirm prompt — a gate that cries wolf gets ignored. A name now only counts when its shape is credential-semantic: suffix separated by _/-/. (api_key, x-access-key, AUTH.TOKEN), a bare suffix (key:, token:), ALL-CAPS env style (APIKEY=, MY_APIKEY=), or a camel compound with a credential prefix (apiKey, authToken, clientSecret). The value stays capture group 1, so the shape check lives in validate (isCredentialShapedEnvName), not the regex. - gstack-redact-prepush's narrowing excluded commits reachable from ANY remote (`--not --remotes`), so a secret that had only ever reached a private/local-path remote was never scanned when later pushed to a PUBLIC remote. The exclusion is now scoped to the push target (`--remotes=<name>/*`) via the remote name git hands pre-push as $1 (the installed wrapper already forwards "$@"); stdin/CLI invocations and URL pushes without a configured name fall back to the historical all-remotes behavior. #2592's catch-up-merge fix is unaffected: upstream commits come from the same remote being pushed to. New coverage: env.kv negative controls (cacheKey/sortKey/partitionKey/ hotkey/monkey/idempotencyKey) + positive controls for all four name shapes; end-to-end hook tests proving a second-remote secret blocks a push to origin while origin-published catch-up content still doesn't, plus both fallbacks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
4a95ce61a0
commit
412ad5c1f9
+41
-11
@@ -70,6 +70,33 @@ function objectExists(sha: string): boolean {
|
||||
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();
|
||||
@@ -104,12 +131,12 @@ function unknownRemoteTipBase(localSha: string): string | null {
|
||||
// engine's byte cap, so `engine.input_too_large` blocks having scanned
|
||||
// NOTHING — "scans more, never less" inverted into "scans nothing".
|
||||
//
|
||||
// `--remotes` covers every remote, not just the push target: content
|
||||
// already published anywhere has already left this machine, so treating it
|
||||
// as pre-existing is deliberate. Git hands the remote name to pre-push in
|
||||
// argv, which this hook does not read; narrowing to it would only matter
|
||||
// for a repo that pushes secrets to one remote but not another.
|
||||
const newCommits = git(["rev-list", "--reverse", localSha, "--not", "--remotes"]).trim();
|
||||
// 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();
|
||||
@@ -149,10 +176,13 @@ function unknownRemoteTipBase(localSha: string): string | null {
|
||||
*
|
||||
* A two-dot range cannot express this: after merging main, neither the remote
|
||||
* tip nor the merge-base with main is an ancestor of the other, so no single
|
||||
* base excludes both. `rev-list --not --remotes` is the operation that does,
|
||||
* and this file already reasons that way in `unknownRemoteTipBase` step 2 —
|
||||
* including why `--remotes` (every remote, not just the push target) is the
|
||||
* right exclusion: content published anywhere has already left this machine.
|
||||
* 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
|
||||
@@ -168,7 +198,7 @@ function addedLinesFromNewCommits(localSha: string, remoteSha: string): string |
|
||||
// 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, "--remotes"]).trim();
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user