feat(redact-prepush): per-address allowlist for pii.email (gstack.redact.allowEmail)

`git config --add gstack.redact.allowEmail <address>` allows that address for
MEDIUM pii.email findings only, so a team alias or support address that has
to be in the repo stops warning without reaching for --no-verify. It never
affects HIGH findings or any other rule, and the pii.email fix line names it.

Controls: a different address still reports, and a HIGH secret next to an
allowed address still blocks.

Plan item: DX-17.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-07 19:48:03 +00:00
1 parent e7a481a28d
commit d192d696e7
2 files changed
+35 -6

No files matched your search

+12 -6
View File
@@ -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 <address>"
: "remove the value before it becomes public";
return ` MEDIUM ${f.id} ${where} fix: ${fix}\n`;
}
function main() {
+23
View File
@@ -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 <address>");
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();