diff --git a/bin/gstack-redact-prepush b/bin/gstack-redact-prepush index 695516bd2..98e9b931c 100755 --- a/bin/gstack-redact-prepush +++ b/bin/gstack-redact-prepush @@ -21,8 +21,9 @@ * - 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 and addresses already in - * the destination's commit metadata (see emailScanOptions). + * - `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_PREPUSH=skip → log + exit 0 (escape valve). * * Installed/uninstalled via `gstack-redact install-prepush-hook` (see the @@ -511,8 +512,9 @@ function configValues(args: string[]): string[] { /** * Addresses `pii.email` treats as not a new leak (#3060): the pusher's own - * (`git config user.email`) 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 + * (`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 @@ -529,6 +531,7 @@ function emailScanOptions(historyRevs: string[], trackingRemote: string | null): 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}/*`] : []), "--", @@ -540,7 +543,7 @@ function emailScanOptions(historyRevs: string[], trackingRemote: string | null): return { options: { ...(selfEmail ? { selfEmail } : {}), - ...(history ? { repoPublicEmails: [...new Set(history)] } : {}), + repoPublicEmails: [...new Set([...(history ?? []), ...allowed])], }, ...(history ? {} : { limited: timedOut ? `reading commit history took over ${HISTORY_TIMEOUT_MS / 1000} s` : "reading commit history failed" }), }; @@ -551,7 +554,10 @@ function mediumLine(f: Finding, part: AddedFile): string { const line = part.lines[f.line - 1]; const where = `${part.file || "(unknown file)"}${line ? `:${line}` : ""}` + (part.commit ? ` in commit ${part.commit.slice(0, 12)}` : ""); - return ` MEDIUM ${f.id} ${where} fix: remove the value before it becomes public\n`; + const fix = f.id === "pii.email" + ? "remove the address, or allow it: git config --add gstack.redact.allowEmail
" + : "remove the value before it becomes public"; + return ` MEDIUM ${f.id} ${where} fix: ${fix}\n`; } function main() { diff --git a/test/redact-prepush-emails.test.ts b/test/redact-prepush-emails.test.ts index 9f851925c..60f733836 100644 --- a/test/redact-prepush-emails.test.ts +++ b/test/redact-prepush-emails.test.ts @@ -163,6 +163,7 @@ describe("own and already-public addresses are not reported (#3060)", () => { expect(lines).toHaveLength(1); expect(lines[0]).toContain("pii.email"); expect(lines[0]).toContain("notes.md:3"); + expect(lines[0]).toContain("git config --add gstack.redact.allowEmail "); expect(stderr).not.toContain("stranger@corp.io"); }); @@ -302,6 +303,28 @@ describe("each input fails on its own (CEO-23, DX-9)", () => { } }); +describe("per-address allowlist (DX-17)", () => { + test("an allowed address is not reported; a different address still is", () => { + const fx = fixture(); + git(fx, fx.repo, ["config", "--add", "gstack.redact.allowEmail", "Team@corp.io"]); + commitFile(fx, fx.repo, "CONTACT.md", "team@corp.io\nother@corp.io\n"); + const { code, stderr } = push(fx, ["origin", "main"]); + expect(code).toBe(0); + const found = mediumLines(stderr); + expect(found).toHaveLength(1); + expect(found[0]).toContain("CONTACT.md:2"); + }); + + test("a HIGH secret next to an allowed address still blocks", () => { + const fx = fixture(); + git(fx, fx.repo, ["config", "--add", "gstack.redact.allowEmail", "team@corp.io"]); + commitFile(fx, fx.repo, "deploy.env", `OWNER=team@corp.io\nkey ${AWS_KEY}\n`); + const { code, stderr } = push(fx, ["origin", "main"]); + expect(code).toBe(1); + expect(stderr).toContain("aws.access_key"); + }); +}); + describe("MEDIUM lines point at the real file line (ENG-8)", () => { test("separated hunks in one file report each hunk's own line", () => { const fx = fixture();