From e32aede93eda81343f27ae5c4109f229052782cc Mon Sep 17 00:00:00 2001 From: Pavish Kumar Ramani Gopal Date: Thu, 5 Nov 2026 10:37:34 +0000 Subject: [PATCH 1/3] fix: keep UUIDs intact in scrubbed text The catch-all scrub scanned inside UUIDs, so a group of digits in an id was taken for a phone number: `409cd08e-512e-4077-825e-001030608345` became `409cd08e-512e-4077-825e-[REDACTED]`. With the current patterns about 0.04% of random UUIDs are damaged; a damaged id no longer resolves or matches the same id elsewhere. scrubPlain now swaps every complete UUID for a private-use marker before the detectors run and restores it afterwards. A UUID cannot hold PII, and PII next to it is still found. Running per plain chunk keeps URLs working: components are decoded before they are scanned. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E3AAifuwHuKGdoVpqZoQAo --- README.md | 3 ++- src/pseudonymizer.ts | 29 ++++++++++++++++++++++++++- src/uuid-protection.test.ts | 40 +++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 src/uuid-protection.test.ts diff --git a/README.md b/README.md index 6c271cc..d18561a 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,8 @@ Being upfront about what this package does **not** do is part of its job: German street addresses ("Hauptstraße 5", "Schiltacher Str. 33" — postal code and city stay), and PII inside URLs (components are decoded first; map links lose the place name, place ids and the precise position, coordinates are rounded to 2 decimals ≈ 1 km). Only the matched - span is replaced. Well-known risky fields + span is replaced. Complete UUIDs are never scanned (a UUID cannot hold PII), so ids such as + `409cd08e-512e-4077-825e-001030608345` keep their digit runs. Well-known risky fields (note/message content, opportunity descriptions, …) are fully redacted via the curated defaults; organizations opt further fields in with `data_classification: 'pii'` or out with `'public'` (which also exempts them from the catch-all). Names or other non-pattern PII typed diff --git a/src/pseudonymizer.ts b/src/pseudonymizer.ts index f07b741..22df6e0 100644 --- a/src/pseudonymizer.ts +++ b/src/pseudonymizer.ts @@ -114,6 +114,30 @@ type ScrubReplacers = ValueReplacers & { phone: (value: string) => string; }; +/** A complete UUID: a fixed-format random token that cannot hold PII, so detectors never scan inside it. */ +const UUID_TOKEN = /(? string): string => { + // Text that already holds marker code points is scanned as-is, so restoring never mixes them up. + if (!value.includes('-') || HAS_KEPT_MARK.test(value)) { + return scan(value); + } + const kept: string[] = []; + const masked = value.replace(UUID_TOKEN, (uuid) => + KEPT_FIRST + kept.length > KEPT_LAST ? uuid : String.fromCharCode(KEPT_FIRST + kept.push(uuid) - 1), + ); + if (kept.length === 0) { + return scan(value); + } + return scan(masked).replace(KEPT_MARK, (mark) => kept[mark.charCodeAt(0) - KEPT_FIRST]); +}; + /** Every detector needs an `@` (emails), a `/` (URLs) or a digit (IBANs, phones, house numbers) */ const SCRUB_CANDIDATE = /[@/\d]/; @@ -127,7 +151,10 @@ const SCRUB_CANDIDATE = /[@/\d]/; const makeScrubText = (replacers: ScrubReplacers) => { const { email, iban, phone } = replacers; - const scrubPlain = (value: string, context?: 'path'): string => { + const scrubPlain = (value: string, context?: 'path'): string => + withUuidsKept(value, (masked) => scrubDetectors(masked, context)); + + const scrubDetectors = (value: string, context?: 'path'): string => { // Cheap prefilters, so the bulk of strings in a large response (enums, labels, numbers, // timestamps) skip the full patterns: emails need an `@`, and every IBAN / phone match // contains a NUMBER_CANDIDATE match (see below). diff --git a/src/uuid-protection.test.ts b/src/uuid-protection.test.ts new file mode 100644 index 0000000..51e75a6 --- /dev/null +++ b/src/uuid-protection.test.ts @@ -0,0 +1,40 @@ +import { randomUUID } from 'crypto'; +import { describe, expect, it } from 'vitest'; +import { createAnonymizer } from './index'; + +const anon = createAnonymizer({ orgId: 'o1', secret: 's' }); +const scrub = (v: string) => anon.scrub(v) as string; + +describe('UUIDs in scrubbed text', () => { + it('stay intact, alone or inside longer ids', () => { + for (const id of [ + '409cd08e-512e-4077-825e-001030608345', + '3bef5002-8369-421b-8984-bc438db0538a', + 'loop-edge-881ab2e3-0029-4209-8f68-bd447103372c', + '534063D5-084A-467A-8E8F-0036458494AA', + ]) + expect(scrub(id)).toBe(id); + for (let i = 0; i < 20000; i++) { + const id = randomUUID(); + expect(scrub(id)).toBe(id); + } + }); + + it('do not shield PII next to them', () => { + const out = scrub('Ticket 409cd08e-512e-4077-825e-001030608345: call +49 30 1234567, IBAN DE89370400440532013000'); + expect(out).toMatch(/^Ticket 409cd08e-512e-4077-825e-001030608345: call \+00 000 \d{8}, IBAN iban_[0-9a-f]{8}$/); + }); + + it('stay intact in URLs while the URL is still scrubbed', () => { + const out = scrub('https://app.example.com/flows/409cd08e-512e-4077-825e-001030608345?mail=max%40stadtwerke-x.de'); + expect(out).toContain('/flows/409cd08e-512e-4077-825e-001030608345'); + expect(out).not.toContain('stadtwerke-x.de'); + }); + + it('keep scrubbing idempotent and leave text with marker characters alone', () => { + const once = scrub('id 409cd08e-512e-4077-825e-001030608345 tel +49 30 1234567'); + expect(scrub(once)).toBe(once); + const odd = ' 409cd08e-512e-4077-825e-001030608345 +49 30 1234567'; + expect(scrub(odd)).toContain('+00 000'); + }); +}); From 996eac2de3941e30d7573c3ad6440e302156af37 Mon Sep 17 00:00:00 2001 From: Pavish Kumar Ramani Gopal Date: Thu, 5 Nov 2026 10:54:43 +0000 Subject: [PATCH 2/3] perf: mask UUIDs only around the IBAN and phone patterns Only the IBAN and phone patterns can match digit runs inside a UUID, and none of them runs without a NUMBER_CANDIDATE hit. Masking now happens inside that branch, behind a cheap UUID hint, so most strings (labels, timestamps, bare ids) never reach it. Emails are matched on the unmasked text again, so a UUID used as an email's local part is pseudonymized with the email. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E3AAifuwHuKGdoVpqZoQAo --- src/pseudonymizer.ts | 19 ++++++++++--------- src/uuid-protection.test.ts | 5 +++++ 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/pseudonymizer.ts b/src/pseudonymizer.ts index 22df6e0..2a29fd5 100644 --- a/src/pseudonymizer.ts +++ b/src/pseudonymizer.ts @@ -121,11 +121,13 @@ const KEPT_FIRST = 0xe100; const KEPT_LAST = 0xefff; const KEPT_MARK = /[\ue100-\uefff]/g; const HAS_KEPT_MARK = /[\ue100-\uefff]/; +// Cheap hint without lookbehind; the exact boundaries are checked by UUID_TOKEN. +const UUID_HINT = /[0-9a-f]{8}-[0-9a-f]{4}-/i; /** Runs `scan` with every UUID swapped for a marker, then puts the UUIDs back. */ const withUuidsKept = (value: string, scan: (masked: string) => string): string => { // Text that already holds marker code points is scanned as-is, so restoring never mixes them up. - if (!value.includes('-') || HAS_KEPT_MARK.test(value)) { + if (!UUID_HINT.test(value) || HAS_KEPT_MARK.test(value)) { return scan(value); } const kept: string[] = []; @@ -151,10 +153,7 @@ const SCRUB_CANDIDATE = /[@/\d]/; const makeScrubText = (replacers: ScrubReplacers) => { const { email, iban, phone } = replacers; - const scrubPlain = (value: string, context?: 'path'): string => - withUuidsKept(value, (masked) => scrubDetectors(masked, context)); - - const scrubDetectors = (value: string, context?: 'path'): string => { + const scrubPlain = (value: string, context?: 'path'): string => { // Cheap prefilters, so the bulk of strings in a large response (enums, labels, numbers, // timestamps) skip the full patterns: emails need an `@`, and every IBAN / phone match // contains a NUMBER_CANDIDATE match (see below). @@ -165,10 +164,12 @@ const makeScrubText = (replacers: ScrubReplacers) => { ? value.replace(EMAIL_PATTERN, (match) => (match.endsWith(`@${PSEUDONYM_EMAIL_DOMAIN}`) ? match : email(match))) : value; if (hasNumber) { - result = result.replace(IBAN_PATTERN, (match) => replaceIban(match, iban)); - if (context !== 'path') { - result = result.replace(PHONE_PATTERN, phone).replace(NATIONAL_PHONE_PATTERN, phone); - } + // IBAN and phone patterns are the ones that can match digit runs inside a UUID. + result = withUuidsKept(result, (masked) => { + const ibans = masked.replace(IBAN_PATTERN, (match) => replaceIban(match, iban)); + + return context === 'path' ? ibans : ibans.replace(PHONE_PATTERN, phone).replace(NATIONAL_PHONE_PATTERN, phone); + }); } return scrubStreets(result, replacers); diff --git a/src/uuid-protection.test.ts b/src/uuid-protection.test.ts index 51e75a6..037fe53 100644 --- a/src/uuid-protection.test.ts +++ b/src/uuid-protection.test.ts @@ -25,6 +25,11 @@ describe('UUIDs in scrubbed text', () => { expect(out).toMatch(/^Ticket 409cd08e-512e-4077-825e-001030608345: call \+00 000 \d{8}, IBAN iban_[0-9a-f]{8}$/); }); + it('are pseudonymized with the email they are part of', () => { + const out = scrub('reply to 409cd08e-512e-4077-825e-001030608345@stadtwerke-x.de'); + expect(out).toMatch(/^reply to [0-9a-f]{8}@anonymized\.invalid$/); + }); + it('stay intact in URLs while the URL is still scrubbed', () => { const out = scrub('https://app.example.com/flows/409cd08e-512e-4077-825e-001030608345?mail=max%40stadtwerke-x.de'); expect(out).toContain('/flows/409cd08e-512e-4077-825e-001030608345'); From 7b2918a89082563573ffb7c676a0f94d023f6e39 Mon Sep 17 00:00:00 2001 From: Pavish Kumar Ramani Gopal Date: Thu, 5 Nov 2026 10:55:00 +0000 Subject: [PATCH 3/3] docs: describe what the UUID guard covers Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E3AAifuwHuKGdoVpqZoQAo --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d18561a..51a74a8 100644 --- a/README.md +++ b/README.md @@ -160,8 +160,9 @@ Being upfront about what this package does **not** do is part of its job: German street addresses ("Hauptstraße 5", "Schiltacher Str. 33" — postal code and city stay), and PII inside URLs (components are decoded first; map links lose the place name, place ids and the precise position, coordinates are rounded to 2 decimals ≈ 1 km). Only the matched - span is replaced. Complete UUIDs are never scanned (a UUID cannot hold PII), so ids such as - `409cd08e-512e-4077-825e-001030608345` keep their digit runs. Well-known risky fields + span is replaced. Digits inside a complete UUID are never taken for a phone number or IBAN (a + UUID cannot hold PII), so ids such as `409cd08e-512e-4077-825e-001030608345` stay intact. + Well-known risky fields (note/message content, opportunity descriptions, …) are fully redacted via the curated defaults; organizations opt further fields in with `data_classification: 'pii'` or out with `'public'` (which also exempts them from the catch-all). Names or other non-pattern PII typed