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",