diff --git a/src/anonymization.test.ts b/src/anonymization.test.ts index 7b286cd..0fec26c 100644 --- a/src/anonymization.test.ts +++ b/src/anonymization.test.ts @@ -365,3 +365,61 @@ describe('non-schema-aware mode', () => { expect(result.note).toBe('[REDACTED]'); }); }); + +describe('message entity from/to names', () => { + const message = { + _id: 'm1', + _schema: 'message', + from: { name: 'Max Mustermann', address: 'max@example.com' }, + to: [ + { name: 'Erika Musterfrau', address: 'erika@example.com' }, + { name: 'Hans Beispiel', address: 'hans@example.com' }, + ], + }; + + it('pseudonymizes from.name and every to[].name without a schema', () => { + const out = anonymizer.anonymizeEntity({ entity: message, schema: null }) as any; + expect(out.from.name).not.toBe('Max Mustermann'); + expect(out.to[0].name).not.toBe('Erika Musterfrau'); + expect(out.to[1].name).not.toBe('Hans Beispiel'); + expect(JSON.stringify(out)).not.toMatch(/example\.com/); + expect(out._id).toBe('m1'); + }); + + it('is deterministic', () => { + const a = anonymizer.anonymizeEntity({ entity: message, schema: null }) as any; + const b = anonymizer.anonymizeEntity({ entity: message, schema: null }) as any; + expect(a.from.name).toBe(b.from.name); + }); + + it('cannot be opted out via data_classification public or keep overrides', () => { + const schema: Schema = { + slug: 'message', + attributes: [ + { name: 'from', type: 'string', data_classification: 'public' }, + { name: 'to', type: 'string', data_classification: 'public' }, + ], + }; + const custom = createAnonymizer({ orgId: '123', secret: 'test-secret', overrides: { 'message:to': 'keep' } }); + const out = custom.anonymizeEntity({ entity: message, schema }) as any; + expect(out.from.name).not.toBe('Max Mustermann'); + expect(out.to[0].name).not.toBe('Erika Musterfrau'); + }); + + it('handles single-object to, bare strings, empty and null values', () => { + const out = anonymizer.anonymizeEntity({ + entity: { _schema: 'message', from: 'Max Mustermann', to: { name: 'Erika Musterfrau' } }, + schema: null, + }) as any; + expect(out.from).not.toBe('Max Mustermann'); + expect(out.to.name).not.toBe('Erika Musterfrau'); + const edge = anonymizer.anonymizeEntity({ entity: { _schema: 'message', from: null, to: [] }, schema: null }) as any; + expect(edge.from).toBeNull(); + expect(edge.to).toEqual([]); + }); + + it('does not affect from/to on other schemas', () => { + const out = anonymizer.anonymizeEntity({ entity: { _schema: 'ticket', from: { name: 'Berlin' } }, schema: null }) as any; + expect(out.from.name).toBe('Berlin'); + }); +}); diff --git a/src/entity.ts b/src/entity.ts index 8dacd71..579ada2 100644 --- a/src/entity.ts +++ b/src/entity.ts @@ -90,6 +90,13 @@ export const anonymizeEntity = (params: { continue; } + // message sender/recipient names are always pseudonymized, regardless of schema + // classification or overrides (not even `data_classification: 'public'` opts them out) + if (slug === 'message' && MESSAGE_PARTY_KEYS.includes(key)) { + result[key] = anonymizeMessageParty(value, pseudonymizer); + continue; + } + const attribute = key === '_title' ? undefined : attributesByName.get(key); // _title is computed from attributes and can carry PII (e.g. contact names) const strategy = getAttributeStrategy({ slug, attributeName: key, attribute, overrides }); @@ -113,6 +120,39 @@ export const anonymizeEntity = (params: { return result as T; }; +/** message entity attributes holding sender (`from`) / recipient (`to`) parties */ +const MESSAGE_PARTY_KEYS = ['from', 'to']; + +/** + * Anonymize a message party (`{ name, address }`, a single object or an array of them). + * `name` is always pseudonymized; any other string (e.g. the email `address`) is scrubbed of + * embedded identifiers. A bare string is pseudonymized as an identifier or a person name. + */ +const anonymizeMessageParty = (value: unknown, pseudonymizer: Pseudonymizer): unknown => { + if (typeof value === 'string') { + return value.includes('@') ? pseudonymizer.scrubText(value) : value ? pseudonymizer.person(value) : value; + } + + if (Array.isArray(value)) { + return value.map((item) => anonymizeMessageParty(item, pseudonymizer)); + } + + if (!isPlainObject(value)) { + return value; + } + + return mapEntries(value, (val, key) => { + if (SYSTEM_KEEP_KEYS.includes(key)) { + return val; + } + if (key.toLowerCase() === 'name') { + return mapStringLeaves(val, (str) => pseudonymizer.person(str)); + } + + return mapStringLeaves(val, (str) => pseudonymizer.scrubText(str)); + }); +}; + /** true when the org deliberately opted a field out of anonymization (never re-scrub it) */ const isExplicitlyKept = (params: { slug: string;