feat(anonymizer): strict anti-leak mode (default on), prefilter decoupling, boundary-aware substitution
Harden the core privacy guarantee: - Add `strict` mode (default true): after detection, verify no mapped value survives as a whole token in the output (ignoring placeholders, whose context may legitimately echo a value like `B+`). Catches a model that redacts one mention of a value but leaves another in clear — which the placeholder/mapping bijection check missed. Fail-closed: throws AnonymizationError naming only the non-secret placeholder key, and runs on the final result so it is not swallowed into the regex fallback (which can't fix a name leak). Set strict:false to opt out. - Add `prefilter` option (default true): decouple the PII pre-filter from the presence of a regex fallback. Set false to always consult the LLM while keeping the fallback for LLM failures (max recall + graceful degradation). - Boundary-aware value substitution: applyKnown and the leak check now match values only as whole tokens (Unicode letter/digit boundaries, regex-escaped), so "Ann" no longer replaces inside "Anna" and "jean@x.ch" no longer matches inside "jean@x.church"; accented/non-Latin names preserved. - deanonymize restores longest placeholder keys first (prefix-overlap defense). Restructures anonymize/anonymizeChunks/anonymizeTurn to a single exit so the leak check runs once on the final result. Behavior is unchanged for callers that were already leak-free. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -186,6 +186,107 @@ describe('Anonymizer (Swiss preset)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('strict anti-leak mode', () => {
|
||||
// A model that redacts the FIRST mention of a value but leaves a second in clear.
|
||||
const leakyLlm = () =>
|
||||
failingLlm({
|
||||
anonymize: vi.fn().mockResolvedValue({
|
||||
anon: 'Dossier de [PER_1.NOM:M] ; contactez Alain Jaccard directement.',
|
||||
mapping: { '[PER_1.NOM:M]': 'Alain Jaccard' },
|
||||
legend: {},
|
||||
}),
|
||||
});
|
||||
|
||||
it('throws AnonymizationError when a mapped value still appears in clear', async () => {
|
||||
const s = new Anonymizer({ llm: leakyLlm(), strict: true });
|
||||
await expect(s.anonymize('Dossier de Alain Jaccard')).rejects.toBeInstanceOf(AnonymizationError);
|
||||
});
|
||||
|
||||
it('the leak error names the placeholder key, never the secret value', async () => {
|
||||
const s = new Anonymizer({ llm: leakyLlm(), strict: true });
|
||||
await expect(s.anonymize('Dossier de Alain Jaccard')).rejects.toMatchObject({
|
||||
message: expect.stringContaining('[PER_1.NOM:M]'),
|
||||
});
|
||||
await expect(s.anonymize('Dossier de Alain Jaccard')).rejects.not.toMatchObject({
|
||||
message: expect.stringContaining('Alain Jaccard'),
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed even with a fallback configured (does not silently degrade to regex)', async () => {
|
||||
const s = new Anonymizer({ llm: leakyLlm(), patterns: presets.swiss, strict: true });
|
||||
await expect(s.anonymize('Dossier de Alain Jaccard')).rejects.toBeInstanceOf(AnonymizationError);
|
||||
});
|
||||
|
||||
it('with strict:false the leaky result is returned as-is', async () => {
|
||||
const s = new Anonymizer({ llm: leakyLlm(), strict: false });
|
||||
const r = await s.anonymize('Dossier de Alain Jaccard');
|
||||
expect(r.anon).toContain('Alain Jaccard'); // leak check disabled → passes through
|
||||
});
|
||||
|
||||
it('is enabled by default (no strict option → still throws on a leak)', async () => {
|
||||
const s = new Anonymizer({ llm: leakyLlm() });
|
||||
await expect(s.anonymize('Dossier de Alain Jaccard')).rejects.toBeInstanceOf(AnonymizationError);
|
||||
});
|
||||
|
||||
it('does NOT flag a value that only appears inside a placeholder context (e.g. B+)', async () => {
|
||||
const anonymize = vi.fn().mockResolvedValue({
|
||||
anon: 'Groupe sanguin [PER_1.SANG:B+] confirmé.',
|
||||
mapping: { '[PER_1.SANG:B+]': 'B+' },
|
||||
legend: {},
|
||||
});
|
||||
const s = new Anonymizer({ llm: failingLlm({ anonymize }), strict: true });
|
||||
const r = await s.anonymize('Groupe sanguin B+ confirmé.');
|
||||
expect(r.anon).toBe('Groupe sanguin [PER_1.SANG:B+] confirmé.');
|
||||
});
|
||||
|
||||
it('does NOT flag a value that is only a substring of a larger token', async () => {
|
||||
// "Ann" is redacted; "Anna" (a different token) is left in clear → not a leak.
|
||||
const anonymize = vi.fn().mockResolvedValue({
|
||||
anon: '[PER_1.PRENOM:F] connaît Anna.',
|
||||
mapping: { '[PER_1.PRENOM:F]': 'Ann' },
|
||||
legend: {},
|
||||
});
|
||||
const s = new Anonymizer({ llm: failingLlm({ anonymize }), strict: true });
|
||||
const r = await s.anonymize('Ann connaît Anna.');
|
||||
expect(r.anon).toBe('[PER_1.PRENOM:F] connaît Anna.');
|
||||
});
|
||||
|
||||
it('regex-only output never trips the check (every occurrence is replaced)', async () => {
|
||||
const s = new Anonymizer({ patterns: presets.swiss, strict: true });
|
||||
const r = await s.anonymize('Écris à jean@exemple.ch puis à jean@exemple.ch');
|
||||
expect(r.anon).not.toContain('jean@exemple.ch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('prefilter decoupling', () => {
|
||||
it('prefilter:false consults the LLM even for PII-free text when a fallback exists', async () => {
|
||||
const anonymize = vi.fn().mockResolvedValue({ anon: 'INNER JOIN', mapping: {}, legend: {} });
|
||||
const s = new Anonymizer({ llm: failingLlm({ anonymize }), patterns: presets.swiss, prefilter: false });
|
||||
await s.anonymize('Explique INNER JOIN'); // no structured PII, no name hint
|
||||
expect(anonymize).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('prefilter default (true) still skips the LLM for PII-free text', async () => {
|
||||
const anonymize = vi.fn();
|
||||
const s = new Anonymizer({ llm: failingLlm({ anonymize }), patterns: presets.swiss });
|
||||
await s.anonymize('Explique INNER JOIN');
|
||||
expect(anonymize).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('boundary-aware applyKnown', () => {
|
||||
it('reuses a known value only as a whole token, not inside a larger word', async () => {
|
||||
const s = new Anonymizer({ llm: failingLlm(), patterns: presets.swiss });
|
||||
const seed = { '[PER_1.PRENOM:F]': 'Ann' };
|
||||
// "Anna" must stay intact; the old substring replace would mangle it.
|
||||
const kept = await s.anonymizeChunks(['Anna arrive demain.'], { mapping: seed });
|
||||
expect(kept.anon[0]).toBe('Anna arrive demain.');
|
||||
// but a standalone "Ann" is reused deterministically (no LLM call).
|
||||
const reused = await s.anonymizeChunks(['Ann arrive demain.'], { mapping: seed });
|
||||
expect(reused.anon[0]).toBe('[PER_1.PRENOM:F] arrive demain.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-turn conversation (anonymizeTurn)', () => {
|
||||
it('keeps stable ids across turns and accumulates the session', async () => {
|
||||
const anonymizeInConversation = vi
|
||||
|
||||
Reference in New Issue
Block a user