diff --git a/bin/gstack-redact-prepush b/bin/gstack-redact-prepush index 3da23c7ad..98e9b931c 100755 --- a/bin/gstack-redact-prepush +++ b/bin/gstack-redact-prepush @@ -19,7 +19,11 @@ * * 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. + * - `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 @@ -28,7 +32,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"; @@ -210,7 +214,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 +240,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 +297,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 +306,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 +324,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 +393,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 +454,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 +479,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 +503,63 @@ 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`), 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 + * 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 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}/*`] : []), "--", + ], { 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 } : {}), + repoPublicEmails: [...new Set([...(history ?? []), ...allowed])], + }, + ...(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]; + const where = `${part.file || "(unknown file)"}${line ? `:${line}` : ""}` + + (part.commit ? ` in commit ${part.commit.slice(0, 12)}` : ""); + 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() { if ((process.env.GSTACK_REDACT_PREPUSH || "").toLowerCase() === "skip") { logSkip(process.env.GSTACK_REDACT_PREPUSH_REASON || "env-skip"); @@ -476,8 +574,9 @@ function main() { .filter(Boolean) .map((l) => l.split(/\s+/)); - const allHigh: Finding[] = []; - let mediumCount = 0; + 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 @@ -511,25 +610,47 @@ 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())); + 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, ...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)); + if (f.id === "pii.email") emailMedium = true; } } } - 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 (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/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. diff --git a/test/redact-prepush-emails.test.ts b/test/redact-prepush-emails.test.ts new file mode 100644 index 000000000..cbb6c9b39 --- /dev/null +++ b/test/redact-prepush-emails.test.ts @@ -0,0 +1,373 @@ +/** + * 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(""); +// 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(() => { + 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 = 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]); + 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 ?? 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"); + 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("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: 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"); + }); + + 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", at("colleague")); + expect(push(fx, ["origin", "main"]).code).toBe(0); + commitFile(fx, fx.repo, "CODEOWNERS", `* ${at("colleague")}\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": "${at("newcomer")}" }\n`, at("newcomer")); + 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", at("old-name")); + expect(push(fx, ["origin", "main"]).code).toBe(0); + 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 ${at("stranger")}\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(lines[0]).toContain("git config --add gstack.redact.allowEmail
"); + 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 ${at("stranger")}\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("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", at("colleague")); + git(fx, fx.repo, ["fetch", "-q", "origin"]); + git(fx, fx.repo, ["checkout", "-q", "-b", "feature"]); + commitFile(fx, fx.repo, "CODEOWNERS", `* ${at("colleague")}\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", at("colleague")); + git(fx, fx.repo, ["fetch", "-q", "origin"]); + git(fx, fx.repo, ["checkout", "-q", "-b", "feature"]); + 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); + }); + + 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", 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", `* ${at("private-colleague")}\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", at("reviewer")); + expect(push(fx, ["origin", "main"]).code).toBe(0); + git(fx, fx.repo, ["reset", "-q", "--hard", "HEAD~1"]); + 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"); + }); + + 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 ? 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(""); + } + 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", `* ${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); + 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", at("colleague")); + expect(push(fx, ["origin", "main"]).code).toBe(0); + 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); + 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: 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", `* ${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); + 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("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", 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); + 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", 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"); + }); +}); + +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 ${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); + 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 ${at("late")}`); + 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 ${at("gone")}\n`); + commitFile(fx, fx.repo, "notes.md", "start\n"); + 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"); + expect(found[0]).toContain(added.slice(0, 12)); + }); +});