feat: harden generic preset, add LLM retry, and finish tooling/docs

- PatternDef.validate + Luhn-gated credit-card detection; de-overlap the
  generic phone/date/IP patterns; presets.swiss unchanged (production behavior)
- strip g/y flags from nameHint so .test() is stateless (latent footgun)
- openAICompatibleProvider: bounded retry on transient failures (network /
  timeout / 429 / 5xx), configurable via retries + retryDelayMs
- eslint + prettier + vitest coverage (97%); CI runs lint/format/coverage
- docs: README badges + new-option docs, SECURITY.md, issue/PR templates

26 tests passing; build emits ESM+CJS+types.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mobiletic
2026-06-24 17:03:20 +01:00
parent f09b917f1a
commit 81b6b03239
21 changed files with 2039 additions and 27 deletions

View File

@@ -14,6 +14,15 @@ const NOOP_LOGGER: Logger = { warn: () => {} };
// Likely proper-name heuristic (two capitalized words), in addition to the structured fallback.
const DEFAULT_NAME_HINT = /\b[A-ZÀ-Ý][a-zà-ÿ]+\s+[A-ZÀ-Ý][a-zà-ÿ]+\b/;
/**
* Return a stateless copy of a regex: a global/sticky (`g`/`y`) regex carries a
* mutable `lastIndex`, which makes repeated `.test()` calls return alternating
* results. The name-hint is used with `.test()`, so we strip those flags.
*/
function stateless(re: RegExp): RegExp {
return re.global || re.sticky ? new RegExp(re.source, re.flags.replace(/[gy]/g, '')) : re;
}
/**
* Framework-agnostic anonymization engine.
*
@@ -34,7 +43,7 @@ export class Anonymizer {
constructor(config: AnonymizerConfig = {}) {
this.llm = config.llm;
this.fallback = new RegexFallback(config.patterns ?? presets.swiss);
this.nameHint = config.nameHint ?? DEFAULT_NAME_HINT;
this.nameHint = stateless(config.nameHint ?? DEFAULT_NAME_HINT);
this.logger = config.logger ?? NOOP_LOGGER;
}

View File

@@ -16,8 +16,10 @@ export class RegexFallback {
const counters: Record<string, number> = {};
let anon = text;
for (const { tag, re } of this.patterns) {
for (const { tag, re, validate } of this.patterns) {
anon = anon.replace(re, (match) => {
// A validator can reject a regex match (e.g. Luhn) → leave the text as-is.
if (validate && !validate(match)) return match;
// Reuse the same placeholder for an identical value (consistency).
const existing = Object.entries(mapping).find(([, v]) => v === match);
if (existing) return existing[0];
@@ -32,9 +34,16 @@ export class RegexFallback {
/** Fast pre-check: is there any structured identifier worth anonymizing? */
hasPii(text: string): boolean {
return this.patterns.some(({ re }) => {
return this.patterns.some(({ re, validate }) => {
re.lastIndex = 0;
return re.test(text);
if (!validate) return re.test(text);
// With a validator, only a match that passes it counts as PII.
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
if (validate(m[0])) return true;
if (m.index === re.lastIndex) re.lastIndex++; // guard against zero-width matches
}
return false;
});
}
}

View File

@@ -12,17 +12,43 @@ const swiss: PatternDef[] = [
{ tag: 'DATE', re: /\b\d{1,2}[./-]\d{1,2}[./-]\d{2,4}\b/g },
];
/** Luhn check on a credit-card candidate (ignores spaces/dashes); 1319 digits. */
function luhnValid(value: string): boolean {
const digits = value.replace(/\D/g, '');
if (digits.length < 13 || digits.length > 19) return false;
let sum = 0;
let double = false;
for (let i = digits.length - 1; i >= 0; i--) {
let d = digits.charCodeAt(i) - 48; // '0' === 48
if (double) {
d *= 2;
if (d > 9) d -= 9;
}
sum += d;
double = !double;
}
return sum % 10 === 0;
}
/**
* Generic preset — locale-agnostic identifiers useful as a starting point
* anywhere. Extend or compose with your own {@link PatternDef}s as needed.
* anywhere. Best-effort: extend or compose with your own {@link PatternDef}s.
*
* Order matters (patterns run in sequence on the already-anonymized text):
* specific identifiers claim their matches before broader ones, and dates are
* claimed before phone numbers so they don't get mis-tagged as `TEL`.
*/
const generic: PatternDef[] = [
{ tag: 'EMAIL', re: /\b[\w.+-]+@[\w-]+\.[\w.-]+\b/g },
{ tag: 'IBAN', re: /\b[A-Z]{2}\d{2}[0-9A-Z]{11,30}\b/g },
{ tag: 'CREDIT_CARD', re: /\b(?:\d[ -]?){13,16}\b/g },
{ tag: 'IPV4', re: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g },
{ tag: 'TEL', re: /\b(?:\+?\d{1,3}[\s.-]?)?(?:\(?\d{2,4}\)?[\s.-]?){2,4}\d{2,4}\b/g },
// 1319 digits in optional space/dash groups, gated by Luhn to avoid eating
// arbitrary long digit runs (account numbers, ids).
{ tag: 'CREDIT_CARD', re: /\b\d(?:[ -]?\d){12,18}\b/g, validate: luhnValid },
{ tag: 'DATE', re: /\b\d{1,2}[./-]\d{1,2}[./-]\d{2,4}\b/g },
// Requires grouped/separated digits (or a leading +country) so plain integers
// aren't mistaken for phone numbers.
{ tag: 'TEL', re: /(?:\+\d{1,3}[\s.-]?)?(?:\(?\d{2,4}\)?[\s.-]){2,3}\d{2,4}/g },
];
/** Built-in pattern sets for the regex fallback. */

View File

@@ -28,14 +28,26 @@ export interface OpenAICompatibleOptions {
apiKey: string;
/** Model id, e.g. `gpt-4o-mini` or `gemma-3-...`. */
model: string;
/** Per-request timeout in milliseconds (default 3000). */
/** Per-request timeout in milliseconds, per attempt (default 3000). */
timeoutMs?: number;
/**
* Extra retries on TRANSIENT failures only (network error, timeout, HTTP 429/5xx).
* Default 1 (→ up to 2 attempts). Worst-case latency is `(retries + 1) × timeoutMs`.
*/
retries?: number;
/** Linear backoff between attempts in milliseconds (delay = attempt × this). Default 250. */
retryDelayMs?: number;
/** Override the system prompt (e.g. for another language or regulation). */
systemPrompt?: string;
/** Extra instructions appended to the system prompt in batch mode. */
batchInstructions?: (usedIds: string[]) => string;
}
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
/** A transport error that the caller may retry (network / timeout / 429 / 5xx). */
class TransientError extends Error {}
const DEFAULT_BATCH_INSTRUCTIONS = (usedIds: string[]): string =>
'\n\nMODE LOT (segments) :' +
'\n- ENTRÉE : un objet JSON {"segments": ["…", "…"]}.' +
@@ -54,17 +66,20 @@ const DEFAULT_BATCH_INSTRUCTIONS = (usedIds: string[]): string =>
*/
export function openAICompatibleProvider(opts: OpenAICompatibleOptions): LlmProvider {
const timeout = opts.timeoutMs ?? 3000;
const retries = Math.max(0, opts.retries ?? 1);
const retryDelayMs = opts.retryDelayMs ?? 250;
const systemPrompt = opts.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
const batchInstructions = opts.batchInstructions ?? DEFAULT_BATCH_INSTRUCTIONS;
const isConfigured = (): boolean => !!(opts.baseUrl && opts.apiKey && opts.model);
async function chat(system: string, user: string): Promise<string> {
if (!isConfigured()) throw new Error('LLM_NOT_CONFIGURED');
/** One HTTP attempt. Throws {@link TransientError} for retryable failures. */
async function attempt(system: string, user: string): Promise<string> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
let res: Response;
try {
const res = await fetch(`${opts.baseUrl}/chat/completions`, {
res = await fetch(`${opts.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -81,12 +96,34 @@ export function openAICompatibleProvider(opts: OpenAICompatibleOptions): LlmProv
}),
signal: controller.signal,
});
if (!res.ok) throw new Error(`LLM_HTTP_${res.status}`);
const data = (await res.json()) as { choices?: Array<{ message?: { content?: string } }> };
return data?.choices?.[0]?.message?.content ?? '';
} catch (err) {
// Network failure or timeout/abort → retryable.
throw new TransientError(`LLM_FETCH_FAILED: ${(err as Error).message}`);
} finally {
clearTimeout(timer);
}
if (!res.ok) {
// 429 + 5xx are transient; other 4xx are not (won't fix on retry).
if (res.status === 429 || res.status >= 500) throw new TransientError(`LLM_HTTP_${res.status}`);
throw new Error(`LLM_HTTP_${res.status}`);
}
const data = (await res.json()) as { choices?: Array<{ message?: { content?: string } }> };
return data?.choices?.[0]?.message?.content ?? '';
}
async function chat(system: string, user: string): Promise<string> {
if (!isConfigured()) throw new Error('LLM_NOT_CONFIGURED');
let lastErr: unknown;
for (let i = 0; i <= retries; i++) {
try {
return await attempt(system, user);
} catch (err) {
lastErr = err;
if (!(err instanceof TransientError) || i === retries) throw err;
if (retryDelayMs > 0) await sleep(retryDelayMs * (i + 1));
}
}
throw lastErr; // unreachable, but keeps the type checker happy
}
return {
@@ -95,7 +132,11 @@ export function openAICompatibleProvider(opts: OpenAICompatibleOptions): LlmProv
async anonymize(text: string): Promise<AnonymizationResult> {
const content = await chat(systemPrompt, text);
const parsed = JSON.parse(content) as { texte_anonymise?: string; mapping?: Record<string, string> };
if (typeof parsed.texte_anonymise !== 'string' || typeof parsed.mapping !== 'object' || !parsed.mapping) {
if (
typeof parsed.texte_anonymise !== 'string' ||
typeof parsed.mapping !== 'object' ||
!parsed.mapping
) {
throw new Error('LLM_BAD_SHAPE');
}
return { anon: parsed.texte_anonymise, mapping: parsed.mapping };

View File

@@ -37,7 +37,14 @@ export interface LlmProvider {
/** A structured-PII detector: a tag (e.g. `EMAIL`) and the global regex that finds it. */
export interface PatternDef {
tag: string;
/** Global (`/…/g`) regex that finds candidate matches. */
re: RegExp;
/**
* Optional second-stage check. When present, a regex match is only treated as
* PII if `validate(match)` returns true — otherwise the text is left untouched.
* Use it to cut false positives (e.g. a Luhn check on credit-card candidates).
*/
validate?: (match: string) => boolean;
}
/** Minimal logger sink. Defaults to a no-op; pass your own to capture fallback warnings. */
@@ -51,7 +58,11 @@ export interface AnonymizerConfig {
llm?: LlmProvider;
/** Structured-PII patterns for the regex fallback. Defaults to {@link presets.swiss}. */
patterns?: PatternDef[];
/** Heuristic that flags likely proper names so the LLM is consulted. Has a sensible default. */
/**
* 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.
*/
nameHint?: RegExp;
/** Where fallback/diagnostic warnings go. Defaults to a no-op. */
logger?: Logger;