Skip to content

Commit 967bbc5

Browse files
feat(pii): custom user-supplied regex patterns for redaction
1 parent 9d5ed38 commit 967bbc5

27 files changed

Lines changed: 836 additions & 79 deletions

File tree

apps/pii/server.py

Lines changed: 107 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
import time
1111
from typing import Any
1212

13-
from fastapi import FastAPI
13+
import regex as regex_module
14+
from fastapi import FastAPI, HTTPException
1415
from presidio_analyzer import (
1516
AnalyzerEngine,
1617
BatchAnalyzerEngine,
@@ -220,9 +221,11 @@ def _analyze_one(
220221
entities: list[str] | None,
221222
score_threshold: float | None,
222223
return_decision_process: bool = False,
224+
ad_hoc_recognizers: list[PatternRecognizer] | None = None,
223225
):
224226
# Regex-only requests reuse a blank NlpArtifacts to skip the spaCy NLP pass;
225-
# otherwise analyze() computes artifacts (runs spaCy) as usual.
227+
# otherwise analyze() computes artifacts (runs spaCy) as usual. Custom-pattern
228+
# recognizers are regex-based, so they run fine against the blank artifacts.
226229
nlp_artifacts = (
227230
_BLANK_ARTIFACTS.get(language) if _regex_only(entities, score_threshold) else None
228231
)
@@ -233,6 +236,7 @@ def _analyze_one(
233236
score_threshold=score_threshold,
234237
return_decision_process=return_decision_process,
235238
nlp_artifacts=nlp_artifacts,
239+
ad_hoc_recognizers=ad_hoc_recognizers or None,
236240
)
237241

238242

@@ -241,6 +245,7 @@ def _analyze_many(
241245
language: str,
242246
entities: list[str] | None,
243247
score_threshold: float | None,
248+
ad_hoc_recognizers: list[PatternRecognizer] | None = None,
244249
):
245250
"""Analyze many texts, skipping the spaCy pass for regex-only requests."""
246251
if _regex_only(entities, score_threshold):
@@ -252,6 +257,7 @@ def _analyze_many(
252257
entities=entities,
253258
score_threshold=score_threshold,
254259
nlp_artifacts=blank,
260+
ad_hoc_recognizers=ad_hoc_recognizers or None,
255261
)
256262
for text in texts
257263
]
@@ -261,33 +267,97 @@ def _analyze_many(
261267
language=language,
262268
entities=entities or None,
263269
score_threshold=score_threshold,
270+
ad_hoc_recognizers=ad_hoc_recognizers or None,
264271
)
265272
)
266273

267274

268275
app = FastAPI(title="Sim Presidio", docs_url=None, redoc_url=None)
269276

277+
# Internal entity id assigned to the i-th user-supplied custom pattern. Never
278+
# surfaced: the anonymizer maps it back to the pattern's chosen `replacement`, and
279+
# callers relabel any leftover CUSTOM_<i> span to the pattern's display name.
280+
CUSTOM_ENTITY_PREFIX = "CUSTOM_"
281+
282+
283+
class CustomPattern(BaseModel):
284+
"""A user-supplied regex pattern. Matches are replaced with `replacement`."""
285+
286+
regex: str
287+
replacement: str = ""
288+
name: str = ""
289+
290+
291+
def custom_operators(patterns: list[CustomPattern] | None) -> dict[str, dict[str, Any]]:
292+
"""Raw replace-operator per custom pattern, keyed by its internal entity id."""
293+
return {
294+
f"{CUSTOM_ENTITY_PREFIX}{i}": {"type": "replace", "new_value": p.replacement}
295+
for i, p in enumerate(patterns or [])
296+
}
297+
298+
299+
def build_custom_recognizers(
300+
patterns: list[CustomPattern] | None, language: str
301+
) -> tuple[list[PatternRecognizer], list[str]]:
302+
"""Ad-hoc PatternRecognizers + their entity ids for the given custom patterns.
303+
304+
Each regex is precompiled so a malformed pattern fails fast as a 400 rather
305+
than surfacing later as an opaque analyze-time 500."""
306+
recognizers: list[PatternRecognizer] = []
307+
entity_ids: list[str] = []
308+
for i, p in enumerate(patterns or []):
309+
try:
310+
regex_module.compile(p.regex)
311+
except regex_module.error as exc:
312+
raise HTTPException(
313+
status_code=400, detail=f"Invalid custom pattern regex: {exc}"
314+
) from exc
315+
entity = f"{CUSTOM_ENTITY_PREFIX}{i}"
316+
recognizers.append(
317+
PatternRecognizer(
318+
supported_entity=entity,
319+
patterns=[Pattern(name=p.name or entity, regex=p.regex, score=0.5)],
320+
supported_language=language,
321+
)
322+
)
323+
entity_ids.append(entity)
324+
return recognizers, entity_ids
325+
326+
327+
def resolve_entities(
328+
req_entities: list[str] | None, custom_entity_ids: list[str]
329+
) -> list[str] | None:
330+
"""Effective entity filter: the requested built-ins plus the custom entity ids.
331+
`None` (detect all built-ins) is preserved only when neither is present, so a
332+
custom-only request never widens into detecting every built-in entity."""
333+
if req_entities is None and not custom_entity_ids:
334+
return None
335+
return list(req_entities or []) + custom_entity_ids
336+
270337

271338
class AnalyzeRequest(BaseModel):
272339
text: str
273340
language: str = "en"
274341
entities: list[str] | None = None
275342
score_threshold: float | None = None
276343
return_decision_process: bool = False
344+
patterns: list[CustomPattern] | None = None
277345

278346

279347
class AnalyzeBatchRequest(BaseModel):
280348
texts: list[str]
281349
language: str = "en"
282350
entities: list[str] | None = None
283351
score_threshold: float | None = None
352+
patterns: list[CustomPattern] | None = None
284353

285354

286355
class AnonymizeRequest(BaseModel):
287356
text: str
288357
analyzer_results: list[dict[str, Any]] = []
289358
anonymizers: dict[str, dict[str, Any]] | None = None
290359
operators: dict[str, dict[str, Any]] | None = None
360+
patterns: list[CustomPattern] | None = None
291361

292362

293363
class AnonymizeBatchItem(BaseModel):
@@ -299,6 +369,7 @@ class AnonymizeBatchRequest(BaseModel):
299369
items: list[AnonymizeBatchItem] = []
300370
anonymizers: dict[str, dict[str, Any]] | None = None
301371
operators: dict[str, dict[str, Any]] | None = None
372+
patterns: list[CustomPattern] | None = None
302373

303374

304375
class RedactRequest(BaseModel):
@@ -308,6 +379,7 @@ class RedactRequest(BaseModel):
308379
score_threshold: float | None = None
309380
anonymizers: dict[str, dict[str, Any]] | None = None
310381
operators: dict[str, dict[str, Any]] | None = None
382+
patterns: list[CustomPattern] | None = None
311383

312384

313385
class RedactBatchRequest(BaseModel):
@@ -317,6 +389,7 @@ class RedactBatchRequest(BaseModel):
317389
score_threshold: float | None = None
318390
anonymizers: dict[str, dict[str, Any]] | None = None
319391
operators: dict[str, dict[str, Any]] | None = None
392+
patterns: list[CustomPattern] | None = None
320393

321394

322395
def build_operators(
@@ -332,6 +405,17 @@ def build_operators(
332405
return operators
333406

334407

408+
def resolve_operators(
409+
anonymizers: dict[str, dict[str, Any]] | None,
410+
operators: dict[str, dict[str, Any]] | None,
411+
patterns: list[CustomPattern] | None,
412+
) -> dict[str, OperatorConfig] | None:
413+
"""Merge the caller's operators with the per-custom-pattern replace operators."""
414+
raw = dict(anonymizers or operators or {})
415+
raw.update(custom_operators(patterns))
416+
return build_operators(raw)
417+
418+
335419
def run_anonymize(
336420
text: str,
337421
raw_results: list[dict[str, Any]],
@@ -366,12 +450,15 @@ def supported_entities(language: str = "en") -> list[str]:
366450
@app.post("/analyze")
367451
def analyze(req: AnalyzeRequest) -> list[dict[str, Any]]:
368452
started = time.perf_counter()
453+
recognizers, custom_ids = build_custom_recognizers(req.patterns, req.language)
454+
entities = resolve_entities(req.entities, custom_ids)
369455
results = _analyze_one(
370456
req.text,
371457
req.language,
372-
req.entities,
458+
entities,
373459
req.score_threshold,
374460
req.return_decision_process,
461+
recognizers,
375462
)
376463
logger.info(
377464
"analyze lang=%s chars=%d entities=%d duration_ms=%.1f",
@@ -387,14 +474,16 @@ def analyze(req: AnalyzeRequest) -> list[dict[str, Any]]:
387474
def analyze_batch(req: AnalyzeBatchRequest) -> list[list[dict[str, Any]]]:
388475
"""Analyze many texts in one pass (spaCy nlp.pipe), returning one span list
389476
per input in request order — the batched counterpart to /analyze."""
390-
results = _analyze_many(req.texts, req.language, req.entities, req.score_threshold)
477+
recognizers, custom_ids = build_custom_recognizers(req.patterns, req.language)
478+
entities = resolve_entities(req.entities, custom_ids)
479+
results = _analyze_many(req.texts, req.language, entities, req.score_threshold, recognizers)
391480
return [[r.to_dict() for r in per_text] for per_text in results]
392481

393482

394483
@app.post("/anonymize")
395484
def anonymize(req: AnonymizeRequest) -> dict[str, Any]:
396485
started = time.perf_counter()
397-
operators = build_operators(req.anonymizers or req.operators)
486+
operators = resolve_operators(req.anonymizers, req.operators, req.patterns)
398487
result = run_anonymize(req.text, req.analyzer_results, operators)
399488
logger.info(
400489
"anonymize chars=%d spans=%d duration_ms=%.1f",
@@ -422,7 +511,7 @@ def anonymize_batch(req: AnonymizeBatchRequest) -> dict[str, list[str]]:
422511
"""Mask many texts in one pass, returning masked text per item in request
423512
order — the batched counterpart to /anonymize. Anonymization is pure string
424513
work (no NLP), so callers should send only items with detected spans."""
425-
operators = build_operators(req.anonymizers or req.operators)
514+
operators = resolve_operators(req.anonymizers, req.operators, req.patterns)
426515
return {
427516
"texts": [
428517
run_anonymize(item.text, item.analyzer_results, operators).text
@@ -438,8 +527,12 @@ def redact(req: RedactRequest) -> dict[str, str]:
438527
with no detected PII passes through unchanged. The analyzer results feed the
439528
anonymizer directly (no dict round-trip)."""
440529
started = time.perf_counter()
441-
operators = build_operators(req.anonymizers or req.operators)
442-
results = _analyze_one(req.text, req.language, req.entities, req.score_threshold)
530+
recognizers, custom_ids = build_custom_recognizers(req.patterns, req.language)
531+
entities = resolve_entities(req.entities, custom_ids)
532+
operators = resolve_operators(req.anonymizers, req.operators, req.patterns)
533+
results = _analyze_one(
534+
req.text, req.language, entities, req.score_threshold, ad_hoc_recognizers=recognizers
535+
)
443536
text = (
444537
req.text
445538
if not results
@@ -466,8 +559,10 @@ def redact_batch(req: RedactBatchRequest) -> dict[str, list[str]]:
466559
the anonymizer directly (no dict round-trip), and anonymization runs only on
467560
texts that actually matched."""
468561
started = time.perf_counter()
469-
operators = build_operators(req.anonymizers or req.operators)
470-
analyzed = _analyze_many(req.texts, req.language, req.entities, req.score_threshold)
562+
recognizers, custom_ids = build_custom_recognizers(req.patterns, req.language)
563+
entities = resolve_entities(req.entities, custom_ids)
564+
operators = resolve_operators(req.anonymizers, req.operators, req.patterns)
565+
analyzed = _analyze_many(req.texts, req.language, entities, req.score_threshold, recognizers)
471566
masked: list[str] = []
472567
total_spans = 0
473568
for text, per_text in zip(req.texts, analyzed):
@@ -484,8 +579,8 @@ def redact_batch(req: RedactBatchRequest) -> dict[str, list[str]]:
484579
"redact_batch lang=%s texts=%d entities=%s nlp=%s spans=%d duration_ms=%.1f",
485580
req.language,
486581
len(req.texts),
487-
len(req.entities) if req.entities else "all",
488-
"skip" if _regex_only(req.entities, req.score_threshold) else "full",
582+
len(entities) if entities else "all",
583+
"skip" if _regex_only(entities, req.score_threshold) else "full",
489584
total_spans,
490585
(time.perf_counter() - started) * 1000,
491586
)

apps/sim/app/api/guardrails/mask-batch/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
2525
const parsed = await parseRequest(guardrailsMaskBatchContract, request, {})
2626
if (!parsed.success) return parsed.response
2727

28-
const { texts, entityTypes, language } = parsed.data.body
28+
const { texts, entityTypes, language, customPatterns } = parsed.data.body
2929

3030
try {
3131
const startedAt = performance.now()
32-
const masked = await maskPIIBatch(texts, entityTypes, language)
32+
const masked = await maskPIIBatch(texts, entityTypes, language, customPatterns)
3333
logger.info('Masked PII batch', {
3434
count: texts.length,
3535
durationMs: Math.round(performance.now() - startedAt),

apps/sim/app/api/guardrails/validate/route.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import { checkAndBillPayerOverageThreshold } from '@/lib/billing/threshold-billing'
1717
import { generateRequestId } from '@/lib/core/utils/request'
1818
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
19+
import type { CustomPiiPattern } from '@/lib/guardrails/pii-entities'
1920
import { validateHallucination } from '@/lib/guardrails/validate_hallucination'
2021
import { validateJson } from '@/lib/guardrails/validate_json'
2122
import { validatePII } from '@/lib/guardrails/validate_pii'
@@ -63,6 +64,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
6364
piiEntityTypes,
6465
piiMode,
6566
piiLanguage,
67+
piiCustomPatterns,
6668
} = body
6769

6870
if (!validationType) {
@@ -280,6 +282,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
280282
piiEntityTypes,
281283
piiMode,
282284
piiLanguage,
285+
piiCustomPatterns,
283286
authHeaders,
284287
requestId
285288
)
@@ -392,6 +395,7 @@ async function executeValidation(
392395
piiEntityTypes: string[] | undefined,
393396
piiMode: string | undefined,
394397
piiLanguage: string | undefined,
398+
piiCustomPatterns: CustomPiiPattern[] | undefined,
395399
authHeaders: { cookie?: string; authorization?: string; billingAttribution?: string } | undefined,
396400
requestId: string
397401
): Promise<{
@@ -450,6 +454,7 @@ async function executeValidation(
450454
entityTypes: piiEntityTypes || [], // Empty array = detect all PII types
451455
mode: (piiMode as 'block' | 'mask') || 'block', // Default to block mode
452456
language: piiLanguage || 'en',
457+
customPatterns: piiCustomPatterns,
453458
requestId,
454459
})
455460
}

apps/sim/blocks/blocks/guardrails.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,17 @@ Return ONLY the regex pattern - no explanations, no quotes, no forward slashes,
214214
},
215215
dependsOn: ['validationType'],
216216
},
217+
{
218+
id: 'piiCustomPatterns',
219+
title: 'Custom Patterns',
220+
type: 'table',
221+
columns: ['Name', 'Pattern', 'Replacement'],
222+
condition: {
223+
field: 'validationType',
224+
value: ['pii'],
225+
},
226+
dependsOn: ['validationType'],
227+
},
217228
],
218229
tools: {
219230
access: ['guardrails_validate'],
@@ -260,6 +271,10 @@ Return ONLY the regex pattern - no explanations, no quotes, no forward slashes,
260271
type: 'string',
261272
description: 'Language for PII detection (default: en)',
262273
},
274+
piiCustomPatterns: {
275+
type: 'json',
276+
description: 'Custom regex patterns to detect and replace (name, pattern, replacement rows)',
277+
},
263278
},
264279
outputs: {
265280
input: {

0 commit comments

Comments
 (0)