From 0340862e010df4c0a267d28f9e16774c26358c3b Mon Sep 17 00:00:00 2001 From: alopes50 Date: Sat, 29 Aug 2026 17:44:43 -0700 Subject: [PATCH] fix: don't flag git SSH remotes as pii.email `pii.email` matches the `git@github.com` inside `git@github.com:acme/widgets.git`. That is a transport user@host, not a person's address, so any diff touching a clone URL -- a deploy config's repo URL, a submodule entry, a README clone line -- draws a spurious MEDIUM from the pre-push hook. Suppressed by URL shape rather than by adding `git` to EMAIL_ALLOW_LOCALPARTS. A bare `git@` allowlist entry would also suppress a genuine address at a domain that merely begins with "git" (git@gitmail.com), converting a false positive into a false negative -- the worse failure for a guardrail. Two shapes are accepted: - `@:.git` for ANY host, covering self-hosted remotes, plus the equivalent ssh:// URL form. - `git@` for github.com, gitlab.com, bitbucket.org and ssh.dev.azure.com, whose bare form appears in docs and in `ssh -T git@github.com` connectivity checks with no path at all. Matched exactly, so gitmail.com is unaffected. emailAllowed now receives the normalized text and the span offset so it can see that surrounding shape; it had only ever been passed the matched span. Tests pin both directions: the SSH remotes go quiet, and a real address still fires -- including at a git host (alex@github.com) and at a git-prefixed domain (git@gitmail.com). Co-Authored-By: Claude Opus 5 (1M context) --- lib/redact-engine.ts | 63 ++++++++++++++++++++++++++++++++++++-- test/redact-engine.test.ts | 22 +++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/lib/redact-engine.ts b/lib/redact-engine.ts index c337e660e..de1738e3e 100644 --- a/lib/redact-engine.ts +++ b/lib/redact-engine.ts @@ -83,6 +83,17 @@ const DEFAULT_MAX_BYTES = 1024 * 1024; // 1 MiB const EMAIL_ALLOW_DOMAINS = [/@example\.(com|org|net)$/i, /@example\.[a-z]{2,}$/i]; const EMAIL_ALLOW_LOCALPARTS = [/^noreply@/i, /^no-reply@/i, /^donotreply@/i]; +/** + * Hosts whose `git@` is a git transport endpoint, never a person's + * mailbox. Matched EXACTLY — a domain that merely starts with "git" + * (gitmail.com) is a normal domain and must keep firing. + */ +const SSH_GIT_HOSTS = new Set([ + "github.com", + "gitlab.com", + "bitbucket.org", + "ssh.dev.azure.com", +]); // ── Normalization ───────────────────────────────────────────────────────────── @@ -240,12 +251,59 @@ function hasNear( // ── Email allowlist ─────────────────────────────────────────────────────────── -function emailAllowed(email: string, opts: ScanOptions): boolean { +/** + * True when the matched "email" is really the user@host of a git SSH remote. + * + * `pii.email` matches the `git@github.com` inside + * `git@github.com:org/repo.git` — a transport identity, not PII. This keys on + * the surrounding URL SHAPE, not on the `git` local part: allowlisting `git@` + * outright would also suppress a genuine address at a domain that merely + * begins with "git" (e.g. git@gitmail.com), turning a false positive into a + * false negative. + * + * Two accepted shapes: + * - scp-like `@:` where the path ends in `.git`, for any + * host — this covers self-hosted remotes. + * - `git@` for the major forges, whose bare form appears in docs + * and in `ssh -T git@github.com` connectivity checks with no path at all. + */ +function isSshGitRemote(email: string, text: string, spanStart: number): boolean { + const at = email.lastIndexOf("@"); + if (at < 0) return false; + const local = email.slice(0, at).toLowerCase(); + const host = email.slice(at + 1).toLowerCase(); + + // Major forges: `git@host`, with or without a trailing path. + if (local === "git" && SSH_GIT_HOSTS.has(host)) return true; + + // Any host in `@:.git` position. The path stops at + // whitespace or a quote so a trailing delimiter never defeats the suffix. + const rest = text.slice(spanStart + email.length, spanStart + email.length + 512); + const scp = /^:(?!\/)([^\s'"`<>]*)/.exec(rest); + if (scp && /\.git\/?$/.test(scp[1])) return true; + + // ssh:// URL form: ssh://@/.git + const before = text.slice(Math.max(0, spanStart - 16), spanStart); + if (/(?:git\+)?ssh:\/\/$/i.test(before)) { + const slash = /^\/([^\s'"`<>]*)/.exec(rest); + if (slash && /\.git\/?$/.test(slash[1])) return true; + } + + return false; +} + +function emailAllowed( + email: string, + opts: ScanOptions, + text: string, + spanStart: number, +): boolean { const lower = email.toLowerCase(); if (opts.selfEmail && lower === opts.selfEmail.toLowerCase()) return true; if (opts.repoPublicEmails?.some((e) => e.toLowerCase() === lower)) return true; if (EMAIL_ALLOW_DOMAINS.some((re) => re.test(email))) return true; if (EMAIL_ALLOW_LOCALPARTS.some((re) => re.test(email))) return true; + if (isSshGitRemote(email, text, spanStart)) return true; return false; } @@ -322,7 +380,8 @@ export function scan(input: string, opts: ScanOptions = {}): ScanResult { } // Email allowlist (layered on top of the pattern). - if (pat.id === "pii.email" && emailAllowed(span, opts)) continue; + if (pat.id === "pii.email" && emailAllowed(span, opts, normalized, normOffset)) + continue; const origOffset = map[Math.min(normOffset, map.length - 1)] ?? 0; const key = `${pat.id}:${origOffset}`; diff --git a/test/redact-engine.test.ts b/test/redact-engine.test.ts index 8b86c471f..67598e79f 100644 --- a/test/redact-engine.test.ts +++ b/test/redact-engine.test.ts @@ -312,6 +312,28 @@ describe("PII patterns", () => { scan("bob@acme.co", { repoVisibility: "private", repoPublicEmails: ["bob@acme.co"] }).findings, ).toHaveLength(0); }); + // A git SSH remote's `git@host` is a transport user@host, not a person's + // address. Suppressed by URL SHAPE rather than by allowlisting the `git` + // local part: a bare `git@` entry would also silently hide a real address + // at a domain that merely starts with "git". + test("ssh git remotes are not flagged as emails", () => { + expect(ids("set :repo_url, 'git@github.com:acme/widgets.git'")).not.toContain( + "pii.email", + ); + expect(ids("git clone git@gitlab.com:acme/widgets.git")).not.toContain("pii.email"); + expect(ids("git@bitbucket.org:acme/widgets.git")).not.toContain("pii.email"); + expect(ids("git@ssh.dev.azure.com:v3/acme/widgets/widgets")).not.toContain("pii.email"); + expect(ids("ssh -T git@github.com")).not.toContain("pii.email"); + // General case: any host in @:.git position. + expect(ids("git@git.acme-internal.net:infra/tools.git")).not.toContain("pii.email"); + expect(ids("ssh://git@scm.acme-internal.net/infra/tools.git")).not.toContain("pii.email"); + }); + test("a real address is still flagged, including at a git host", () => { + expect(ids("ping alex@github.com about the issue")).toContain("pii.email"); + // A domain that merely STARTS WITH "git" is not a git host — this is the + // case a bare `git@` local-part allowlist would have wrongly suppressed. + expect(ids("contact git@gitmail.com for access")).toContain("pii.email"); + }); test("phone E.164 flags, skips compact timestamps", () => { expect(ids("call +14155550123 now")).toContain("pii.phone.e164"); expect(ids("backup stamp 20260727202423 ran late")).not.toContain("pii.phone.e164");