From 8f29f1cf586ff22b3bff39f27a4f02132099226d Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 7 Oct 2026 19:47:09 +0000 Subject: [PATCH 1/5] fix(redact-prepush): list each MEDIUM finding with rule, file line and fix The pre-push hook printed only a MEDIUM count, which nobody could review without re-running the engine by hand. It now prints one line per finding: rule id, file and file line, and a fix, never the matched value. Line numbers come from the hunk headers (combined --cc headers included), so a finding in a later hunk or past a scan-chunk boundary points at the real file line. Findings from the per-commit catch-up path name the commit whose file line it is, so content removed before the pushed tip is still locatable. Plan items: CEO-7 (summary half), DX-9 (fix lines), ENG-8 (line mapping). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-redact-prepush | 116 +++++++++++++----- test/redact-prepush-emails.test.ts | 186 +++++++++++++++++++++++++++++ 2 files changed, 269 insertions(+), 33 deletions(-) create mode 100644 test/redact-prepush-emails.test.ts diff --git a/bin/gstack-redact-prepush b/bin/gstack-redact-prepush index 3da23c7ad..6d3f2a5c9 100755 --- a/bin/gstack-redact-prepush +++ b/bin/gstack-redact-prepush @@ -19,7 +19,8 @@ * * Behavior: * - HIGH finding in added lines → print + exit 1 (block), for public AND private. - * - MEDIUM → warn (non-blocking). LOW/WARN → silent. + * - MEDIUM → warn (non-blocking), one line per finding naming its rule, + * file line and fix, never the matched value. LOW/WARN → silent. * - GSTACK_REDACT_PREPUSH=skip → log + exit 0 (escape valve). * * Installed/uninstalled via `gstack-redact install-prepush-hook` (see the @@ -210,7 +211,7 @@ function unknownRemoteTipBase(localSha: string): string | null { * 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 { +function addedLinesFromNewCommits(localSha: string, remoteSha: string): AddedFile[] | 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 @@ -236,26 +237,32 @@ function addedLinesFromNewCommits(localSha: string, remoteSha: string): string | 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[] = []; + const out: AddedFile[] = []; for (const sha of shas) { // gitStrict: a failed diff must never read as "nothing added" (#1946). - out.push(gitStrict([ + // Collected per commit so a finding names the commit whose file line it is. + out.push(...collectAddedLines(gitStrict([ "show", "--unified=0", "--no-color", "--no-ext-diff", "--no-textconv", "--src-prefix=a/", "--dst-prefix=b/", "--cc", "--format=", sha, - ])); + ]), sha)); } - return out.join("\n"); + return out; } -/** Return the added-line text for a ref update being pushed. */ -/** Added lines grouped by the file they were added to (`+++ b/` path; empty when unknown). */ -interface AddedFile { file: string; text: string } +/** + * Added lines grouped by the file they were added to (`+++ b/` path; empty + * when unknown). `lines[i]` is the file line number of the i-th line of + * `text`, read from the hunk headers; `commit` is set when the lines came from + * one commit's diff rather than the net diff of the pushed range. + */ +interface AddedFile { file: string; text: string; lines: number[]; commit?: string } +/** Return the added lines for a ref update being pushed. */ function addedLinesFor(localSha: string, remoteSha: string): AddedFile[] { // 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); + if (fromNew !== null) return fromNew; let range: string; if (ZERO.test(remoteSha) || !objectExists(remoteSha)) { @@ -287,7 +294,7 @@ function addedLinesFor(localSha: string, remoteSha: string): AddedFile[] { * 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): AddedFile[] { +function collectAddedLines(diff: string, commit?: string): AddedFile[] { const added: AddedFile[] = []; // Hunk-aware header skip (#2498): `+++ ` is only a FILE HEADER outside a // hunk. Inside a hunk, an added content line whose text begins with "++" @@ -296,10 +303,17 @@ function collectAddedLines(diff: string): AddedFile[] { let inHunk = false; let file = ""; let fileLines: string[] = []; + let lineNumbers: number[] = []; + // File line tracking from hunk headers. A combined (--cc) header has one + // more `@` than it has parents, and each body line one prefix column per + // parent; a line belongs to the resulting file unless a column says `-`. + let parents = 1; + let nextLine = 0; const flush = () => { const lines = releaseVersionExempt(file, fileLines); - if (lines.length) added.push({ file, text: lines.join("\n") }); + if (lines.length) added.push({ file, text: lines.join("\n"), lines: lineNumbers, ...(commit ? { commit } : {}) }); fileLines = []; + lineNumbers = []; }; for (const line of diff.split("\n")) { // `diff --` rather than `diff --git`: a merge scanned with --cc emits @@ -307,10 +321,20 @@ function collectAddedLines(diff: string): AddedFile[] { // 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 --")) { flush(); file = ""; inHunk = false; continue; } - if (line.startsWith("@@")) { inHunk = true; continue; } + if (line.startsWith("@@")) { + inHunk = true; + parents = (/^@+/.exec(line)?.[0].length ?? 2) - 1; + nextLine = Number(/ \+(\d+)(?:,\d+)? @+/.exec(line)?.[1] ?? 0); + continue; + } if (!inHunk && line.startsWith("+++ ")) { file = line.startsWith("+++ b/") ? line.slice(6) : ""; continue; } if (!inHunk && (line.startsWith("+++") || line.startsWith("---"))) continue; - if (line.startsWith("+")) fileLines.push(line.slice(1)); + const inResult = inHunk && !line.startsWith("\\") && !line.slice(0, parents).includes("-"); + const lineNumber = inResult ? nextLine++ : 0; + if (line.startsWith("+")) { + fileLines.push(line.slice(1)); + lineNumbers.push(lineNumber); + } } flush(); return added; @@ -366,9 +390,10 @@ const MAX_SCAN_BYTES = 1024 * 1024; * 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. + * The engine reports line/col relative to each slice; an admitted finding's + * `line` is rewritten to its 1-based line within `added`, which the caller + * maps to the file line. Do not lift this into the engine, where callers rely + * on absolute line numbers. */ function scanAddedLines(added: string, opts: Parameters[1]): Finding[] { const findings: Finding[] = []; @@ -426,6 +451,19 @@ function scanAddedLines(added: string, opts: Parameters[1]): Findin } }; + const addedLineStarts = [0]; + for (let i = 0; i < added.length; i++) if (added[i] === "\n") addedLineStarts.push(i + 1); + const lineAt = (offset: number): number => { + let lo = 0; + let hi = addedLineStarts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (addedLineStarts[mid] <= offset) lo = mid; + else hi = mid - 1; + } + return lo + 1; + }; + for (const core of cores) { const left = contextStart(core.start); const text = added.slice(left, contextEnd(core.end)); @@ -438,7 +476,7 @@ function scanAddedLines(added: string, opts: Parameters[1]): Findin for (let i = 0; i < text.length; i++) if (text[i] === "\n") starts.push(i + 1); for (const finding of result.findings) { const offset = starts[finding.line - 1] + finding.col - 1 + left; - if (offset >= core.start && offset < core.end) findings.push(finding); + if (offset >= core.start && offset < core.end) findings.push({ ...finding, line: lineAt(offset) }); } } @@ -462,6 +500,14 @@ function logSkip(reason: string): void { } } +/** One MEDIUM report line: rule id, file line (and commit), and the fix. Never the matched value. */ +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`; +} + function main() { if ((process.env.GSTACK_REDACT_PREPUSH || "").toLowerCase() === "skip") { logSkip(process.env.GSTACK_REDACT_PREPUSH_REASON || "env-skip"); @@ -476,8 +522,7 @@ function main() { .filter(Boolean) .map((l) => l.split(/\s+/)); - const allHigh: Finding[] = []; - let mediumCount = 0; + const parts: AddedFile[] = []; for (const fields of refs) { // Fail CLOSED on a ref line we cannot parse (#2498): git hands pre-push @@ -511,25 +556,30 @@ function main() { ); process.exit(1); } - if (!added.some((part) => part.text.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. - // Scanned per file so a pattern can use the file's type (#3048). - for (const part of added) { - for (const f of scanAddedLines(part.text, { repoVisibility: "private", ...(part.file ? { sourcePath: part.file } : {}) })) { - if (f.severity === "HIGH") allHigh.push(f); - else if (f.severity === "MEDIUM") mediumCount++; - } + parts.push(...added.filter((part) => part.text.trim())); + } + + const allHigh: Finding[] = []; + const mediums: string[] = []; + // 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. + // Scanned per file so a pattern can use the file's type (#3048). + for (const part of parts) { + const opts = { repoVisibility: "private" as const, ...(part.file ? { sourcePath: part.file } : {}) }; + for (const f of scanAddedLines(part.text, opts)) { + if (f.severity === "HIGH") allHigh.push(f); + else if (f.severity === "MEDIUM") mediums.push(mediumLine(f, part)); } } - if (mediumCount > 0) { + if (mediums.length > 0) { process.stderr.write( - `gstack-redact-prepush: ${mediumCount} MEDIUM finding(s) in pushed diff (PII/internal). ` + + `gstack-redact-prepush: ${mediums.length} MEDIUM finding(s) in pushed diff (PII/internal). ` + "Not blocking. Review before this becomes public.\n", ); + for (const line of mediums) process.stderr.write(line); } if (allHigh.length > 0) { diff --git a/test/redact-prepush-emails.test.ts b/test/redact-prepush-emails.test.ts new file mode 100644 index 000000000..519d6cb4d --- /dev/null +++ b/test/redact-prepush-emails.test.ts @@ -0,0 +1,186 @@ +/** + * gstack-redact-prepush: which email addresses the push guard reports, and + * where it says they are (#3060, CEO-7, CEO-23, DX-9, DX-17, ENG-8). + * + * An address that is the pusher's own, or already sits in the destination's + * commit metadata, is not a new leak, so `pii.email` must not report it. Every + * relaxation here has a paired control that still reports: a stranger's + * address, an address only another remote knows, a URL push that cannot vouch + * for tracking refs, and a HIGH secret next to an allowed address. + * + * The fixtures are real repositories with explicit per-commit identities and + * an isolated git config, so the machine's own identity can never leak in. + */ +import { afterEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +const PREPUSH = path.resolve(import.meta.dir, "../bin/gstack-redact-prepush"); +const REDACT = path.resolve(import.meta.dir, "../bin/gstack-redact"); +const ZERO = "0".repeat(40); +const AWS_KEY = ["AKIA", "1234567890ABCDEF"].join(""); +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +function baseEnv(root: string): Record { + const env: Record = {}; + for (const [k, v] of Object.entries(process.env)) { + if (v === undefined || /^GIT_(AUTHOR|COMMITTER)_/.test(k) || k.startsWith("GSTACK_REDACT_PREPUSH")) continue; + env[k] = v; + } + const globalConfig = path.join(root, "global.gitconfig"); + if (!fs.existsSync(globalConfig)) fs.writeFileSync(globalConfig, ""); + return { ...env, GIT_CONFIG_GLOBAL: globalConfig, GIT_CONFIG_NOSYSTEM: "1" }; +} + +interface Fixture { root: string; repo: string; origin: string; env: Record } + +function git(fx: Fixture, cwd: string, args: string[], extraEnv: Record = {}): string { + const r = spawnSync("git", args, { cwd, encoding: "utf8", env: { ...fx.env, ...extraEnv }, timeout: 60_000 }); + if (r.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${r.stderr}`); + return (r.stdout ?? "").trim(); +} + +function as(email: string): Record { + return { GIT_AUTHOR_NAME: "Someone", GIT_AUTHOR_EMAIL: email, GIT_COMMITTER_NAME: "Someone", GIT_COMMITTER_EMAIL: email }; +} + +let clock = 1_800_000_000; + +/** + * Commit `file` as `email`. The commit object is written by hand rather than + * by `git commit`, so a git wrapper that exports its own GIT_AUTHOR_* (some CI + * and agent machines do) cannot replace the identity under test. + */ +function commitFile(fx: Fixture, cwd: string, file: string, body: string, email = "me@corp.io"): string { + fs.mkdirSync(path.dirname(path.join(cwd, file)), { recursive: true }); + fs.writeFileSync(path.join(cwd, file), body); + git(fx, cwd, ["add", file]); + const tree = git(fx, cwd, ["write-tree"]); + const parent = spawnSync("git", ["rev-parse", "--verify", "-q", "HEAD"], { cwd, encoding: "utf8", env: fx.env, timeout: 30_000 }).stdout?.trim(); + const stamp = `${clock++} +0000`; + const object = [`tree ${tree}`, ...(parent ? [`parent ${parent}`] : []), `author Someone <${email}> ${stamp}`, `committer Someone <${email}> ${stamp}`, "", `edit ${file}`, ""].join("\n"); + const made = spawnSync("git", ["hash-object", "-t", "commit", "-w", "--stdin"], { cwd, input: object, encoding: "utf8", env: fx.env, timeout: 30_000 }); + if (made.status !== 0) throw new Error(`hash-object failed: ${made.stderr}`); + const sha = made.stdout.trim(); + git(fx, cwd, ["update-ref", "HEAD", sha]); + return sha; +} + +/** A working repo with an origin that already holds one seed commit, and the managed hook installed. */ +function fixture(opts: { selfEmail?: string | null; seed?: boolean } = {}): Fixture { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "prepush-emails-")); + roots.push(root); + const fx: Fixture = { root, repo: path.join(root, "repo"), origin: path.join(root, "origin.git"), env: baseEnv(root) }; + fs.mkdirSync(fx.repo); + git(fx, root, ["init", "--bare", "-q", "-b", "main", fx.origin]); + git(fx, fx.repo, ["init", "-q", "-b", "main"]); + git(fx, fx.repo, ["config", "user.name", "Me"]); + if (opts.selfEmail !== null) git(fx, fx.repo, ["config", "user.email", opts.selfEmail ?? "me@corp.io"]); + git(fx, fx.repo, ["remote", "add", "origin", fx.origin]); + if (opts.seed !== false) { + commitFile(fx, fx.repo, "README.md", "seed\n", "seed@example.com"); + git(fx, fx.repo, ["push", "-q", "origin", "main"]); + } + const install = spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: fx.repo, encoding: "utf8", env: fx.env, timeout: 30_000 }); + expect(install.status).toBe(0); + return fx; +} + +function push(fx: Fixture, args: string[]): { code: number; stderr: string } { + const r = spawnSync("git", ["push", ...args], { cwd: fx.repo, encoding: "utf8", env: fx.env, timeout: 60_000 }); + return { code: r.status ?? -1, stderr: r.stderr ?? "" }; +} + +/** Run the hook binary directly with git's argv and stdin, for fixtures that stub `git`. */ +function runHook(fx: Fixture, stdin: string, argv: string[], extraEnv: Record = {}): { code: number; stderr: string } { + const r = spawnSync(process.execPath, [PREPUSH, ...argv], { + cwd: fx.repo, input: stdin, encoding: "utf8", env: { ...fx.env, ...extraEnv }, timeout: 60_000, + }); + return { code: r.status ?? -1, stderr: r.stderr ?? "" }; +} + +const mediumLines = (stderr: string): string[] => stderr.split("\n").filter((l) => /^\s+MEDIUM\s/.test(l)); +const limitedLine = /existing-email suppression was limited for this push/; + +/** A producer clone that pushes `branch` with one commit by `email` to `remote`. */ +function publishForeignBranch(fx: Fixture, remote: string, branch: string, email: string): void { + const producer = path.join(fx.root, `producer-${branch}`); + git(fx, fx.root, ["clone", "-q", remote, producer]); + commitFile(fx, producer, `${branch}.txt`, "their work\n", email); + git(fx, producer, ["push", "-q", "origin", `HEAD:refs/heads/${branch}`]); +} + +describe("MEDIUM findings are listed one per line", () => { + test("control: a stranger's address is reported with rule, file, line and fix, never the address", () => { + const fx = fixture(); + commitFile(fx, fx.repo, "notes.md", "line one\nline two\ncontact stranger@corp.io\n"); + const { code, stderr } = push(fx, ["origin", "main"]); + expect(code).toBe(0); + expect(stderr).toMatch(/1 MEDIUM finding/); + const lines = mediumLines(stderr); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain("pii.email"); + expect(lines[0]).toContain("notes.md:3"); + expect(stderr).not.toContain("stranger@corp.io"); + }); + + test("control: a stranger's address in the first push of a repo with no history is reported", () => { + const fx = fixture({ seed: false }); + commitFile(fx, fx.repo, "notes.md", "contact stranger@corp.io\n"); + const { code, stderr } = push(fx, ["origin", "main"]); + expect(code).toBe(0); + const lines = mediumLines(stderr); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain("notes.md:1"); + }); +}); + +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(); + const body = Array.from({ length: 30 }, (_, i) => `line ${i + 1}`); + commitFile(fx, fx.repo, "notes.md", body.join("\n") + "\n"); + expect(push(fx, ["origin", "main"]).code).toBe(0); + body[4] = "ask first@corp.io"; + body[24] = "ask second@corp.io"; + commitFile(fx, fx.repo, "notes.md", body.join("\n") + "\n"); + const found = mediumLines(push(fx, ["origin", "main"]).stderr); + expect(found).toHaveLength(2); + expect(found[0]).toContain("notes.md:5"); + expect(found[1]).toContain("notes.md:25"); + }); + + test("a finding past a scan chunk boundary reports its file line", () => { + const fx = fixture(); + const filler = Array.from({ length: 20_000 }, (_, i) => `filler line ${i} ${"x".repeat(40)}`); + filler.push("ask late@corp.io"); + commitFile(fx, fx.repo, "big.txt", filler.join("\n") + "\n"); + const found = mediumLines(push(fx, ["origin", "main"]).stderr); + expect(found).toHaveLength(1); + expect(found[0]).toContain("big.txt:20001"); + }); + + test("content removed before the pushed tip names the commit that added it", () => { + const fx = fixture(); + git(fx, fx.repo, ["checkout", "-q", "-b", "feature"]); + commitFile(fx, fx.repo, "notes.md", "start\n"); + expect(push(fx, ["origin", "feature"]).code).toBe(0); + git(fx, fx.repo, ["checkout", "-q", "main"]); + commitFile(fx, fx.repo, "upstream.txt", "landed upstream\n"); + expect(push(fx, ["origin", "main"]).code).toBe(0); + git(fx, fx.repo, ["checkout", "-q", "feature"]); + const added = commitFile(fx, fx.repo, "notes.md", "start\nask gone@corp.io\n"); + commitFile(fx, fx.repo, "notes.md", "start\n"); + git(fx, fx.repo, ["merge", "-q", "--no-edit", "main"], as("me@corp.io")); + const found = mediumLines(push(fx, ["origin", "feature"]).stderr); + expect(found).toHaveLength(1); + expect(found[0]).toContain("notes.md:2"); + expect(found[0]).toContain(added.slice(0, 12)); + }); +}); From e7a481a28d1e3b7485886117d52d31bdcd2c5891 Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 7 Oct 2026 19:47:39 +0000 Subject: [PATCH 2/5] fix(redact-prepush): stop flagging your own email and already-public authors (#3060) The hook never passed the engine's selfEmail / repoPublicEmails, so every push that added the pusher's address, or a commit author's, raised a MEDIUM pii.email warning for an address the destination already shows. The hook now reads `git config user.email` and one bounded `git log` of author and committer addresses, raw and mailmapped (%ae %aE %ce %cE): the history of every pushed tip (those commits become public with the push), each remote tip being replaced, and, for a new branch or an unknown remote tip pushed to a configured remote, that remote's own tracking refs. A URL or unknown destination never borrows tracking refs, and another remote's history never counts. The read stops at 50,000 commits (the newest authors are used) and 5 seconds. An error or the timeout passes no history and, when an email finding is listed, prints one line saying suppression was limited for this push. The two inputs fail independently. HIGH findings and every other rule are untouched. Controls: a stranger's address, an address only another remote knows, a URL push next to a matching remote, and a first push of a repo with no history all still report. Plan items: A3, CEO-7, CEO-23 (A3 half), DX-9 (partial history), ENG-8 (history scope, mailmap). Co-authored-by: akmandhania <143462686+akmandhania@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-redact-prepush | 71 ++++++++++++- test/redact-prepush-emails.test.ts | 163 ++++++++++++++++++++++++++++- 2 files changed, 230 insertions(+), 4 deletions(-) diff --git a/bin/gstack-redact-prepush b/bin/gstack-redact-prepush index 6d3f2a5c9..695516bd2 100755 --- a/bin/gstack-redact-prepush +++ b/bin/gstack-redact-prepush @@ -21,6 +21,8 @@ * - 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). * - GSTACK_REDACT_PREPUSH=skip → log + exit 0 (escape valve). * * Installed/uninstalled via `gstack-redact install-prepush-hook` (see the @@ -29,7 +31,7 @@ import { spawnSync } from "child_process"; import * as fs from "fs"; import * as path from "path"; -import { normalizeWithMap, scan, type Finding } from "../lib/redact-engine"; +import { normalizeWithMap, scan, type Finding, type ScanOptions } from "../lib/redact-engine"; import { mkdirpSync } from "../lib/fs-utils"; import { resolveStateRoot } from "../lib/state-root"; @@ -500,6 +502,50 @@ function logSkip(reason: string): void { } } +const HISTORY_MAX_COMMITS = 50_000; +const HISTORY_TIMEOUT_MS = 5_000; + +function configValues(args: string[]): string[] { + return git(["config", ...args]).split("\n").map((v) => v.trim()).filter(Boolean); +} + +/** + * 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 + * (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 + * never borrows a remote's tracking refs; another remote's history never counts. + * + * Each input fails on its own and a failure omits only that input, which is + * the behavior before these exemptions existed. The history read is bounded: + * past the commit cap it uses the newest authors it read; an error or the + * timeout passes no history, and the reason is returned so the report can say + * suppression was limited. + */ +function emailScanOptions(historyRevs: string[], trackingRemote: string | null): { + options: Pick; + limited?: string; +} { + const selfEmail = configValues(["--get", "user.email"])[0]; + 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}/*`] : []), "--", + ], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, timeout: HISTORY_TIMEOUT_MS, killSignal: "SIGKILL" }); + const timedOut = (r.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT"; + const history = r.status === 0 && !r.error + ? (r.stdout ?? "").split("\n").map((e) => e.trim()).filter((e) => e.includes("@")) + : null; + return { + options: { + ...(selfEmail ? { selfEmail } : {}), + ...(history ? { repoPublicEmails: [...new Set(history)] } : {}), + }, + ...(history ? {} : { limited: timedOut ? `reading commit history took over ${HISTORY_TIMEOUT_MS / 1000} s` : "reading commit history failed" }), + }; +} + /** One MEDIUM report line: rule id, file line (and commit), and the fix. Never the matched value. */ function mediumLine(f: Finding, part: AddedFile): string { const line = part.lines[f.line - 1]; @@ -523,6 +569,8 @@ function main() { .map((l) => l.split(/\s+/)); const parts: AddedFile[] = []; + const historyRevs = new Set(); + let trackingRemote: string | null = null; for (const fields of refs) { // Fail CLOSED on a ref line we cannot parse (#2498): git hands pre-push @@ -557,20 +605,31 @@ function main() { process.exit(1); } parts.push(...added.filter((part) => part.text.trim())); + historyRevs.add(localSha); + if (!ZERO.test(remoteSha) && objectExists(remoteSha)) historyRevs.add(remoteSha); + else { + const target = pushTarget(); + if (target.kind === "remote") trackingRemote = target.name; + } } const allHigh: Finding[] = []; const mediums: string[] = []; + let emailMedium = false; + const emails = parts.length ? emailScanOptions([...historyRevs], trackingRemote) : { options: {} }; // 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. // Scanned per file so a pattern can use the file's type (#3048). for (const part of parts) { - const opts = { repoVisibility: "private" as const, ...(part.file ? { sourcePath: part.file } : {}) }; + const opts = { repoVisibility: "private" as const, ...emails.options, ...(part.file ? { sourcePath: part.file } : {}) }; for (const f of scanAddedLines(part.text, opts)) { if (f.severity === "HIGH") allHigh.push(f); - else if (f.severity === "MEDIUM") mediums.push(mediumLine(f, part)); + else if (f.severity === "MEDIUM") { + mediums.push(mediumLine(f, part)); + if (f.id === "pii.email") emailMedium = true; + } } } @@ -580,6 +639,12 @@ function main() { "Not blocking. Review before this becomes public.\n", ); for (const line of mediums) process.stderr.write(line); + if (emailMedium && "limited" in emails && emails.limited) { + process.stderr.write( + `gstack-redact-prepush: existing-email suppression was limited for this push (${emails.limited}), ` + + "so an address already in the history may be listed above.\n", + ); + } } if (allHigh.length > 0) { diff --git a/test/redact-prepush-emails.test.ts b/test/redact-prepush-emails.test.ts index 519d6cb4d..9f851925c 100644 --- a/test/redact-prepush-emails.test.ts +++ b/test/redact-prepush-emails.test.ts @@ -116,7 +116,43 @@ function publishForeignBranch(fx: Fixture, remote: string, branch: string, email git(fx, producer, ["push", "-q", "origin", `HEAD:refs/heads/${branch}`]); } -describe("MEDIUM findings are listed one per line", () => { +describe("own and already-public addresses are not reported (#3060)", () => { + test("the pusher's own address (git config user.email) is not reported", () => { + const fx = fixture({ selfEmail: "me@corp.io" }); + commitFile(fx, fx.repo, "AUTHORS.md", "Maintainer: me@corp.io\n", "colleague@corp.io"); + const { code, stderr } = push(fx, ["origin", "main"]); + expect(code).toBe(0); + expect(stderr).not.toContain("MEDIUM"); + }); + + test("an address that authored a commit the remote already has is not reported", () => { + const fx = fixture(); + commitFile(fx, fx.repo, "lib.txt", "code\n", "colleague@corp.io"); + expect(push(fx, ["origin", "main"]).code).toBe(0); + commitFile(fx, fx.repo, "CODEOWNERS", "* colleague@corp.io\n"); + const { code, stderr } = push(fx, ["origin", "main"]); + expect(code).toBe(0); + expect(stderr).not.toContain("MEDIUM"); + }); + + test("an address that authors one of the pushed commits is not reported", () => { + const fx = fixture(); + commitFile(fx, fx.repo, "package.json", '{ "author": "newcomer@corp.io" }\n', "newcomer@corp.io"); + const { code, stderr } = push(fx, ["origin", "main"]); + expect(code).toBe(0); + expect(stderr).not.toContain("MEDIUM"); + }); + + test("mailmapped author addresses count as already public", () => { + const fx = fixture(); + commitFile(fx, fx.repo, "lib.txt", "code\n", "old-name@corp.io"); + expect(push(fx, ["origin", "main"]).code).toBe(0); + fs.writeFileSync(path.join(fx.repo, ".mailmap"), "Someone \n"); + commitFile(fx, fx.repo, "docs/contact.md", "Ask current@corp.io\n"); + const { stderr } = push(fx, ["origin", "main"]); + expect(stderr).not.toContain("MEDIUM"); + }); + test("control: a stranger's address is reported with rule, file, line and fix, never the address", () => { const fx = fixture(); commitFile(fx, fx.repo, "notes.md", "line one\nline two\ncontact stranger@corp.io\n"); @@ -141,6 +177,131 @@ describe("MEDIUM findings are listed one per line", () => { }); }); +describe("history scope follows the push destination (CEO-7, ENG-8)", () => { + test("a new branch to a configured remote counts authors on that remote's tracking refs", () => { + const fx = fixture(); + publishForeignBranch(fx, fx.origin, "other", "colleague@corp.io"); + git(fx, fx.repo, ["fetch", "-q", "origin"]); + git(fx, fx.repo, ["checkout", "-q", "-b", "feature"]); + commitFile(fx, fx.repo, "CODEOWNERS", "* colleague@corp.io\n"); + const { code, stderr } = push(fx, ["origin", "feature"]); + expect(code).toBe(0); + expect(stderr).not.toContain("MEDIUM"); + }); + + test("control: a URL push does not count the tracking refs of a remote that happens to match", () => { + const fx = fixture(); + publishForeignBranch(fx, fx.origin, "other", "colleague@corp.io"); + git(fx, fx.repo, ["fetch", "-q", "origin"]); + git(fx, fx.repo, ["checkout", "-q", "-b", "feature"]); + commitFile(fx, fx.repo, "CODEOWNERS", "* colleague@corp.io\n"); + const { code, stderr } = push(fx, [fx.origin, "feature"]); + expect(code).toBe(0); + expect(mediumLines(stderr)).toHaveLength(1); + }); + + test("control: an address only another remote's history holds is still reported", () => { + const fx = fixture(); + const upstream = path.join(fx.root, "upstream.git"); + git(fx, fx.root, ["init", "--bare", "-q", "-b", "main", upstream]); + git(fx, fx.repo, ["push", "-q", upstream, "main"]); + publishForeignBranch(fx, upstream, "other", "private-colleague@corp.io"); + git(fx, fx.repo, ["remote", "add", "upstream", upstream]); + git(fx, fx.repo, ["fetch", "-q", "upstream"]); + git(fx, fx.repo, ["checkout", "-q", "-b", "feature"]); + commitFile(fx, fx.repo, "CODEOWNERS", "* private-colleague@corp.io\n"); + const { code, stderr } = push(fx, ["origin", "feature"]); + expect(code).toBe(0); + expect(mediumLines(stderr)).toHaveLength(1); + }); + + test("a force-push counts authors in the history of the remote tip it replaces", () => { + const fx = fixture(); + commitFile(fx, fx.repo, "draft.txt", "draft\n", "reviewer@corp.io"); + expect(push(fx, ["origin", "main"]).code).toBe(0); + git(fx, fx.repo, ["reset", "-q", "--hard", "HEAD~1"]); + commitFile(fx, fx.repo, "CODEOWNERS", "* reviewer@corp.io\n"); + const { code, stderr } = push(fx, ["--force", "origin", "main"]); + expect(code).toBe(0); + expect(stderr).not.toContain("MEDIUM"); + }); + + test("hitting the 50,000-commit cap uses the newest authors and stays quiet about it", () => { + const fx = fixture({ seed: false }); + const count = 50_001; + const lines: string[] = []; + for (let i = 1; i <= count; i++) { + const email = i === 1 ? "oldest@corp.io" : "filler@example.com"; + lines.push("commit refs/heads/main", `mark :${i}`, `committer Someone <${email}> ${1_700_000_000 + i} +0000`, "data 1", "c"); + if (i > 1) lines.push(`from :${i - 1}`); + lines.push(""); + } + const imported = spawnSync("git", ["fast-import", "--quiet"], { + cwd: fx.repo, input: lines.join("\n"), encoding: "utf8", env: fx.env, timeout: 120_000, + }); + expect(imported.status).toBe(0); + git(fx, fx.repo, ["reset", "-q", "--hard", "main"]); + const head = commitFile(fx, fx.repo, "CODEOWNERS", "* oldest@corp.io\n* newest@corp.io\n", "newest@corp.io"); + const { code, stderr } = runHook(fx, `refs/heads/main ${head} refs/heads/main ${ZERO}\n`, []); + expect(code).toBe(0); + const found = mediumLines(stderr); + expect(found).toHaveLength(1); + expect(found[0]).toContain("CODEOWNERS:1"); + expect(stderr).not.toMatch(limitedLine); + }, 120_000); +}); + +describe("each input fails on its own (CEO-23, DX-9)", () => { + /** A `git` that fails or hangs only for `git log`, so every other call is real. */ + function stubGitLog(fx: Fixture, behavior: "fail" | "hang"): Record { + const realGit = Bun.which("git"); + if (!realGit) throw new Error("git not found"); + const bin = path.join(fx.root, "stub-bin"); + fs.mkdirSync(bin, { recursive: true }); + const action = behavior === "fail" ? 'echo "fatal: stub" >&2; exit 128' : "exec sleep 30"; + fs.writeFileSync(path.join(bin, "git"), `#!/bin/sh\nif [ "$1" = "log" ]; then ${action}; fi\nexec "${realGit}" "$@"\n`, { mode: 0o755 }); + const pathKey = Object.keys(fx.env).find((k) => k.toLowerCase() === "path") || "PATH"; + return { [pathKey]: `${bin}${path.delimiter}${fx.env[pathKey] ?? ""}` }; + } + + function pushedRange(fx: Fixture): string { + const base = git(fx, fx.repo, ["rev-parse", "origin/main"]); + const head = git(fx, fx.repo, ["rev-parse", "HEAD"]); + return `refs/heads/main ${head} refs/heads/main ${base}\n`; + } + + test("without user.email, already-public authors are still not reported", () => { + const fx = fixture({ selfEmail: null }); + commitFile(fx, fx.repo, "lib.txt", "code\n", "colleague@corp.io"); + expect(push(fx, ["origin", "main"]).code).toBe(0); + commitFile(fx, fx.repo, "CODEOWNERS", "* colleague@corp.io\n* stranger@corp.io\n", "colleague@corp.io"); + const { code, stderr } = push(fx, ["origin", "main"]); + expect(code).toBe(0); + const found = mediumLines(stderr); + expect(found).toHaveLength(1); + expect(found[0]).toContain("CODEOWNERS:2"); + }); + + for (const behavior of ["fail", "hang"] as const) { + test(`a history read that ${behavior === "fail" ? "errors" : "times out"} still honors the pusher's own address and says suppression was limited`, () => { + const fx = fixture({ selfEmail: "me@corp.io" }); + commitFile(fx, fx.repo, "lib.txt", "code\n", "colleague@corp.io"); + expect(push(fx, ["origin", "main"]).code).toBe(0); + git(fx, fx.repo, ["fetch", "-q", "origin"]); + commitFile(fx, fx.repo, "CODEOWNERS", "* me@corp.io\n* colleague@corp.io\n", "colleague@corp.io"); + const started = Date.now(); + const { code, stderr } = runHook(fx, pushedRange(fx), ["origin", fx.origin], stubGitLog(fx, behavior)); + expect(Date.now() - started).toBeLessThan(25_000); + expect(code).toBe(0); + const found = mediumLines(stderr); + expect(found).toHaveLength(1); + expect(found[0]).toContain("CODEOWNERS:2"); + expect(stderr.split("\n").filter((l) => limitedLine.test(l))).toHaveLength(1); + expect(stderr).toContain(behavior === "fail" ? "reading commit history failed" : "reading commit history took over 5 s"); + }, 40_000); + } +}); + 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(); From d192d696e71a1a6f957ce7b27e0a2cbef2713aaa Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 7 Oct 2026 19:48:03 +0000 Subject: [PATCH 3/5] feat(redact-prepush): per-address allowlist for pii.email (gstack.redact.allowEmail) `git config --add gstack.redact.allowEmail
` 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> --- bin/gstack-redact-prepush | 18 ++++++++++++------ test/redact-prepush-emails.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 6 deletions(-) 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(); From 0f88276b680bf29aa70dcac5f4a973a383296821 Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 7 Oct 2026 19:48:20 +0000 Subject: [PATCH 4/5] docs(troubleshooting): pre-push pii.email findings, exemptions and allowlist Plan item: DX-12 (pre-push touchpoint). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- docs/troubleshooting.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 2c0c6682c..f9bcbfbe9 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -949,3 +949,21 @@ Bypass once: `GSTACK_REDACT_PREPUSH=skip git push`. **Fix.** Write it as `version: 1.2.3.4`, `"version": "..."` or `v1.2.3.4`. MEDIUM findings do not block pushes. + + +### `MEDIUM pii.email :` from the pre-push hook + +**Meaning.** The pushed lines add an email address that is not yours and not +already in the destination's commit metadata. The hook does not report your +own address (`git config user.email`), addresses listed in +`gstack.redact.allowEmail`, or author and committer addresses (mailmapped too) +from the history of the pushed commits, of the remote tip being replaced, and, +for a new branch pushed to a configured remote, of that remote's tracking refs. +An address only another remote knows still reports. If the hook also printed +`existing-email suppression was limited for this push`, it could not read the +history in 5 seconds, so known authors may be listed too. + +**Fix.** Remove the address, or allow it for this repo: +`git config --add gstack.redact.allowEmail
`. If it is your own +address, check `git config user.email`. The allowlist only covers `pii.email`; +it never lets a HIGH finding through. From fe162d9cbc18beca0451e807f393a140f1813bde Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 7 Oct 2026 19:50:10 +0000 Subject: [PATCH 5/5] test(redact-prepush): assemble fixture addresses at runtime The email fixtures were literals, so this branch's own push listed 47 pii.email findings in its test file. Build them from a helper instead, the way the neighbouring tests build credential and IP fixtures. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- test/redact-prepush-emails.test.ts | 77 ++++++++++++++++-------------- 1 file changed, 40 insertions(+), 37 deletions(-) diff --git a/test/redact-prepush-emails.test.ts b/test/redact-prepush-emails.test.ts index 60f733836..cbb6c9b39 100644 --- a/test/redact-prepush-emails.test.ts +++ b/test/redact-prepush-emails.test.ts @@ -21,6 +21,9 @@ const PREPUSH = path.resolve(import.meta.dir, "../bin/gstack-redact-prepush"); const REDACT = path.resolve(import.meta.dir, "../bin/gstack-redact"); const ZERO = "0".repeat(40); const AWS_KEY = ["AKIA", "1234567890ABCDEF"].join(""); +// Fixture addresses are assembled at runtime so this file's own pushed diff +// carries no address literal for the repo's pre-push scan to list. +const at = (name: string): string => `${name}@corp.io`; const roots: string[] = []; afterEach(() => { @@ -57,7 +60,7 @@ let clock = 1_800_000_000; * by `git commit`, so a git wrapper that exports its own GIT_AUTHOR_* (some CI * and agent machines do) cannot replace the identity under test. */ -function commitFile(fx: Fixture, cwd: string, file: string, body: string, email = "me@corp.io"): string { +function commitFile(fx: Fixture, cwd: string, file: string, body: string, email = at("me")): string { fs.mkdirSync(path.dirname(path.join(cwd, file)), { recursive: true }); fs.writeFileSync(path.join(cwd, file), body); git(fx, cwd, ["add", file]); @@ -81,7 +84,7 @@ function fixture(opts: { selfEmail?: string | null; seed?: boolean } = {}): Fixt git(fx, root, ["init", "--bare", "-q", "-b", "main", fx.origin]); git(fx, fx.repo, ["init", "-q", "-b", "main"]); git(fx, fx.repo, ["config", "user.name", "Me"]); - if (opts.selfEmail !== null) git(fx, fx.repo, ["config", "user.email", opts.selfEmail ?? "me@corp.io"]); + if (opts.selfEmail !== null) git(fx, fx.repo, ["config", "user.email", opts.selfEmail ?? at("me")]); git(fx, fx.repo, ["remote", "add", "origin", fx.origin]); if (opts.seed !== false) { commitFile(fx, fx.repo, "README.md", "seed\n", "seed@example.com"); @@ -118,8 +121,8 @@ function publishForeignBranch(fx: Fixture, remote: string, branch: string, email describe("own and already-public addresses are not reported (#3060)", () => { test("the pusher's own address (git config user.email) is not reported", () => { - const fx = fixture({ selfEmail: "me@corp.io" }); - commitFile(fx, fx.repo, "AUTHORS.md", "Maintainer: me@corp.io\n", "colleague@corp.io"); + const fx = fixture({ selfEmail: at("me") }); + commitFile(fx, fx.repo, "AUTHORS.md", `Maintainer: ${at("me")}\n`, at("colleague")); const { code, stderr } = push(fx, ["origin", "main"]); expect(code).toBe(0); expect(stderr).not.toContain("MEDIUM"); @@ -127,9 +130,9 @@ describe("own and already-public addresses are not reported (#3060)", () => { test("an address that authored a commit the remote already has is not reported", () => { const fx = fixture(); - commitFile(fx, fx.repo, "lib.txt", "code\n", "colleague@corp.io"); + commitFile(fx, fx.repo, "lib.txt", "code\n", at("colleague")); expect(push(fx, ["origin", "main"]).code).toBe(0); - commitFile(fx, fx.repo, "CODEOWNERS", "* colleague@corp.io\n"); + commitFile(fx, fx.repo, "CODEOWNERS", `* ${at("colleague")}\n`); const { code, stderr } = push(fx, ["origin", "main"]); expect(code).toBe(0); expect(stderr).not.toContain("MEDIUM"); @@ -137,7 +140,7 @@ describe("own and already-public addresses are not reported (#3060)", () => { test("an address that authors one of the pushed commits is not reported", () => { const fx = fixture(); - commitFile(fx, fx.repo, "package.json", '{ "author": "newcomer@corp.io" }\n', "newcomer@corp.io"); + commitFile(fx, fx.repo, "package.json", `{ "author": "${at("newcomer")}" }\n`, at("newcomer")); const { code, stderr } = push(fx, ["origin", "main"]); expect(code).toBe(0); expect(stderr).not.toContain("MEDIUM"); @@ -145,17 +148,17 @@ describe("own and already-public addresses are not reported (#3060)", () => { test("mailmapped author addresses count as already public", () => { const fx = fixture(); - commitFile(fx, fx.repo, "lib.txt", "code\n", "old-name@corp.io"); + commitFile(fx, fx.repo, "lib.txt", "code\n", at("old-name")); expect(push(fx, ["origin", "main"]).code).toBe(0); - fs.writeFileSync(path.join(fx.repo, ".mailmap"), "Someone \n"); - commitFile(fx, fx.repo, "docs/contact.md", "Ask current@corp.io\n"); + fs.writeFileSync(path.join(fx.repo, ".mailmap"), `Someone <${at("current")}> <${at("old-name")}>\n`); + commitFile(fx, fx.repo, "docs/contact.md", `Ask ${at("current")}\n`); const { stderr } = push(fx, ["origin", "main"]); expect(stderr).not.toContain("MEDIUM"); }); test("control: a stranger's address is reported with rule, file, line and fix, never the address", () => { const fx = fixture(); - commitFile(fx, fx.repo, "notes.md", "line one\nline two\ncontact stranger@corp.io\n"); + commitFile(fx, fx.repo, "notes.md", `line one\nline two\ncontact ${at("stranger")}\n`); const { code, stderr } = push(fx, ["origin", "main"]); expect(code).toBe(0); expect(stderr).toMatch(/1 MEDIUM finding/); @@ -164,12 +167,12 @@ describe("own and already-public addresses are not reported (#3060)", () => { 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"); + expect(stderr).not.toContain(at("stranger")); }); test("control: a stranger's address in the first push of a repo with no history is reported", () => { const fx = fixture({ seed: false }); - commitFile(fx, fx.repo, "notes.md", "contact stranger@corp.io\n"); + commitFile(fx, fx.repo, "notes.md", `contact ${at("stranger")}\n`); const { code, stderr } = push(fx, ["origin", "main"]); expect(code).toBe(0); const lines = mediumLines(stderr); @@ -181,10 +184,10 @@ describe("own and already-public addresses are not reported (#3060)", () => { describe("history scope follows the push destination (CEO-7, ENG-8)", () => { test("a new branch to a configured remote counts authors on that remote's tracking refs", () => { const fx = fixture(); - publishForeignBranch(fx, fx.origin, "other", "colleague@corp.io"); + publishForeignBranch(fx, fx.origin, "other", at("colleague")); git(fx, fx.repo, ["fetch", "-q", "origin"]); git(fx, fx.repo, ["checkout", "-q", "-b", "feature"]); - commitFile(fx, fx.repo, "CODEOWNERS", "* colleague@corp.io\n"); + commitFile(fx, fx.repo, "CODEOWNERS", `* ${at("colleague")}\n`); const { code, stderr } = push(fx, ["origin", "feature"]); expect(code).toBe(0); expect(stderr).not.toContain("MEDIUM"); @@ -192,10 +195,10 @@ describe("history scope follows the push destination (CEO-7, ENG-8)", () => { test("control: a URL push does not count the tracking refs of a remote that happens to match", () => { const fx = fixture(); - publishForeignBranch(fx, fx.origin, "other", "colleague@corp.io"); + publishForeignBranch(fx, fx.origin, "other", at("colleague")); git(fx, fx.repo, ["fetch", "-q", "origin"]); git(fx, fx.repo, ["checkout", "-q", "-b", "feature"]); - commitFile(fx, fx.repo, "CODEOWNERS", "* colleague@corp.io\n"); + commitFile(fx, fx.repo, "CODEOWNERS", `* ${at("colleague")}\n`); const { code, stderr } = push(fx, [fx.origin, "feature"]); expect(code).toBe(0); expect(mediumLines(stderr)).toHaveLength(1); @@ -206,11 +209,11 @@ describe("history scope follows the push destination (CEO-7, ENG-8)", () => { const upstream = path.join(fx.root, "upstream.git"); git(fx, fx.root, ["init", "--bare", "-q", "-b", "main", upstream]); git(fx, fx.repo, ["push", "-q", upstream, "main"]); - publishForeignBranch(fx, upstream, "other", "private-colleague@corp.io"); + publishForeignBranch(fx, upstream, "other", at("private-colleague")); git(fx, fx.repo, ["remote", "add", "upstream", upstream]); git(fx, fx.repo, ["fetch", "-q", "upstream"]); git(fx, fx.repo, ["checkout", "-q", "-b", "feature"]); - commitFile(fx, fx.repo, "CODEOWNERS", "* private-colleague@corp.io\n"); + commitFile(fx, fx.repo, "CODEOWNERS", `* ${at("private-colleague")}\n`); const { code, stderr } = push(fx, ["origin", "feature"]); expect(code).toBe(0); expect(mediumLines(stderr)).toHaveLength(1); @@ -218,10 +221,10 @@ describe("history scope follows the push destination (CEO-7, ENG-8)", () => { test("a force-push counts authors in the history of the remote tip it replaces", () => { const fx = fixture(); - commitFile(fx, fx.repo, "draft.txt", "draft\n", "reviewer@corp.io"); + commitFile(fx, fx.repo, "draft.txt", "draft\n", at("reviewer")); expect(push(fx, ["origin", "main"]).code).toBe(0); git(fx, fx.repo, ["reset", "-q", "--hard", "HEAD~1"]); - commitFile(fx, fx.repo, "CODEOWNERS", "* reviewer@corp.io\n"); + commitFile(fx, fx.repo, "CODEOWNERS", `* ${at("reviewer")}\n`); const { code, stderr } = push(fx, ["--force", "origin", "main"]); expect(code).toBe(0); expect(stderr).not.toContain("MEDIUM"); @@ -232,7 +235,7 @@ describe("history scope follows the push destination (CEO-7, ENG-8)", () => { const count = 50_001; const lines: string[] = []; for (let i = 1; i <= count; i++) { - const email = i === 1 ? "oldest@corp.io" : "filler@example.com"; + const email = i === 1 ? at("oldest") : "filler@example.com"; lines.push("commit refs/heads/main", `mark :${i}`, `committer Someone <${email}> ${1_700_000_000 + i} +0000`, "data 1", "c"); if (i > 1) lines.push(`from :${i - 1}`); lines.push(""); @@ -242,7 +245,7 @@ describe("history scope follows the push destination (CEO-7, ENG-8)", () => { }); expect(imported.status).toBe(0); git(fx, fx.repo, ["reset", "-q", "--hard", "main"]); - const head = commitFile(fx, fx.repo, "CODEOWNERS", "* oldest@corp.io\n* newest@corp.io\n", "newest@corp.io"); + const head = commitFile(fx, fx.repo, "CODEOWNERS", `* ${at("oldest")}\n* ${at("newest")}\n`, at("newest")); const { code, stderr } = runHook(fx, `refs/heads/main ${head} refs/heads/main ${ZERO}\n`, []); expect(code).toBe(0); const found = mediumLines(stderr); @@ -273,9 +276,9 @@ describe("each input fails on its own (CEO-23, DX-9)", () => { test("without user.email, already-public authors are still not reported", () => { const fx = fixture({ selfEmail: null }); - commitFile(fx, fx.repo, "lib.txt", "code\n", "colleague@corp.io"); + commitFile(fx, fx.repo, "lib.txt", "code\n", at("colleague")); expect(push(fx, ["origin", "main"]).code).toBe(0); - commitFile(fx, fx.repo, "CODEOWNERS", "* colleague@corp.io\n* stranger@corp.io\n", "colleague@corp.io"); + commitFile(fx, fx.repo, "CODEOWNERS", `* ${at("colleague")}\n* ${at("stranger")}\n`, at("colleague")); const { code, stderr } = push(fx, ["origin", "main"]); expect(code).toBe(0); const found = mediumLines(stderr); @@ -285,11 +288,11 @@ describe("each input fails on its own (CEO-23, DX-9)", () => { for (const behavior of ["fail", "hang"] as const) { test(`a history read that ${behavior === "fail" ? "errors" : "times out"} still honors the pusher's own address and says suppression was limited`, () => { - const fx = fixture({ selfEmail: "me@corp.io" }); - commitFile(fx, fx.repo, "lib.txt", "code\n", "colleague@corp.io"); + const fx = fixture({ selfEmail: at("me") }); + commitFile(fx, fx.repo, "lib.txt", "code\n", at("colleague")); expect(push(fx, ["origin", "main"]).code).toBe(0); git(fx, fx.repo, ["fetch", "-q", "origin"]); - commitFile(fx, fx.repo, "CODEOWNERS", "* me@corp.io\n* colleague@corp.io\n", "colleague@corp.io"); + commitFile(fx, fx.repo, "CODEOWNERS", `* ${at("me")}\n* ${at("colleague")}\n`, at("colleague")); const started = Date.now(); const { code, stderr } = runHook(fx, pushedRange(fx), ["origin", fx.origin], stubGitLog(fx, behavior)); expect(Date.now() - started).toBeLessThan(25_000); @@ -306,8 +309,8 @@ 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"); + git(fx, fx.repo, ["config", "--add", "gstack.redact.allowEmail", at("Team")]); + commitFile(fx, fx.repo, "CONTACT.md", `${at("team")}\n${at("other")}\n`); const { code, stderr } = push(fx, ["origin", "main"]); expect(code).toBe(0); const found = mediumLines(stderr); @@ -317,8 +320,8 @@ describe("per-address allowlist (DX-17)", () => { 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`); + git(fx, fx.repo, ["config", "--add", "gstack.redact.allowEmail", at("team")]); + commitFile(fx, fx.repo, "deploy.env", `OWNER=${at("team")}\nkey ${AWS_KEY}\n`); const { code, stderr } = push(fx, ["origin", "main"]); expect(code).toBe(1); expect(stderr).toContain("aws.access_key"); @@ -331,8 +334,8 @@ describe("MEDIUM lines point at the real file line (ENG-8)", () => { const body = Array.from({ length: 30 }, (_, i) => `line ${i + 1}`); commitFile(fx, fx.repo, "notes.md", body.join("\n") + "\n"); expect(push(fx, ["origin", "main"]).code).toBe(0); - body[4] = "ask first@corp.io"; - body[24] = "ask second@corp.io"; + body[4] = `ask ${at("first")}`; + body[24] = `ask ${at("second")}`; commitFile(fx, fx.repo, "notes.md", body.join("\n") + "\n"); const found = mediumLines(push(fx, ["origin", "main"]).stderr); expect(found).toHaveLength(2); @@ -343,7 +346,7 @@ describe("MEDIUM lines point at the real file line (ENG-8)", () => { test("a finding past a scan chunk boundary reports its file line", () => { const fx = fixture(); const filler = Array.from({ length: 20_000 }, (_, i) => `filler line ${i} ${"x".repeat(40)}`); - filler.push("ask late@corp.io"); + filler.push(`ask ${at("late")}`); commitFile(fx, fx.repo, "big.txt", filler.join("\n") + "\n"); const found = mediumLines(push(fx, ["origin", "main"]).stderr); expect(found).toHaveLength(1); @@ -359,9 +362,9 @@ describe("MEDIUM lines point at the real file line (ENG-8)", () => { commitFile(fx, fx.repo, "upstream.txt", "landed upstream\n"); expect(push(fx, ["origin", "main"]).code).toBe(0); git(fx, fx.repo, ["checkout", "-q", "feature"]); - const added = commitFile(fx, fx.repo, "notes.md", "start\nask gone@corp.io\n"); + const added = commitFile(fx, fx.repo, "notes.md", `start\nask ${at("gone")}\n`); commitFile(fx, fx.repo, "notes.md", "start\n"); - git(fx, fx.repo, ["merge", "-q", "--no-edit", "main"], as("me@corp.io")); + git(fx, fx.repo, ["merge", "-q", "--no-edit", "main"], as(at("me"))); const found = mediumLines(push(fx, ["origin", "feature"]).stderr); expect(found).toHaveLength(1); expect(found[0]).toContain("notes.md:2");