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
58 changes: 58 additions & 0 deletions src/anonymization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '[email protected]' },
to: [
{ name: 'Erika Musterfrau', address: '[email protected]' },
{ name: 'Hans Beispiel', address: '[email protected]' },
],
};

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');
});
});
40 changes: 40 additions & 0 deletions src/entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,13 @@ export const anonymizeEntity = <T extends EntityLike>(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 });
Expand All @@ -113,6 +120,39 @@ export const anonymizeEntity = <T extends EntityLike>(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;
Expand Down
Loading