fix(redact): pii.phone skips bare numeric constants; pii.email skips package@version filenames

Fixes #3577
Fixes #3576

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-29 03:11:33 +00:00
1 parent 4729f4752b
commit 0ff98dba1f
2 files changed
+145 -2

No files matched your search

+53 -2
View File
@@ -201,6 +201,35 @@ function looksLikeCompactTimestamp(span: string): boolean {
);
}
/**
* Bare integers that programs print as constants: 2^30 through 2^49 and each
* one minus one (`4294967296`, `2147483647`), and the 32-bit multipliers,
* offsets and primes of common hashes and PRNGs (Knuth's multiplicative hash,
* the golden-ratio constant, the glibc and Numerical Recipes LCGs, FNV-1a,
* MurmurHash2 and 3, the MT19937 seeder, the largest 32-bit prime). pii.phone.e164
* reads each as a bare 10-15 digit number. Only the exact separator-free value
* is exempt: the same digits with a `+`, spaces, dashes or dots still report,
* so an E.164 number, which carries its `+`, is never exempt.
*/
const NUMERIC_CONSTANTS: ReadonlySet<string> = new Set([
...Array.from({ length: 20 }, (_, i) => 2n ** BigInt(30 + i)).flatMap((p) => [String(p), String(p - 1n)]),
"2654435761",
"2654435769",
"1103515245",
"1013904223",
"2166136261",
"1540483477",
"3432918353",
"2246822507",
"3266489909",
"1812433253",
"4294967291",
]);
export function isNumericConstant(span: string): boolean {
return NUMERIC_CONSTANTS.has(span);
}
/** Context window for pairing a normalized parcel ID with its punctuated form. */
const PARCEL_CONTEXT_CHARS = 400;
@@ -446,6 +475,25 @@ export function isDotenvFilename(match: RegExpExecArray): boolean {
return spanStart > 0 && input[spanStart - 1] === ".";
}
/** File extensions that are not top-level domains (checked against the IANA
* root zone list, 2026-10-28). Extensions that are also TLDs (`.md`, `.py`,
* `.sh`, `.rs`, `.so`, `.zip`, `.mov`, `.map`) are left out on purpose. */
const NON_TLD_EXTENSION = /\.(?:json|jsonc|jsonl|ndjson|ya?ml|toml|lock|txt|csv|tsv|log|ini|cfg|conf|xml|html?|[mc]?js|jsx|tsx?|tgz|gz|tar|bz2|xz|zst|whl|jar|gem|nupkg|deb|rpm|wasm|svg|png|jpe?g|pdf|patch|diff)$/i;
/**
* True when a pii.email span is a filename with an `@` in it rather than an
* address: `terminal-bench@2.0.json`, `lodash@4.17.21.tgz`,
* `eslint-config@next.yaml`. The package@version convention puts a version
* after the `@`, so either sign decides: the "domain" ends in a file extension
* no TLD uses, or it is a dotted version (two or more all-digit labels) with
* one label after it. `user@163.com` (one numeric label) and an address at a
* TLD that doubles as an extension (`dev@acme.sh`, `ops@acme.md`) still report.
*/
export function looksLikeAtFilename(span: string): boolean {
const domain = span.slice(span.lastIndexOf("@") + 1);
return NON_TLD_EXTENSION.test(domain) || /^\d+(?:\.\d+)+\.[A-Za-z]{2,}$/.test(domain);
}
/** Extensions that mark a `<name>.local.<ext>` span as a file. Closed on
* purpose: `.md` is also Moldova's TLD, but no internal suffix sits in front
* of a public one, so after `.local` it can only be an extension. */
@@ -955,6 +1003,7 @@ export const PATTERNS: RedactPattern[] = [
regex: /\b([A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,})\b/,
autoRedactable: true,
redactToken: "<REDACTED-EMAIL>",
validate: (span) => !looksLikeAtFilename(span),
// Engine layers the email allowlist (example.com, noreply@, user's own,
// repo-public authors) on top of this — see redact-engine.ts.
},
@@ -969,12 +1018,14 @@ export const PATTERNS: RedactPattern[] = [
// A digit-only UUID's hyphen groups read as national phone formatting, and
// so do a county tax-map parcel ID (see looksLikeParcelId), vector
// coordinates (looksLikeDecimalCoordinates) and seed/nonce/timestamp JSON
// values (isNumericMetadataValue), and GitHub Actions run and job ids in
// their id positions (isCiRunIdentifier).
// values (isNumericMetadataValue), GitHub Actions run and job ids in
// their id positions (isCiRunIdentifier), and bare powers of two and
// hash/PRNG constants (isNumericConstant).
validate: (span, match) =>
!insideUuid(match) &&
span.replace(/\D/g, "").length >= 10 &&
!looksLikeCompactTimestamp(span) &&
!isNumericConstant(span) &&
!looksLikeParcelId(span, match) &&
!looksLikeDecimalCoordinates(span) &&
!isNumericMetadataValue(span, match) &&
@@ -0,0 +1,92 @@
/**
* Two MEDIUM false positives from the gstack-evals runs (gstack 1.91.299-301):
* pii.phone.e164 on `4294967296` (2^32, a PRNG constant) and pii.email on
* `terminal-bench@2.0.json` (a package@version filename). Each guard is
* pinned in both directions: the false positive is clean, and the real shape
* next to it still reports.
*/
import { describe, test, expect } from "bun:test";
import { scan } from "../lib/redact-engine";
import { isNumericConstant, looksLikeAtFilename } from "../lib/redact-patterns";
const flags = (id: string, s: string): boolean =>
scan(s, { repoVisibility: "private" }).findings.some((f) => f.id === id);
describe("pii.phone.e164 and bare numeric constants", () => {
// Value: protects=a bare power of two (or one less) and the common 32-bit hash/PRNG constants raise no phone finding; fails_when=the guard is
// removed and `4294967296` reports pii.phone.e164 again; why_new=no constant guard existed; seam=none (scan); no_claim=other constants not listed
test("powers of two, their max values and hash/PRNG constants are not phone numbers", () => {
for (const s of [
"const RANGE = 4294967296;",
"mask = 4294967295",
"INT32_MAX 2147483647",
"x % 2147483648",
"1073741824 bytes",
"2^49 is 562949953421312",
"h *= 2654435761",
"seed ^= 2654435769",
"a = 1103515245",
"basis 2166136261",
"mt init 1812433253",
"p = 4294967291",
]) {
expect([s, flags("pii.phone.e164", s)]).toEqual([s, false]);
}
expect(isNumericConstant("4294967296")).toBe(true);
expect(isNumericConstant("4294967297")).toBe(false);
});
// Value: protects=the same digits written as a phone number (a +, spaces, dashes, parens) and ordinary bare numbers still report; fails_when=the
// guard exempts by digits alone or by length, so +14294967296 or 429-496-7296 goes quiet; why_new=pins the narrowness of the new guard;
// seam=none (scan); no_claim=international formats beyond the ones listed
test("real phone numbers still report, including the constant's digits in phone form", () => {
for (const s of [
"tel: +14294967296",
"call 429-496-7296",
"reach +1 429 496 7296",
"phone (429) 496-7296",
"p 429.496.7296",
"call 4294967297 now",
"ring +44 20 7946 0958",
"office 4155550123",
]) {
expect([s, flags("pii.phone.e164", s)]).toEqual([s, true]);
}
});
});
describe("pii.email and package@version filenames", () => {
// Value: protects=a filename with an @ (package@version, or a name ending in a file extension that is not a TLD) raises no email finding;
// fails_when=the guard is removed and terminal-bench@2.0.json reports pii.email again; why_new=no filename guard existed; seam=none (scan);
// no_claim=extensions outside the closed list
test("package@version and @-named files are not email addresses", () => {
for (const s of [
"results in terminal-bench@2.0.json",
"npm pack wrote lodash@4.17.21.tgz",
"config eslint-config@next.yaml",
"cache react-dom@18.2.0.tar.gz",
"pin left-pad@1.3.0.lock",
"notes a@1.2.md",
]) {
expect([s, flags("pii.email", s)]).toEqual([s, false]);
}
expect(looksLikeAtFilename("terminal-bench@2.0.json")).toBe(true);
});
// Value: protects=real addresses still report, including a numeric domain label (163.com) and TLDs that double as file extensions (.sh, .md, .py,
// .zip); fails_when=the guard treats any digit label or any extension-like TLD as a filename; why_new=pins the narrowness of the new guard;
// seam=none (scan); no_claim=every IANA TLD
test("real addresses still report, numeric domains and extension-shaped TLDs included", () => {
for (const s of [
"mail alex@acme-corp.com",
"reach li.wei@163.com",
"ops dev@acme.sh",
"docs ops@acme.md",
"py team@acme.py",
"files sales@acme.zip",
"contact jo@mail.acme.io",
]) {
expect([s, flags("pii.email", s)]).toEqual([s, true]);
}
});
});