fix(redact): parcel IDs are not phone numbers

A county tax-map parcel ID (APN) reads as a national-format phone number
to `pii.phone.e164` — the same collision class as the digit-only UUID
that `insideUuid` already guards. `12-3456789.000` matches, and so does
its normalized `123456789000`.

This is not a rare edge. Land, title and property-tax repos carry APNs
by the hundred; a single title branch pushed 2 MEDIUM findings, and the
same shape recurs in every fixture, mart and smoke in the domain. A
guardrail that cries wolf on the domain's primary identifier is one
people learn to wave through, which is how a real HIGH finding
eventually gets ignored.

The guard is deliberately narrow, in two tiers:

1. The DOTTED form is exempt on its own shape. No phone convention puts
   a dot before a trailing 3-4 digit group after a 4-8 digit middle.
   Hyphen-only variants (22-0001-000) are NOT shape-exempted — 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 APN within the surrounding window. Fixtures and marts
   carry the pair; a real phone number has no such twin. This reads the
   document's own evidence instead of guessing from digits.

Verified against the unmodified engine over inputs spanning every rule
family (AWS, PEM, GitHub PAT, email, IP, credit card, SSN, timestamp,
UUID, nine phone formats): exactly one behavior changed, the APN pair.

The new test pins both directions and was proven red under mutation —
stubbing the guard to `return true` (the dangerous blanket-exemption
failure) fails 12 of 15; `return false` fails 3.

Absorbs PR #2591 by @Two-Six-Alpha-1115 (applied via git am -3; 96 tests
pass across test/redact-parcel-id-false-positive.test.ts +
test/redact-engine.test.ts, and the pattern-lint / CLI / prepush-hook /
autoredact suites stay green).

Co-authored-by: Scott <scott@peninsulaminerals.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-16 10:02:33 -07:00
co-authored by Scott Claude Fable 5
parent 4e055ca202
commit 4cc19e4712
2 changed files with 133 additions and 2 deletions
+56 -2
View File
@@ -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: /(?<![\w.])(\+?[1-9]\d{0,2}[ \-.]?\(?\d{2,4}\)?[ \-.]?\d{3,4}[ \-.]?\d{3,4})(?![\w.])/,
autoRedactable: true,
redactToken: "<REDACTED-PHONE>",
// 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",
@@ -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);
});
});