diff --git a/README.md b/README.md index 6c271cc..51a74a8 100644 --- a/README.md +++ b/README.md @@ -160,7 +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. 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 diff --git a/src/pseudonymizer.ts b/src/pseudonymizer.ts index f07b741..2a29fd5 100644 --- a/src/pseudonymizer.ts +++ b/src/pseudonymizer.ts @@ -114,6 +114,32 @@ 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 (!UUID_HINT.test(value) || 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]/; @@ -138,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 new file mode 100644 index 0000000..037fe53 --- /dev/null +++ b/src/uuid-protection.test.ts @@ -0,0 +1,45 @@ +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('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'); + 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'); + }); +});