Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions src/anonymization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
12 changes: 10 additions & 2 deletions src/pseudonymizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = /(?:\+|(?<![\w.])00)[1-9][\d\s\-/()]{6,}\d/g;
// National (German/Austrian) format: trunk prefix 0 + area code, then a separator:
// "030 1234567", "0221/123456", "0170-1234567", "(0221) 123 456". The separator keeps bare
// digit runs (ids, postal codes) out; "0[1-9]" keeps the "+00 000 …" pseudonyms from re-matching.
const NATIONAL_PHONE_PATTERN =
/(?<![\w.:/-])(?:0[1-9]\d{1,4}[\s/-]|\(0[1-9]\d{1,4}\)\s?)\d{3,}(?:[\s-]\d+)*(?![\w:/-]|\.\d)/g;

export const createPseudonymizer = (params: {
orgId: string;
Expand Down Expand Up @@ -106,6 +113,7 @@ const makeScrubText =
value
.replace(EMAIL_PATTERN, (match) => (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();
Loading