diff --git a/lib/redact-patterns.ts b/lib/redact-patterns.ts index 7f6e8f73e..0424a8e84 100644 --- a/lib/redact-patterns.ts +++ b/lib/redact-patterns.ts @@ -168,6 +168,58 @@ function looksLikeCompactTimestamp(span: string): boolean { ); } +/** Context window for pairing a normalized parcel ID with its punctuated form. */ +const PARCEL_CONTEXT_CHARS = 400; + +/** + * County tax-map parcel ID (APN). Ohio's dominant form is NN-NNNNNNN.NNN, and + * some counties use a hyphen before the 3-4 digit suffix instead of a dot. + */ +const PARCEL_PUNCT_RE = /\b\d{2}-\d{4,8}[.\-]\d{3,4}\b/g; + +/** + * A parcel ID reads as a national-format phone number to pii.phone.e164 — the + * same collision class as the digit-only UUID that `insideUuid` already guards. + * Land/title repos carry these by the hundred, so the false positives are not + * incidental: they arrive on every branch that touches title, and a guardrail + * that cries wolf on the domain's primary identifier trains people to wave the + * warning through, which is how a real HIGH finding eventually gets ignored. + * + * Deliberately narrow, in two tiers: + * + * 1. The DOTTED form is exempt on its own shape. No phone convention places a + * dot before a trailing 3-4 digit group after a 4-8 digit middle, so this + * cannot swallow a real number. The hyphen-only variants (22-0001-000) are + * NOT exempted by shape — those genuinely are phone-shaped. + * + * 2. A DIGITS-ONLY span is phone-shaped in isolation, so it earns the exemption + * only by evidence: it must be the exact digit-normalization of a punctuated + * parcel ID within the surrounding window. Fixtures and marts always carry + * the pair ({parcel_id: "12-3456789.000", norm: "123456789000"}), and a bare + * phone number has no such twin nearby — so this reads the document's own + * evidence rather than guessing from digits. + */ +export function looksLikeParcelId(span: string, match: RegExpExecArray): boolean { + if (/^\d{2}-\d{4,8}\.\d{3,4}$/.test(span)) return true; + if (!/^\d{10,14}$/.test(span)) return false; + + const input = match.input ?? ""; + const spanStartInMatch = match[1] !== undefined ? match[0].indexOf(match[1]) : 0; + const spanStart = match.index + Math.max(0, spanStartInMatch); + const spanEnd = spanStart + span.length; + const window = input.slice( + Math.max(0, spanStart - PARCEL_CONTEXT_CHARS), + spanEnd + PARCEL_CONTEXT_CHARS, + ); + + PARCEL_PUNCT_RE.lastIndex = 0; + let p: RegExpExecArray | null; + while ((p = PARCEL_PUNCT_RE.exec(window)) !== null) { + if (p[0].replace(/\D/g, "") === span) return true; + } + return false; +} + // ── Placeholder suppression (per-matched-span, NOT per-line) ───────────────── /** @@ -558,11 +610,13 @@ export const PATTERNS: RedactPattern[] = [ regex: /(?", - // A digit-only UUID's hyphen groups read as national phone formatting. + // A digit-only UUID's hyphen groups read as national phone formatting, and + // so does a county tax-map parcel ID (see looksLikeParcelId). validate: (span, match) => !insideUuid(match) && span.replace(/\D/g, "").length >= 10 && - !looksLikeCompactTimestamp(span), + !looksLikeCompactTimestamp(span) && + !looksLikeParcelId(span, match), }, { id: "pii.ssn", diff --git a/test/redact-parcel-id-false-positive.test.ts b/test/redact-parcel-id-false-positive.test.ts new file mode 100644 index 000000000..9f34bf420 --- /dev/null +++ b/test/redact-parcel-id-false-positive.test.ts @@ -0,0 +1,77 @@ +/** + * pii.phone.e164 vs county tax-map parcel IDs (APNs). + * + * A parcel ID reads as a national-format phone number to the e164 pattern — + * the same collision class as the digit-only UUID `insideUuid` already guards. + * Land, title and property-tax repos carry these by the hundred, so the noise + * is not incidental; it arrives on every branch that touches the domain. + * + * The guard has to be narrow, so this file pins BOTH directions: parcels stay + * clean, and every real phone shape stays flagged. The negative controls are + * the point — a guard that exempted long digit runs wholesale would pass the + * "parcels clean" half and quietly gut the pattern. + */ +import { describe, test, expect } from "bun:test"; +import { scan } from "../lib/redact-engine"; +import { looksLikeParcelId } from "../lib/redact-patterns"; + +const flagsPhone = (s: string): boolean => + scan(s, { repoVisibility: "private" }).findings.some((f) => f.id === "pii.phone.e164"); + +describe("pii.phone.e164 — real phone numbers stay flagged", () => { + const REAL_PHONES: [string, string][] = [ + ["US dashed", "call 415-555-0123 now"], + ["US parens", "phone: (415) 555-0123"], + ["US dotted", "p 415.555.0123"], + ["E.164 US", "tel: +14155550123"], + ["E.164 US spaced", "contact +1 415 555 0123"], + ["E.164 UK", "ring +44 20 7946 0958"], + ["E.164 DE", "fon +49 30 901820"], + ["E.164 PT 12-digit", "reach +351912345678"], + ["bare 11-digit", "operator 14155550123 ext"], + ]; + for (const [label, input] of REAL_PHONES) { + test(label, () => { + expect(flagsPhone(input)).toBe(true); + }); + } +}); + +describe("pii.phone.e164 — parcel IDs are not phone numbers", () => { + test("dotted APN is exempt on shape alone", () => { + expect(flagsPhone(' parcel_id: "12-3456789.000",')).toBe(false); + }); + + test("a normalized APN is exempt when paired with its punctuated form", () => { + expect( + flagsPhone(' parcel_id: "12-3456789.000",\n norm: "123456789000",'), + ).toBe(false); + }); + + test("8-digit middle is still an APN", () => { + expect(flagsPhone('apn "30-00414123.0001"')).toBe(false); + }); +}); + +describe("pii.phone.e164 — the guard stays narrow", () => { + /** + * The load-bearing control. A bare digit run is phone-shaped in isolation, so + * it may only be exempted by EVIDENCE — a punctuated APN in the surrounding + * window. With no such twin, the finding must survive. If this ever goes + * green-by-exemption, the guard has become a blanket hole in the pattern. + */ + test("a bare digit run with no punctuated APN nearby is still flagged", () => { + expect(flagsPhone('norm: "123456789000",')).toBe(true); + }); + + test("hyphen-only APN variants are NOT exempted by shape", () => { + // 22-0001-000 is genuinely phone-shaped; only the dotted form earns a + // shape-based pass. This one may only be cleared by the evidence tier. + expect(looksLikeParcelId("22-0001-000", /(.*)/.exec("22-0001-000")!)).toBe(false); + }); + + test("evidence pairing requires an exact digit match, not a prefix", () => { + // A near-miss APN in the window must not clear a different digit run. + expect(flagsPhone(' parcel_id: "12-3456789.000",\n other: "999888777666",')).toBe(true); + }); +});