mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 14:38: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
|
||||
|
||||
+42
-2
@@ -108,6 +108,39 @@ export function shannonEntropy(s: string): number {
|
||||
return h;
|
||||
}
|
||||
|
||||
// env.kv name-shape calibration: the regex's zero-or-more-prefix net matches
|
||||
// ANY identifier ending in a credential suffix, so `cacheKey:`, `sortKey:`,
|
||||
// `partitionKey:`, `hotkey:`, even `monkey:` with an 8+-char entropic value
|
||||
// all hit a MEDIUM confirm prompt — a gate that cries wolf gets ignored.
|
||||
// A matched name only counts when its shape is credential-semantic:
|
||||
// (i) suffix separated from the prefix by _ / - / . (api_key, x-access-key,
|
||||
// AUTH.TOKEN)
|
||||
// (ii) the whole name IS the bare suffix (key:, token:)
|
||||
// (iii) the name is ALL-CAPS env style (APIKEY=, MY_APIKEY=)
|
||||
// (iv) a lowercase/camel compound whose prefix ends in a credential word
|
||||
// (apiKey, authToken, clientSecret, stripeApiKey) — cacheKey/sortKey/
|
||||
// monkey have no credential prefix and are rejected.
|
||||
const ENV_KV_NAME =
|
||||
/^[ \t]*(?:export[ \t]+)?["']?([A-Za-z0-9_.-]*?(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|DSN|AUTH|COOKIE|SESSION|PRIVATE))["']?[ \t]*[:=]/i;
|
||||
const ENV_KV_SUFFIX =
|
||||
/(KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|DSN|AUTH|COOKIE|SESSION|PRIVATE)$/i;
|
||||
const ENV_KV_CRED_PREFIX =
|
||||
/(api|auth|access|secret|private|app|client|server|master|admin|signing|encryption|session|csrf|jwt|oauth|bearer)$/i;
|
||||
|
||||
/** True when the full env.kv match starts with a credential-shaped name. */
|
||||
export function isCredentialShapedEnvName(fullMatch: string): boolean {
|
||||
const nameMatch = ENV_KV_NAME.exec(fullMatch);
|
||||
if (!nameMatch) return false;
|
||||
const name = nameMatch[1];
|
||||
const suffixMatch = ENV_KV_SUFFIX.exec(name);
|
||||
if (!suffixMatch) return false;
|
||||
const prefix = name.slice(0, name.length - suffixMatch[1].length);
|
||||
if (prefix === "") return true; // (ii) bare suffix
|
||||
if (/[_.\-]$/.test(prefix)) return true; // (i) separator before suffix
|
||||
if (!/[a-z]/.test(name)) return true; // (iii) ALL-CAPS env style
|
||||
return ENV_KV_CRED_PREFIX.test(prefix); // (iv) credential-semantic compound
|
||||
}
|
||||
|
||||
/** True when an IPv4 string is a public address (not RFC1918/loopback/etc). */
|
||||
export function isPublicIPv4(ip: string): boolean {
|
||||
const m = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||
@@ -566,9 +599,16 @@ export const PATTERNS: RedactPattern[] = [
|
||||
// The name part is `[A-Za-z0-9_.-]*` + suffix (zero-or-more prefix, not
|
||||
// one-or-more): a mandatory first char would swallow the suffix's own
|
||||
// first letter and bare names like `password:` / `key:` would never match.
|
||||
// The wide net is then calibrated by isCredentialShapedEnvName in
|
||||
// validate — without it, any identifier that merely ENDS in a suffix
|
||||
// (cacheKey:, sortKey:, monkey:) fires a MEDIUM confirm on entropic
|
||||
// values. The value must stay capture group 1 (the engine masks group 1),
|
||||
// so name-shape checking lives in validate, not in a second group.
|
||||
regex: /^[ \t]*(?:export[ \t]+)?["']?[A-Za-z0-9_.-]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|DSN|AUTH|COOKIE|SESSION|PRIVATE)["']?[ \t]*[:=][ \t]*["']?([^\s'"]{8,})["']?/i,
|
||||
// Only fire on high-entropy values — kills `FOO_KEY=changeme` FPs.
|
||||
validate: (span) =>
|
||||
// Only fire on credential-shaped names with high-entropy values — kills
|
||||
// `FOO_KEY=changeme` and `cacheKey: <entropic-id>` FPs.
|
||||
validate: (span, match) =>
|
||||
isCredentialShapedEnvName(match[0]) &&
|
||||
!isPlaceholderSpan(span) &&
|
||||
!/^\$\{?[A-Za-z_]/.test(span) &&
|
||||
shannonEntropy(span) >= 3.0,
|
||||
|
||||
@@ -183,6 +183,29 @@ describe("MEDIUM demoted credential-shaped patterns (TENSION-1)", () => {
|
||||
expect(ids("apiKey: YOUR_API_KEY_HERE")).not.toContain("env.kv");
|
||||
expect(ids("api_key=${MY_VAR}")).not.toContain("env.kv");
|
||||
});
|
||||
// T1 calibration: the zero-or-more-prefix net matched ANY identifier ending
|
||||
// in a suffix, so ordinary code (`cacheKey: <entropic id>`) hit a MEDIUM
|
||||
// confirm prompt. Name shape must be credential-semantic to count.
|
||||
test("env.kv ignores non-credential names ending in a suffix (entropic values)", () => {
|
||||
const v = "8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ";
|
||||
expect(ids(`cacheKey: ${v}`)).not.toContain("env.kv");
|
||||
expect(ids(`sortKey: ${v}`)).not.toContain("env.kv");
|
||||
expect(ids(`partitionKey: ${v}`)).not.toContain("env.kv");
|
||||
expect(ids(`hotkey: ${v}`)).not.toContain("env.kv");
|
||||
expect(ids(`monkey: ${v}`)).not.toContain("env.kv");
|
||||
expect(ids(`idempotencyKey: ${v}`)).not.toContain("env.kv");
|
||||
});
|
||||
test("env.kv still fires on every credential-shaped name form", () => {
|
||||
const v = "8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ";
|
||||
expect(ids(`api_key=${v}`)).toContain("env.kv"); // (i) separator
|
||||
expect(ids(`API_KEY=${v}`)).toContain("env.kv"); // (i) + ALL-CAPS
|
||||
expect(ids(`x-access-key: ${v}`)).toContain("env.kv"); // (i) dash separator
|
||||
expect(ids(`key: ${v}`)).toContain("env.kv"); // (ii) bare suffix
|
||||
expect(ids(`APIKEY=${v}`)).toContain("env.kv"); // (iii) ALL-CAPS compound
|
||||
expect(ids(`apiKey: ${v}`)).toContain("env.kv"); // (iv) credential camel
|
||||
expect(ids(`authToken: ${v}`)).toContain("env.kv"); // (iv) credential camel
|
||||
expect(ids(`clientSecret: ${v}`)).toContain("env.kv"); // (iv) credential camel
|
||||
});
|
||||
test("env.kv stays MEDIUM (calibration: generic net, not a blocker)", () => {
|
||||
const f = scan("api_key=8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ", { repoVisibility: "private" })
|
||||
.findings.find((x) => x.id === "env.kv");
|
||||
|
||||
@@ -159,3 +159,118 @@ describe("narrowing the range does not narrow coverage", () => {
|
||||
expect(addedOnly(addedLinesFromNewCommits(dir))).toContain(FAKE_AWS_NOREMOT);
|
||||
});
|
||||
});
|
||||
|
||||
// ── S1: the exclusion is scoped to the PUSH TARGET's remote ─────────────────
|
||||
//
|
||||
// A bare `--remotes` excludes commits reachable from ANY remote-tracking ref,
|
||||
// so a secret that had only ever reached a private/local-path remote was never
|
||||
// scanned when pushed to a PUBLIC remote. Git hands pre-push the push remote's
|
||||
// name as $1; the hook now scopes the exclusion to `--remotes=<name>/*`.
|
||||
// These run END-TO-END through the hook binary with the real argv + stdin
|
||||
// protocol, because the behavior under test is the argv threading itself.
|
||||
describe("S1: exclusion scoped to the push-target remote", () => {
|
||||
const PREPUSH = join(import.meta.dir, "..", "bin", "gstack-redact-prepush");
|
||||
const FAKE_AWS_OTHERREM = ["AKIA", "IOSFODNN7OTHERRM"].join("");
|
||||
|
||||
function runHook(stdinLines: string, argv: string[]): { code: number; stderr: string } {
|
||||
const r = spawnSync("bun", [PREPUSH, ...argv], {
|
||||
cwd: dir,
|
||||
input: Buffer.from(stdinLines),
|
||||
encoding: "utf8",
|
||||
env: { ...process.env },
|
||||
});
|
||||
return { code: r.status ?? 0, stderr: r.stderr ?? "" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the S1 shape. Returns the feature branch's last-pushed origin tip
|
||||
* (what git hands the hook as remoteSha):
|
||||
* 1. main + feature pushed to origin (T0 = feature's origin tip)
|
||||
* 2. a HIGH-shaped secret commit reaches a SECOND remote only
|
||||
* (pushed there, fetched back → other/leaky tracking ref)
|
||||
* 3. feature merges the secret commit — the next push to origin is
|
||||
* the first time this content heads anywhere public
|
||||
*/
|
||||
function buildSecretOnSecondRemote(): { originTip: string } {
|
||||
const origin = mkdtempSync(join(tmpdir(), "gstack-prepush-origin-"));
|
||||
run(["init", "-q", "--bare", "-b", "main"], origin);
|
||||
run(["remote", "add", "origin", origin]);
|
||||
run(["push", "-q", "origin", "main"]);
|
||||
run(["checkout", "-q", "-b", "feature"]);
|
||||
commit("mine.ts", "export const mine = 1;\n", "my work");
|
||||
run(["push", "-q", "-u", "origin", "feature"]);
|
||||
const originTip = run(["rev-parse", "HEAD"]).trim();
|
||||
|
||||
const other = mkdtempSync(join(tmpdir(), "gstack-prepush-other-"));
|
||||
run(["init", "-q", "--bare", "-b", "main"], other);
|
||||
run(["remote", "add", "other", other]);
|
||||
run(["checkout", "-q", "-b", "leaky"]);
|
||||
commit("leak.ts", `const k = "${FAKE_AWS_OTHERREM}";\n`, "secret to private remote only");
|
||||
run(["push", "-q", "other", "leaky"]);
|
||||
run(["fetch", "-q", "other"]);
|
||||
|
||||
run(["checkout", "-q", "feature"]);
|
||||
// --no-ff: a fast-forward would make the secret commit the branch TIP,
|
||||
// where the remoteSha two-dot fallback catches it regardless of the
|
||||
// --remotes exclusion. The hole shape needs a real merge commit, so the
|
||||
// narrowed path (per-commit --cc diffs) is what decides coverage.
|
||||
run(["merge", "-q", "--no-ff", "--no-edit", "leaky"]);
|
||||
return { originTip };
|
||||
}
|
||||
|
||||
test("a commit known only to a SECOND remote IS scanned when pushing to origin", () => {
|
||||
const { originTip } = buildSecretOnSecondRemote();
|
||||
const head = run(["rev-parse", "HEAD"]).trim();
|
||||
const { code, stderr } = runHook(
|
||||
`refs/heads/feature ${head} refs/heads/feature ${originTip}\n`,
|
||||
["origin", "file:///ignored"],
|
||||
);
|
||||
expect(code).toBe(1);
|
||||
expect(stderr).toContain("BLOCKED");
|
||||
expect(stderr).toContain("aws.access_key");
|
||||
});
|
||||
|
||||
test("origin-published commits still are NOT re-scanned (catch-up merge, #2592 kept)", () => {
|
||||
setUpRemoteWithForeignFixture();
|
||||
run(["checkout", "-q", "-b", "feature", "HEAD~1"]);
|
||||
commit("mine.ts", "export const mine = 1;\n", "my work");
|
||||
run(["push", "-q", "-u", "origin", "feature"]);
|
||||
const originTip = run(["rev-parse", "HEAD"]).trim();
|
||||
run(["merge", "-q", "--no-edit", "main"]); // catch-up merge brings the foreign fixture
|
||||
|
||||
const head = run(["rev-parse", "HEAD"]).trim();
|
||||
const { code, stderr } = runHook(
|
||||
`refs/heads/feature ${head} refs/heads/feature ${originTip}\n`,
|
||||
["origin", "file:///ignored"],
|
||||
);
|
||||
expect(stderr).not.toContain("BLOCKED");
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
test("no argv (stdin/CLI invocation) falls back to the historical all-remotes exclusion", () => {
|
||||
// Documented contract, not a gap being celebrated: without the remote
|
||||
// name there is nothing to scope to, and the fallback scans exactly what
|
||||
// the hook always scanned. The installed hook wrapper forwards "$@", so
|
||||
// real pushes always carry the name.
|
||||
const { originTip } = buildSecretOnSecondRemote();
|
||||
const head = run(["rev-parse", "HEAD"]).trim();
|
||||
const { code, stderr } = runHook(
|
||||
`refs/heads/feature ${head} refs/heads/feature ${originTip}\n`,
|
||||
[],
|
||||
);
|
||||
expect(stderr).not.toContain("BLOCKED");
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
test("an unconfigured name (URL push) also falls back rather than erroring", () => {
|
||||
const { originTip } = buildSecretOnSecondRemote();
|
||||
const head = run(["rev-parse", "HEAD"]).trim();
|
||||
const url = "file:///not-a-configured-remote";
|
||||
const { code, stderr } = runHook(
|
||||
`refs/heads/feature ${head} refs/heads/feature ${originTip}\n`,
|
||||
[url, url],
|
||||
);
|
||||
expect(stderr).not.toContain("could not");
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user