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:
@@ -54,6 +54,24 @@ function stateless(re: RegExp): RegExp {
|
||||
return re.global || re.sticky ? new RegExp(re.source, re.flags.replace(/[gy]/g, '')) : re;
|
||||
}
|
||||
|
||||
/** Escape a literal string for safe interpolation into a `RegExp` source. */
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* A global regex that matches `value` only as a whole token — not glued to
|
||||
* additional letters/digits on either side (so a short value like `"Al"` never
|
||||
* matches inside `"Alps"`). `\p{L}\p{N}` (with the `u` flag) keeps accented
|
||||
* names and non-Latin scripts intact. The value is escaped: it may contain regex
|
||||
* metacharacters (`.`, `+`, `@`, …). Shared by value→placeholder substitution
|
||||
* ({@link Anonymizer.applyKnown}) and the strict leak check so the two agree on
|
||||
* exactly which occurrences count.
|
||||
*/
|
||||
function boundedValue(value: string): RegExp {
|
||||
return new RegExp(`(?<![\\p{L}\\p{N}])${escapeRegExp(value)}(?![\\p{L}\\p{N}])`, 'gu');
|
||||
}
|
||||
|
||||
/**
|
||||
* Framework-agnostic anonymization engine.
|
||||
*
|
||||
@@ -75,6 +93,8 @@ export class Anonymizer {
|
||||
private readonly nameHint: RegExp;
|
||||
private readonly logger: Logger;
|
||||
private readonly historyMaxTurns: number;
|
||||
private readonly strict: boolean;
|
||||
private readonly prefilter: boolean;
|
||||
|
||||
constructor(config: AnonymizerConfig = {}) {
|
||||
this.llm = config.llm;
|
||||
@@ -82,6 +102,8 @@ export class Anonymizer {
|
||||
this.nameHint = stateless(config.nameHint ?? DEFAULT_NAME_HINT);
|
||||
this.logger = config.logger ?? NOOP_LOGGER;
|
||||
this.historyMaxTurns = config.historyMaxTurns ?? 10;
|
||||
this.strict = config.strict ?? true;
|
||||
this.prefilter = config.prefilter ?? true;
|
||||
if (!this.llm && !this.fallback) {
|
||||
throw new AnonymizationError('Anonymizer requires an LlmProvider, regex patterns, or both');
|
||||
}
|
||||
@@ -97,19 +119,19 @@ export class Anonymizer {
|
||||
|
||||
async anonymize(text: string): Promise<AnonymizationResult> {
|
||||
// Pre-filter: most text has no PII → avoid the LLM round-trip. Only safe when
|
||||
// a regex fallback exists to judge "no PII"; without one we always consult the
|
||||
// LLM so structured PII (e.g. emails) can't slip through unanonymized.
|
||||
if (this.fallback && !this.likelyHasPii(text)) return { anon: text, mapping: {}, legend: {} };
|
||||
// a regex fallback exists to judge "no PII" (name-hint alone would miss e.g.
|
||||
// emails); without one we always consult the LLM. Disable with prefilter:false
|
||||
// to always consult the LLM even when a fallback is configured.
|
||||
if (this.prefilter && this.fallback && !this.likelyHasPii(text)) {
|
||||
return { anon: text, mapping: {}, legend: {} };
|
||||
}
|
||||
|
||||
let result: AnonymizationResult | undefined;
|
||||
if (this.hasLlm()) {
|
||||
try {
|
||||
const result = await this.llm!.anonymize(text);
|
||||
this.validate(result); // throws if inconsistent → fall back
|
||||
return {
|
||||
anon: result.anon,
|
||||
mapping: result.mapping,
|
||||
legend: this.buildLegend(result.anon, result.legend),
|
||||
};
|
||||
const r = await this.llm!.anonymize(text);
|
||||
this.validate(r); // throws if inconsistent → fall back
|
||||
result = { anon: r.anon, mapping: r.mapping, legend: this.buildLegend(r.anon, r.legend) };
|
||||
} catch (err) {
|
||||
if (!this.fallback) {
|
||||
throw new AnonymizationError(`anonymize failed: ${(err as Error).message}`, { cause: err });
|
||||
@@ -117,7 +139,11 @@ export class Anonymizer {
|
||||
this.logger.warn(`LLM unavailable/invalid → regex fallback: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
return this.fallbackAnonymize(text);
|
||||
if (!result) result = this.fallbackAnonymize(text);
|
||||
// Strict anti-leak runs on the final result and throws (fail-closed) rather
|
||||
// than being swallowed into the regex fallback, which can't fix a name leak.
|
||||
this.assertNoLeak(result.anon, result.mapping);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,10 +166,13 @@ export class Anonymizer {
|
||||
if (!chunks.length) return { anon: [], mapping: { ...seed.mapping }, legend: { ...seedLegend } };
|
||||
|
||||
const seeded = chunks.map((c) => this.applyKnown(c, seed.mapping));
|
||||
if (this.fallback && !seeded.some((c) => this.likelyHasPii(c))) {
|
||||
return { anon: seeded, mapping: { ...seed.mapping }, legend: { ...seedLegend } };
|
||||
if (this.prefilter && this.fallback && !seeded.some((c) => this.likelyHasPii(c))) {
|
||||
const out = { anon: seeded, mapping: { ...seed.mapping }, legend: { ...seedLegend } };
|
||||
for (const a of out.anon) this.assertNoLeak(a, out.mapping);
|
||||
return out;
|
||||
}
|
||||
|
||||
let out: { anon: string[]; mapping: Record<string, string>; legend: Record<string, string> } | undefined;
|
||||
if (this.hasLlm()) {
|
||||
try {
|
||||
const {
|
||||
@@ -161,7 +190,7 @@ export class Anonymizer {
|
||||
}
|
||||
}
|
||||
const legend = this.buildLegend(anon.join('\n'), { ...seedLegend, ...gLegend });
|
||||
return { anon, mapping, legend };
|
||||
out = { anon, mapping, legend };
|
||||
} catch (err) {
|
||||
if (!this.fallback) {
|
||||
throw new AnonymizationError(`anonymizeChunks failed: ${(err as Error).message}`, { cause: err });
|
||||
@@ -169,7 +198,9 @@ export class Anonymizer {
|
||||
this.logger.warn(`Chunk anonymization → regex fallback: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
return this.fallbackChunks(seeded, seed.mapping, seedLegend);
|
||||
if (!out) out = this.fallbackChunks(seeded, seed.mapping, seedLegend);
|
||||
for (const a of out.anon) this.assertNoLeak(a, out.mapping);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,9 +230,10 @@ export class Anonymizer {
|
||||
const prev: AnonymizerSession = session ?? { mapping: {}, legend: {}, history: [] };
|
||||
const seeded = this.applyKnown(text, prev.mapping);
|
||||
|
||||
let turn: AnonymizationResult;
|
||||
let merged: { anon: string; mapping: Record<string, string>; legend: Record<string, string> } | undefined;
|
||||
if (this.hasLlm()) {
|
||||
try {
|
||||
let turn: AnonymizationResult;
|
||||
if (this.llm!.anonymizeInConversation) {
|
||||
turn = await this.llm!.anonymizeInConversation(seeded, {
|
||||
history: prev.history,
|
||||
@@ -214,7 +246,7 @@ export class Anonymizer {
|
||||
if (b.segments.length !== 1) throw new Error('SEGMENT_COUNT_MISMATCH');
|
||||
turn = { anon: b.segments[0], mapping: b.mapping, legend: b.legend };
|
||||
}
|
||||
return this.commitTurn(prev, this.mergeAndCheck(prev.mapping, turn));
|
||||
merged = this.mergeAndCheck(prev.mapping, turn);
|
||||
} catch (err) {
|
||||
if (!this.fallback) {
|
||||
throw new AnonymizationError(`anonymizeTurn failed: ${(err as Error).message}`, { cause: err });
|
||||
@@ -222,7 +254,9 @@ export class Anonymizer {
|
||||
this.logger.warn(`Conversation turn → regex fallback: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
return this.commitTurn(prev, this.mergeAndCheck(prev.mapping, this.fallback!.anonymize(seeded)));
|
||||
if (!merged) merged = this.mergeAndCheck(prev.mapping, this.fallback!.anonymize(seeded));
|
||||
this.assertNoLeak(merged.anon, merged.mapping);
|
||||
return this.commitTurn(prev, merged);
|
||||
}
|
||||
|
||||
/** Merge a turn's result into the running mapping (de-collision) + anti-leak check. */
|
||||
@@ -275,17 +309,43 @@ export class Anonymizer {
|
||||
};
|
||||
}
|
||||
|
||||
/** Replace known values (seed) by their placeholder, longest values first. */
|
||||
/**
|
||||
* Replace known values (seed) by their placeholder, longest values first (so a
|
||||
* value that contains a shorter one is claimed first). Matching is boundary-aware
|
||||
* ({@link boundedValue}): a value is only swapped when it stands as a whole token,
|
||||
* so a short value like `"Al"` is never substituted inside `"Alps"`.
|
||||
*/
|
||||
private applyKnown(text: string, seed: Record<string, string>): string {
|
||||
let out = text;
|
||||
for (const [ph, val] of Object.entries(seed)
|
||||
.filter(([, v]) => v)
|
||||
.sort((a, b) => b[1].length - a[1].length)) {
|
||||
out = out.split(val).join(ph);
|
||||
out = out.replace(boundedValue(val), ph);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict anti-leak guard: no mapped value may still appear as a whole token in
|
||||
* the anonymized text, ignoring placeholders (a placeholder's context field can
|
||||
* legitimately echo a value, e.g. the `B+` in `[PER_1.SANG:B+]`). Catches a model
|
||||
* that emits the placeholder for one mention of a value but leaves another in
|
||||
* clear. Fail-closed: throws {@link AnonymizationError} naming only the non-secret
|
||||
* placeholder key (never the value). No-op unless `strict` is enabled.
|
||||
*/
|
||||
private assertNoLeak(anon: string, mapping: Record<string, string>): void {
|
||||
if (!this.strict) return;
|
||||
const residual = anon.replace(PLACEHOLDER_RE, ''); // drop placeholders — their context may echo a value
|
||||
for (const [ph, value] of Object.entries(mapping)) {
|
||||
// Skip 1-char values: negligibly identifying and a magnet for false positives.
|
||||
if (value.length >= 2 && boundedValue(value).test(residual)) {
|
||||
throw new AnonymizationError(
|
||||
`strict mode: the value behind ${ph} still appears in the anonymized output`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private applyRename(text: string, rename: Record<string, string>): string {
|
||||
let out = text;
|
||||
for (const [from, to] of Object.entries(rename)) out = out.split(from).join(to);
|
||||
@@ -406,11 +466,13 @@ export class Anonymizer {
|
||||
}
|
||||
}
|
||||
|
||||
/** Restore the real values (replace every known placeholder). */
|
||||
/** Restore the real values (replace every known placeholder), longest key first. */
|
||||
deanonymize(text: string, mapping: Record<string, string>): string {
|
||||
if (!text) return text;
|
||||
let out = text;
|
||||
for (const [ph, value] of Object.entries(mapping)) {
|
||||
// Longest placeholder first so a shorter key can't pre-empt a longer one that
|
||||
// shares its prefix (defensive; the bracketed format already prevents most overlaps).
|
||||
for (const [ph, value] of Object.entries(mapping).sort((a, b) => b[0].length - a[0].length)) {
|
||||
out = out.split(ph).join(value);
|
||||
}
|
||||
return out;
|
||||
|
||||
24
src/types.ts
24
src/types.ts
@@ -113,8 +113,32 @@ export interface AnonymizerConfig {
|
||||
* Heuristic that flags likely proper names so the LLM is consulted. Has a
|
||||
* sensible default. Any `g`/`y` flags are stripped internally so the regex is
|
||||
* used statelessly — a global flag would otherwise alternate results.
|
||||
*
|
||||
* The default is Latin-script oriented (`A–Z` + Latin-1 accents); pass your own
|
||||
* for other scripts, or set `prefilter: false` to always consult the LLM.
|
||||
*/
|
||||
nameHint?: RegExp;
|
||||
/**
|
||||
* Strict anti-leak mode. When `true`, the anonymized text is checked after
|
||||
* detection to ensure no mapped value still appears — as a whole token, and
|
||||
* outside placeholders (a placeholder's context field may legitimately echo a
|
||||
* value, e.g. `[PER_1.SANG:B+]`). This catches a model that redacts one mention
|
||||
* of a value but leaves another in clear. On detection it throws
|
||||
* {@link AnonymizationError} (fail-closed; the error names only the non-secret
|
||||
* placeholder key, never the value). **Default `true`** (fail-safe). Set `false`
|
||||
* to trade the guarantee for throughput, or if a false positive — a value that
|
||||
* also occurs as a legitimate standalone token — rejects an otherwise-fine result.
|
||||
*/
|
||||
strict?: boolean;
|
||||
/**
|
||||
* Whether to run the cheap PII pre-filter that skips the LLM round-trip when no
|
||||
* personal data is heuristically detected. **Default `true`.** It only takes
|
||||
* effect when a `patterns` fallback is configured — the structured detector is
|
||||
* what makes a "no PII" verdict sound (name-hint alone would miss e.g. emails).
|
||||
* Set `false` to ALWAYS consult the LLM while still keeping the regex fallback
|
||||
* for when the LLM fails: maximum recall with graceful degradation.
|
||||
*/
|
||||
prefilter?: boolean;
|
||||
/** Where fallback/diagnostic warnings go. Defaults to a no-op. */
|
||||
logger?: Logger;
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user