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:

  - `<user>@<host>:<path>.git` for ANY host, covering self-hosted
    remotes, plus the equivalent ssh:// URL form.
  - `git@<known-host>` 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) <noreply@anthropic.com>
This commit is contained in:
alopes50
2026-08-31 20:52:29 +00:00
committed by Garry Tan
co-authored by Claude Opus 5
parent cc94bc34ba
commit 0340862e01
2 changed files with 83 additions and 2 deletions
+61 -2
View File
@@ -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@<host>` 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 `<user>@<host>:<path>` where the path ends in `.git`, for any
* host — this covers self-hosted remotes.
* - `git@<known-host>` 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 `<user>@<host>:<path>.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://<user>@<host>/<path>.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}`;
+22
View File
@@ -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 <user>@<host>:<path>.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");