diff --git a/README.md b/README.md index adee035..0bd3b15 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,10 @@ Being upfront about what this package does **not** do is part of its job: `'public'` (which also exempts them from the catch-all). Names or other non-pattern PII typed into unclassified fields can still slip through. 3. **Heuristics are best-effort.** The non-schema mode catches common field names and - email/phone/IBAN patterns. Novel field names or unusual formats can slip through — use + email/phone/IBAN patterns. Phones match in international (`+49 …`, `0049 …`) and German/Austrian + national format with a separator after the area code (`030 1234567`, `0221/123456`, + `(0221) 123 456`); a national number written as one digit run (`0301234567`) is left alone so + ids and postal codes are not mangled. Novel field names or unusual formats can slip through — use `strict: true` where that is unacceptable. 4. **Quasi-identifier re-identification.** Kept generalized values (postal code + city + birth year + consumption data) can, in small populations, narrow down individuals. If you diff --git a/src/anonymization.test.ts b/src/anonymization.test.ts index d56a9f4..640b698 100644 --- a/src/anonymization.test.ts +++ b/src/anonymization.test.ts @@ -338,6 +338,31 @@ describe('non-schema-aware mode', () => { expect(anonymizer.scrub(once)).toEqual(once); }); + it('scrub replaces national phone numbers, idempotently', () => { + const phones = ['030 1234567', '0221/123456', '0170-1234567', '(0221) 123 456', '089 123 456 78']; + for (const phone of phones) { + const once = anonymizer.scrub({ text: `Hotline: ${phone}.` }) as any; + expect(once.text).toMatch(/^Hotline: \+00 000 \d{8}\.$/); + expect(anonymizer.scrub(once)).toEqual(once); + } + }); + + it('scrub leaves ids, dates, times, versions and amounts alone', () => { + const value = { + id: '01M5NNM83TV1', + ts: '17297001234567890', + order: 'A-2007891234567', + date: '2026-10-24', + time: '05:30 Uhr', + version: 'v0.123 4567', + postal: '01067 Dresden', + amount: '0,50 EUR', + url: 'https://example.com/030-1234567', + iso: '2026-10-24T05:30:00.000Z', + }; + expect(anonymizer.scrub(value)).toEqual(value); + }); + it('anonymizeUnknown applies field-name heuristics', () => { const event = { event_type: 'contact.created', diff --git a/src/pseudonymizer.ts b/src/pseudonymizer.ts index 8ac3bdd..e744ba0 100644 --- a/src/pseudonymizer.ts +++ b/src/pseudonymizer.ts @@ -33,7 +33,14 @@ export type Pseudonymizer = { const EMAIL_PATTERN = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g; const IBAN_PATTERN = /\b[A-Z]{2}\d{2}[ ]?(?:[A-Z0-9][ ]?){11,30}\b/g; -const PHONE_PATTERN = /(?:\+|00)[1-9][\d\s\-/()]{6,}\d/g; +// International: "+49 170 1234567", "0049 170 1234567". The "00" form only at a token start, +// so digit runs inside ids, timestamps or amounts ("1729700123456") are left alone. +const PHONE_PATTERN = /(?:\+|(? (match.endsWith(`@${PSEUDONYM_EMAIL_DOMAIN}`) ? match : email(match))) .replace(IBAN_PATTERN, iban) - .replace(PHONE_PATTERN, phone); + .replace(PHONE_PATTERN, phone) + .replace(NATIONAL_PHONE_PATTERN, phone); const normalize = (value: string) => value.trim().toLowerCase();