chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
# Guardrails Validators
|
||||
|
||||
Validation scripts for the Guardrails block.
|
||||
|
||||
## Validators
|
||||
|
||||
- **JSON Validation** - Validates if content is valid JSON (TypeScript)
|
||||
- **Regex Validation** - Validates content against regex patterns (TypeScript)
|
||||
- **Hallucination Detection** - Validates LLM output against knowledge base using RAG + LLM scoring (TypeScript)
|
||||
- **PII Detection** - Detects personally identifiable information using Microsoft Presidio (Python)
|
||||
|
||||
## Setup
|
||||
|
||||
### TypeScript Validators (JSON, Regex, Hallucination)
|
||||
|
||||
No additional setup required! These validators work out of the box.
|
||||
|
||||
For **hallucination detection**, you'll need:
|
||||
- A knowledge base with documents
|
||||
- An LLM provider API key (or use hosted models)
|
||||
|
||||
### PII Detection (Presidio service)
|
||||
|
||||
PII detection runs against a **standalone Presidio service** — a combined analyzer + anonymizer
|
||||
(built from `docker/pii.Dockerfile`, source in `apps/pii/server.py`) that constructs a warm
|
||||
`AnalyzerEngine` + `AnonymizerEngine` once and exposes `/analyze`, `/anonymize`, and `/health` on a
|
||||
single port. In deployment it is its **own ECS service** (a dedicated task/service, not a sidecar in
|
||||
the app task), reached over the network via `PII_URL` and scaled independently of the app. The app
|
||||
(both the Next.js server and the trigger.dev runtime) is a thin HTTP client (`validate_pii.ts`) — no
|
||||
Python, no local venv.
|
||||
|
||||
Locally, build and run it as a container:
|
||||
|
||||
```bash
|
||||
docker build -f docker/pii.Dockerfile -t sim-pii .
|
||||
docker run -d -p 5001:5001 sim-pii
|
||||
```
|
||||
|
||||
Point the app at it with `PII_URL`:
|
||||
|
||||
- **Local**: `PII_URL=http://localhost:5001` (the default)
|
||||
- **Deployed**: `PII_URL` points to the Presidio ECS service's internal endpoint (service-discovery
|
||||
DNS / internal load balancer) — never `localhost`, since the service runs in a separate task
|
||||
|
||||
The image bakes in the recognizers itself — a check-digit-validated **VIN** recognizer and
|
||||
multi-language NLP models (en/es/it/pl/fi). The redaction language is configured per rule (Data
|
||||
Retention) and defaults to English.
|
||||
|
||||
> **Deploy requirement:** the execution-altering redaction stages (workflow input + block outputs)
|
||||
> fail-fast and abort a run if the Presidio service is unreachable. Every environment that can run
|
||||
> workflows must have a reachable Presidio service at `PII_URL`.
|
||||
|
||||
## Usage
|
||||
|
||||
### JSON & Regex Validation
|
||||
|
||||
These are implemented in TypeScript and work out of the box - no additional dependencies needed.
|
||||
|
||||
### Hallucination Detection
|
||||
|
||||
The hallucination detector uses a modern RAG + LLM confidence scoring approach:
|
||||
|
||||
1. **RAG Query** - Calls the knowledge base search API to retrieve relevant chunks
|
||||
2. **LLM Confidence Scoring** - Uses an LLM to score how well the user input is supported by the retrieved context on a 0-10 confidence scale:
|
||||
- 0-2: Full hallucination - completely unsupported by context, contradicts the context
|
||||
- 3-4: Low confidence - mostly unsupported, significant claims not in context
|
||||
- 5-6: Medium confidence - partially supported, some claims not in context
|
||||
- 7-8: High confidence - mostly supported, minor details not in context
|
||||
- 9-10: Very high confidence - fully supported by context, all claims verified
|
||||
3. **Threshold Check** - Compares the confidence score against your threshold (default: 3)
|
||||
4. **Result** - Returns `passed: true/false` with confidence score and reasoning
|
||||
|
||||
**Configuration:**
|
||||
- `knowledgeBaseId` (required): Select from dropdown of available knowledge bases
|
||||
- `threshold` (optional): Confidence threshold 0-10, default 3 (scores below 3 fail)
|
||||
- `topK` (optional): Number of chunks to retrieve, default 10
|
||||
- `model` (required): Select from dropdown of available LLM models, default `gpt-4o-mini`
|
||||
- `apiKey` (conditional): API key for the LLM provider (hidden for hosted models and Ollama)
|
||||
|
||||
### PII Detection
|
||||
|
||||
The PII detector uses Microsoft Presidio to identify personally identifiable information:
|
||||
|
||||
1. **Analysis** - Scans text for PII entities using pattern matching, NER, and context
|
||||
2. **Detection** - Identifies PII types like names, emails, phone numbers, SSNs, credit cards, etc.
|
||||
3. **Action** - Either blocks the request or masks the PII based on mode
|
||||
|
||||
**Modes:**
|
||||
- **Block Mode** (default): Fails validation if any PII is detected
|
||||
- **Mask Mode**: Passes validation and returns text with PII replaced by `<ENTITY_TYPE>` placeholders
|
||||
|
||||
**Configuration:**
|
||||
- `piiEntityTypes` (optional): Array of PII types to detect (empty = detect all)
|
||||
- `piiMode` (optional): `block` or `mask`, default `block`
|
||||
- `piiLanguage` (optional): Language code, default `en`
|
||||
|
||||
**Supported PII Types:**
|
||||
- **Common**: Person name, Email, Phone, Credit card, Location, IP address, Date/time, URL
|
||||
- **USA**: SSN, Passport, Driver license, Bank account, ITIN
|
||||
- **UK**: NHS number, National Insurance Number
|
||||
- **Other**: Spanish NIF/NIE, Italian fiscal code, Polish PESEL, Singapore NRIC, Australian ABN/TFN, Indian Aadhaar/PAN, and more
|
||||
|
||||
See [Presidio documentation](https://microsoft.github.io/presidio/supported_entities/) for full list.
|
||||
|
||||
## Files
|
||||
|
||||
- `validate_json.ts` - JSON validation (TypeScript)
|
||||
- `validate_regex.ts` - Regex validation (TypeScript)
|
||||
- `validate_hallucination.ts` - Hallucination detection with RAG + LLM scoring (TypeScript)
|
||||
- `validate_pii.ts` - PII detection client: calls the Presidio service's /analyze + /anonymize (TypeScript)
|
||||
- `pii-entities.ts` - Client-safe PII entity + language catalog (shared by the block and Data Retention)
|
||||
- `mask-client.ts` - Internal HTTP client for batch PII masking from the log-redaction persist path
|
||||
- `validate.test.ts` - Test suite for JSON and regex validators
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockToken, mockBaseUrl } = vi.hoisted(() => ({
|
||||
mockToken: vi.fn(),
|
||||
mockBaseUrl: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/internal', () => ({ generateInternalToken: mockToken }))
|
||||
vi.mock('@/lib/core/utils/urls', () => ({ getInternalApiBaseUrl: mockBaseUrl }))
|
||||
|
||||
import { maskPIIBatchViaHttp } from '@/lib/guardrails/mask-client'
|
||||
|
||||
describe('maskPIIBatchViaHttp', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockToken.mockResolvedValue('tok')
|
||||
mockBaseUrl.mockReturnValue('http://app.internal:3000')
|
||||
fetchMock = vi.fn(async (_url: string, init: { body: string }) => {
|
||||
const { texts } = JSON.parse(init.body) as { texts: string[] }
|
||||
return new Response(JSON.stringify({ masked: texts.map((t) => `M(${t})`) }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('masks a small batch in a single request', async () => {
|
||||
const out = await maskPIIBatchViaHttp(['a', 'b', 'c'], ['EMAIL_ADDRESS'])
|
||||
|
||||
expect(out).toEqual(['M(a)', 'M(b)', 'M(c)'])
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('splits by count into multiple requests, preserving global order', async () => {
|
||||
const texts = Array.from({ length: 5000 }, (_, i) => `t${i}`)
|
||||
|
||||
const out = await maskPIIBatchViaHttp(texts, [])
|
||||
|
||||
expect(out).toHaveLength(5000)
|
||||
expect(out[0]).toBe('M(t0)')
|
||||
expect(out[4999]).toBe('M(t4999)')
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3) // 2000-per-request cap
|
||||
})
|
||||
|
||||
it('throws on a non-2xx response so the caller can scrub', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response('boom', { status: 500 }))
|
||||
|
||||
await expect(maskPIIBatchViaHttp(['a'], [])).rejects.toThrow(/mask-batch request failed/)
|
||||
})
|
||||
|
||||
it('returns [] without any request for empty input', async () => {
|
||||
const out = await maskPIIBatchViaHttp([], [])
|
||||
|
||||
expect(out).toEqual([])
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { GuardrailsMaskBatchResult } from '@/lib/api/contracts'
|
||||
import { generateInternalToken } from '@/lib/auth/internal'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
|
||||
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { chunkIndicesByBudget } from '@/lib/guardrails/pii-batching'
|
||||
|
||||
/**
|
||||
* Max in-flight mask-batch requests per call. Each request is a CPU-heavy NER
|
||||
* batch — default 64, sized to saturate the load-balanced Presidio fleet behind
|
||||
* the internal ALB (which spreads each request across tasks). Effective throughput
|
||||
* is capped by fleet worker capacity, so past that this just queues; tune via
|
||||
* `PII_MASK_CHUNK_CONCURRENCY` to the fleet size (and lower to 1 for a single
|
||||
* self-hosted instance). No request timeout: masking a large batch is slow and the
|
||||
* (scaled) Presidio service is expected to eventually respond; an unreachable
|
||||
* service still rejects fast (connection refused) so the caller scrubs.
|
||||
*/
|
||||
const CHUNK_CONCURRENCY = env.PII_MASK_CHUNK_CONCURRENCY ?? 64
|
||||
|
||||
/**
|
||||
* Mask PII across many strings via the internal app-container endpoint.
|
||||
*
|
||||
* Only the app task reaches the Presidio service (it holds `PII_URL`), but the
|
||||
* log-redaction persist path also runs inside the trigger.dev runtime — so
|
||||
* redaction always routes through HTTP, the same way the guardrails tool does.
|
||||
* Strings are grouped into byte/count-budgeted chunks (keeping each request far
|
||||
* under the 10MB Next body limit) and the chunks are sent with bounded
|
||||
* concurrency, so a large payload fans out rather than serializing; order is
|
||||
* preserved, so the returned array matches `texts` length.
|
||||
*
|
||||
* Rejects on any non-2xx, timeout, or shape mismatch so the caller can apply
|
||||
* its own fail-safe (scrubbing rather than leaking).
|
||||
*/
|
||||
export async function maskPIIBatchViaHttp(
|
||||
texts: string[],
|
||||
entityTypes: string[],
|
||||
language?: string
|
||||
): Promise<string[]> {
|
||||
if (texts.length === 0) return []
|
||||
|
||||
const url = `${getInternalApiBaseUrl()}/api/guardrails/mask-batch`
|
||||
const masked = new Array<string>(texts.length)
|
||||
|
||||
await mapWithConcurrency(chunkIndicesByBudget(texts), CHUNK_CONCURRENCY, async (indices) => {
|
||||
const chunk = indices.map((i) => texts[i])
|
||||
const out = await postChunk(url, chunk, entityTypes, language)
|
||||
if (out.length !== chunk.length) {
|
||||
throw new Error('PII mask-batch returned an unexpected result')
|
||||
}
|
||||
indices.forEach((originalIndex, k) => {
|
||||
masked[originalIndex] = out[k]
|
||||
})
|
||||
})
|
||||
|
||||
return masked
|
||||
}
|
||||
|
||||
async function postChunk(
|
||||
url: string,
|
||||
texts: string[],
|
||||
entityTypes: string[],
|
||||
language: string | undefined
|
||||
): Promise<string[]> {
|
||||
// Mint per request: a single token (5min TTL) can expire mid-batch when a
|
||||
// large execution fans out into many sequential chunk requests.
|
||||
const token = await generateInternalToken()
|
||||
|
||||
// boundary-raw-fetch: internal server-to-server call to the app container (internal JWT auth, configurable base URL)
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ texts, entityTypes, language }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '')
|
||||
throw new Error(`PII mask-batch request failed (${response.status}): ${detail.slice(0, 200)}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as GuardrailsMaskBatchResult
|
||||
if (!Array.isArray(data.masked)) {
|
||||
throw new Error('PII mask-batch returned an unexpected result')
|
||||
}
|
||||
return data.masked
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Per-request bounds shared by both Presidio hops: the app→route HTTP call
|
||||
* (`mask-client`) and the route→service call (`validate_pii`). Keeping a single
|
||||
* source of truth ensures every request stays far under the 10MB Next body limit
|
||||
* and small enough for one short spaCy NER pass per Presidio request.
|
||||
*/
|
||||
|
||||
/** Max UTF-8 bytes of text per Presidio request. ~40× under the 10MB Next limit. */
|
||||
export const PII_REQUEST_MAX_BYTES = 256 * 1024
|
||||
/** Max strings per request; caps per-item overhead and stays well under the contract's 100k-entry cap. */
|
||||
export const PII_REQUEST_MAX_COUNT = 2_000
|
||||
|
||||
/**
|
||||
* Group `texts` into chunks of original indices, flushing a chunk when adding the
|
||||
* next string would exceed {@link PII_REQUEST_MAX_BYTES} or {@link PII_REQUEST_MAX_COUNT}.
|
||||
* A single string larger than the byte budget still gets its own chunk — strings
|
||||
* are never dropped, since an unredacted leaf would persist PII. Order is preserved
|
||||
* across and within chunks.
|
||||
*/
|
||||
export function chunkIndicesByBudget(texts: string[]): number[][] {
|
||||
const chunks: number[][] = []
|
||||
let current: number[] = []
|
||||
let bytes = 0
|
||||
|
||||
for (let i = 0; i < texts.length; i++) {
|
||||
const size = Buffer.byteLength(texts[i], 'utf8')
|
||||
if (
|
||||
current.length > 0 &&
|
||||
(current.length >= PII_REQUEST_MAX_COUNT || bytes + size > PII_REQUEST_MAX_BYTES)
|
||||
) {
|
||||
chunks.push(current)
|
||||
current = []
|
||||
bytes = 0
|
||||
}
|
||||
current.push(i)
|
||||
bytes += size
|
||||
}
|
||||
if (current.length > 0) chunks.push(current)
|
||||
|
||||
return chunks
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* Client-safe catalog of Microsoft Presidio PII entity types. Single source of
|
||||
* truth shared by the server-only validator (`validate_pii.ts`) and client
|
||||
* settings UI — keep no node-only imports here.
|
||||
*/
|
||||
export const SUPPORTED_PII_ENTITIES = {
|
||||
// Common/Global
|
||||
CREDIT_CARD: 'Credit card number',
|
||||
CRYPTO: 'Cryptocurrency wallet address',
|
||||
DATE_TIME: 'Date or time',
|
||||
EMAIL_ADDRESS: 'Email address',
|
||||
IBAN_CODE: 'International Bank Account Number',
|
||||
IP_ADDRESS: 'IP address',
|
||||
NRP: 'Nationality, religious or political group',
|
||||
LOCATION: 'Location',
|
||||
PERSON: 'Person name',
|
||||
PHONE_NUMBER: 'Phone number',
|
||||
MEDICAL_LICENSE: 'Medical license number',
|
||||
URL: 'URL',
|
||||
VIN: 'Vehicle Identification Number',
|
||||
|
||||
// USA
|
||||
US_BANK_NUMBER: 'US bank account number',
|
||||
US_DRIVER_LICENSE: 'US driver license',
|
||||
US_ITIN: 'US Individual Taxpayer Identification Number',
|
||||
US_PASSPORT: 'US passport number',
|
||||
US_SSN: 'US Social Security Number',
|
||||
|
||||
// UK
|
||||
UK_NHS: 'UK NHS number',
|
||||
UK_NINO: 'UK National Insurance Number',
|
||||
|
||||
// Other countries
|
||||
ES_NIF: 'Spanish NIF number',
|
||||
ES_NIE: 'Spanish NIE number',
|
||||
IT_FISCAL_CODE: 'Italian fiscal code',
|
||||
IT_DRIVER_LICENSE: 'Italian driver license',
|
||||
IT_VAT_CODE: 'Italian VAT code',
|
||||
IT_PASSPORT: 'Italian passport',
|
||||
IT_IDENTITY_CARD: 'Italian identity card',
|
||||
PL_PESEL: 'Polish PESEL number',
|
||||
SG_NRIC_FIN: 'Singapore NRIC/FIN',
|
||||
SG_UEN: 'Singapore Unique Entity Number',
|
||||
AU_ABN: 'Australian Business Number',
|
||||
AU_ACN: 'Australian Company Number',
|
||||
AU_TFN: 'Australian Tax File Number',
|
||||
AU_MEDICARE: 'Australian Medicare number',
|
||||
IN_PAN: 'Indian Permanent Account Number',
|
||||
IN_AADHAAR: 'Indian Aadhaar number',
|
||||
IN_VEHICLE_REGISTRATION: 'Indian vehicle registration',
|
||||
IN_VOTER: 'Indian voter ID',
|
||||
IN_PASSPORT: 'Indian passport',
|
||||
FI_PERSONAL_IDENTITY_CODE: 'Finnish Personal Identity Code',
|
||||
} as const
|
||||
|
||||
export type PIIEntityType = keyof typeof SUPPORTED_PII_ENTITIES
|
||||
|
||||
/** Flat `{ value, label }` options for entity-type pickers, in catalog order. */
|
||||
export const PII_ENTITY_OPTIONS: ReadonlyArray<{ value: PIIEntityType; label: string }> =
|
||||
Object.entries(SUPPORTED_PII_ENTITIES).map(([value, label]) => ({
|
||||
value: value as PIIEntityType,
|
||||
label,
|
||||
}))
|
||||
|
||||
/** Entity types grouped by region, for a grouped checkbox picker. */
|
||||
export const PII_ENTITY_GROUPS: ReadonlyArray<{
|
||||
label: string
|
||||
entities: ReadonlyArray<{ value: PIIEntityType; label: string }>
|
||||
}> = [
|
||||
{
|
||||
label: 'Common',
|
||||
entities: [
|
||||
'PERSON',
|
||||
'EMAIL_ADDRESS',
|
||||
'PHONE_NUMBER',
|
||||
'CREDIT_CARD',
|
||||
'IP_ADDRESS',
|
||||
'LOCATION',
|
||||
'DATE_TIME',
|
||||
'URL',
|
||||
'IBAN_CODE',
|
||||
'CRYPTO',
|
||||
'NRP',
|
||||
'MEDICAL_LICENSE',
|
||||
'VIN',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'United States',
|
||||
entities: ['US_SSN', 'US_PASSPORT', 'US_DRIVER_LICENSE', 'US_BANK_NUMBER', 'US_ITIN'],
|
||||
},
|
||||
{ label: 'United Kingdom', entities: ['UK_NHS', 'UK_NINO'] },
|
||||
{
|
||||
label: 'Other regions',
|
||||
entities: [
|
||||
'ES_NIF',
|
||||
'ES_NIE',
|
||||
'IT_FISCAL_CODE',
|
||||
'IT_DRIVER_LICENSE',
|
||||
'IT_VAT_CODE',
|
||||
'IT_PASSPORT',
|
||||
'IT_IDENTITY_CARD',
|
||||
'PL_PESEL',
|
||||
'SG_NRIC_FIN',
|
||||
'SG_UEN',
|
||||
'AU_ABN',
|
||||
'AU_ACN',
|
||||
'AU_TFN',
|
||||
'AU_MEDICARE',
|
||||
'IN_PAN',
|
||||
'IN_AADHAAR',
|
||||
'IN_VEHICLE_REGISTRATION',
|
||||
'IN_VOTER',
|
||||
'IN_PASSPORT',
|
||||
'FI_PERSONAL_IDENTITY_CODE',
|
||||
],
|
||||
},
|
||||
].map((group) => ({
|
||||
label: group.label,
|
||||
entities: group.entities.map((value) => ({
|
||||
value: value as PIIEntityType,
|
||||
label: SUPPORTED_PII_ENTITIES[value as PIIEntityType],
|
||||
})),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Languages the Presidio image has NLP models for. The analyzer only recognizes a
|
||||
* language's entities when its model is loaded, so this set must match the image.
|
||||
*/
|
||||
export const PII_LANGUAGES = [
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'es', label: 'Spanish' },
|
||||
{ value: 'it', label: 'Italian' },
|
||||
{ value: 'pl', label: 'Polish' },
|
||||
{ value: 'fi', label: 'Finnish' },
|
||||
] as const
|
||||
|
||||
export type PIILanguage = (typeof PII_LANGUAGES)[number]['value']
|
||||
|
||||
/** Non-empty tuple of language codes for schema/enum use. */
|
||||
export const PII_LANGUAGE_CODES = PII_LANGUAGES.map((l) => l.value) as [
|
||||
PIILanguage,
|
||||
...PIILanguage[],
|
||||
]
|
||||
|
||||
/** Default redaction language when a rule doesn't set one. */
|
||||
export const DEFAULT_PII_LANGUAGE: PIILanguage = 'en'
|
||||
|
||||
/**
|
||||
* Narrow a loosely-typed (stored/legacy) language to a supported code. Unknown or
|
||||
* stale values (e.g. a dropped locale) return `undefined` so callers fall back to
|
||||
* the default rather than forwarding an unsupported language to Presidio.
|
||||
*/
|
||||
export function coercePiiLanguage(value: string | undefined): PIILanguage | undefined {
|
||||
return value && (PII_LANGUAGE_CODES as readonly string[]).includes(value)
|
||||
? (value as PIILanguage)
|
||||
: undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity types every served language recognizes: Presidio's global pattern
|
||||
* recognizers, the spaCy NER entities (PERSON/LOCATION/NRP), and the native VIN
|
||||
* recognizer (registered under every language in `apps/pii/server.py`).
|
||||
*/
|
||||
const GLOBAL_PII_ENTITIES: readonly PIIEntityType[] = [
|
||||
'PERSON',
|
||||
'LOCATION',
|
||||
'NRP',
|
||||
'CREDIT_CARD',
|
||||
'CRYPTO',
|
||||
'DATE_TIME',
|
||||
'EMAIL_ADDRESS',
|
||||
'IBAN_CODE',
|
||||
'IP_ADDRESS',
|
||||
'PHONE_NUMBER',
|
||||
'URL',
|
||||
'MEDICAL_LICENSE',
|
||||
'VIN',
|
||||
]
|
||||
|
||||
/**
|
||||
* Entity types each language recognizes, mirroring the recognizer registration in
|
||||
* `apps/pii/server.py`: globals + NER + VIN everywhere, plus the locale-specific
|
||||
* id recognizers under the language they're registered for (US/UK/AU/IN/SG ids
|
||||
* are English; es/it/pl/fi carry only their own national ids). Keep in sync with
|
||||
* the image — a stale entry only no-ops (redaction fails safe), it never leaks.
|
||||
* `/supportedentities` is the authoritative source if this ever needs to go live.
|
||||
*/
|
||||
export const PII_ENTITIES_BY_LANGUAGE: Record<PIILanguage, ReadonlySet<PIIEntityType>> = {
|
||||
en: new Set<PIIEntityType>([
|
||||
...GLOBAL_PII_ENTITIES,
|
||||
'US_SSN',
|
||||
'US_PASSPORT',
|
||||
'US_DRIVER_LICENSE',
|
||||
'US_BANK_NUMBER',
|
||||
'US_ITIN',
|
||||
'UK_NHS',
|
||||
'UK_NINO',
|
||||
'AU_ABN',
|
||||
'AU_ACN',
|
||||
'AU_TFN',
|
||||
'AU_MEDICARE',
|
||||
'IN_PAN',
|
||||
'IN_AADHAAR',
|
||||
'IN_VEHICLE_REGISTRATION',
|
||||
'IN_VOTER',
|
||||
'IN_PASSPORT',
|
||||
'SG_NRIC_FIN',
|
||||
'SG_UEN',
|
||||
]),
|
||||
es: new Set<PIIEntityType>([...GLOBAL_PII_ENTITIES, 'ES_NIF', 'ES_NIE']),
|
||||
it: new Set<PIIEntityType>([
|
||||
...GLOBAL_PII_ENTITIES,
|
||||
'IT_FISCAL_CODE',
|
||||
'IT_DRIVER_LICENSE',
|
||||
'IT_VAT_CODE',
|
||||
'IT_PASSPORT',
|
||||
'IT_IDENTITY_CARD',
|
||||
]),
|
||||
pl: new Set<PIIEntityType>([...GLOBAL_PII_ENTITIES, 'PL_PESEL']),
|
||||
fi: new Set<PIIEntityType>([...GLOBAL_PII_ENTITIES, 'FI_PERSONAL_IDENTITY_CODE']),
|
||||
}
|
||||
|
||||
/** True when the entity has a recognizer for the given language. */
|
||||
export function isEntitySupportedForLanguage(
|
||||
entity: PIIEntityType,
|
||||
language: PIILanguage
|
||||
): boolean {
|
||||
return PII_ENTITIES_BY_LANGUAGE[language].has(entity)
|
||||
}
|
||||
|
||||
/** {@link PII_ENTITY_GROUPS} filtered to entities the language recognizes (empty groups dropped). */
|
||||
export function getEntityGroupsForLanguage(language: PIILanguage) {
|
||||
return PII_ENTITY_GROUPS.map((group) => ({
|
||||
label: group.label,
|
||||
entities: group.entities.filter((e) => isEntitySupportedForLanguage(e.value, language)),
|
||||
})).filter((group) => group.entities.length > 0)
|
||||
}
|
||||
|
||||
/** The PII redaction stages, in execution order. */
|
||||
export const PII_STAGES = ['input', 'blockOutputs', 'logs'] as const
|
||||
export type PiiStageKey = (typeof PII_STAGES)[number]
|
||||
|
||||
/** Per-stage redaction policy. `enabled: false` makes the stage a no-op. */
|
||||
export interface PiiStagePolicy {
|
||||
enabled: boolean
|
||||
entityTypes: string[]
|
||||
language: PIILanguage
|
||||
}
|
||||
|
||||
export type PiiStages = Record<PiiStageKey, PiiStagePolicy>
|
||||
|
||||
/**
|
||||
* Stage catalog driving the settings UI, in display order (Logs first — the
|
||||
* safe, observability-only default). The execution-altering caveat for the
|
||||
* input/blockOutputs stages is folded into their descriptions.
|
||||
*/
|
||||
export const PII_STAGE_META: ReadonlyArray<{
|
||||
key: PiiStageKey
|
||||
label: string
|
||||
description: string
|
||||
}> = [
|
||||
{
|
||||
key: 'logs',
|
||||
label: 'Logs',
|
||||
description: 'Redact workflow logs when they are persisted.',
|
||||
},
|
||||
{
|
||||
key: 'input',
|
||||
label: 'Workflow input',
|
||||
description:
|
||||
'Redact the workflow input before execution. Data is redacted during runtime and may affect workflow output.',
|
||||
},
|
||||
{
|
||||
key: 'blockOutputs',
|
||||
label: 'Block outputs',
|
||||
description:
|
||||
'Mask every block output before the next block reads it. Data is redacted during runtime and may affect workflow output and execution performance.',
|
||||
},
|
||||
]
|
||||
|
||||
/** Recognizers that over-redact (loose, no checksum); surfaced as UI guidance. */
|
||||
export const RISKY_PII_ENTITIES: ReadonlySet<PIIEntityType> = new Set<PIIEntityType>([
|
||||
'US_SSN',
|
||||
'US_BANK_NUMBER',
|
||||
'DATE_TIME',
|
||||
])
|
||||
|
||||
/** A fully-disabled stage policy for new drafts. */
|
||||
export function emptyStagePolicy(): PiiStagePolicy {
|
||||
return { enabled: false, entityTypes: [], language: DEFAULT_PII_LANGUAGE }
|
||||
}
|
||||
|
||||
/** A fully-disabled stage set for new drafts. */
|
||||
export function emptyPiiStages(): PiiStages {
|
||||
return {
|
||||
input: emptyStagePolicy(),
|
||||
blockOutputs: emptyStagePolicy(),
|
||||
logs: emptyStagePolicy(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate a stored rule into the per-stage shape. A legacy flat rule (no
|
||||
* `stages`) becomes `logs` enabled with its entity types, the two new stages
|
||||
* disabled — exactly its pre-stages behavior.
|
||||
*/
|
||||
export function normalizeRuleStages(rule: {
|
||||
stages?: Partial<Record<PiiStageKey, Partial<PiiStagePolicy> | undefined>>
|
||||
entityTypes?: string[]
|
||||
language?: string
|
||||
}): PiiStages {
|
||||
const sanitize = (policy: Partial<PiiStagePolicy> | undefined): PiiStagePolicy => ({
|
||||
enabled: Boolean(policy?.enabled),
|
||||
entityTypes: Array.isArray(policy?.entityTypes)
|
||||
? policy.entityTypes.filter((t): t is string => typeof t === 'string')
|
||||
: [],
|
||||
language: coercePiiLanguage(policy?.language) ?? DEFAULT_PII_LANGUAGE,
|
||||
})
|
||||
|
||||
if (rule.stages) {
|
||||
return {
|
||||
input: sanitize(rule.stages.input),
|
||||
blockOutputs: sanitize(rule.stages.blockOutputs),
|
||||
logs: sanitize(rule.stages.logs),
|
||||
}
|
||||
}
|
||||
|
||||
const entityTypes = Array.isArray(rule.entityTypes)
|
||||
? rule.entityTypes.filter((t): t is string => typeof t === 'string')
|
||||
: []
|
||||
return {
|
||||
input: emptyStagePolicy(),
|
||||
blockOutputs: emptyStagePolicy(),
|
||||
logs: {
|
||||
enabled: entityTypes.length > 0,
|
||||
entityTypes,
|
||||
language: coercePiiLanguage(rule.language) ?? DEFAULT_PII_LANGUAGE,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { db } from '@sim/db'
|
||||
import { account } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { executeProviderRequest } from '@/providers'
|
||||
import { getProviderFromModel } from '@/providers/utils'
|
||||
|
||||
const logger = createLogger('HallucinationValidator')
|
||||
|
||||
export interface HallucinationValidationResult {
|
||||
passed: boolean
|
||||
error?: string
|
||||
score?: number
|
||||
reasoning?: string
|
||||
/** Billable LLM cost (dollars) for the scoring call; 0 for BYOK/non-hosted. */
|
||||
cost?: number
|
||||
}
|
||||
|
||||
export interface HallucinationValidationInput {
|
||||
userInput: string
|
||||
knowledgeBaseId: string
|
||||
threshold: number // 0-10 confidence scale, default 3 (scores below 3 fail)
|
||||
topK: number // Number of chunks to retrieve, default 10
|
||||
model: string
|
||||
apiKey?: string
|
||||
providerCredentials?: {
|
||||
azureEndpoint?: string
|
||||
azureApiVersion?: string
|
||||
vertexProject?: string
|
||||
vertexLocation?: string
|
||||
vertexCredential?: string
|
||||
bedrockAccessKeyId?: string
|
||||
bedrockSecretKey?: string
|
||||
bedrockRegion?: string
|
||||
}
|
||||
workflowId?: string
|
||||
workspaceId?: string
|
||||
authHeaders?: {
|
||||
cookie?: string
|
||||
authorization?: string
|
||||
}
|
||||
requestId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Query knowledge base to get relevant context chunks using the search API
|
||||
*/
|
||||
async function queryKnowledgeBase(
|
||||
knowledgeBaseId: string,
|
||||
query: string,
|
||||
topK: number,
|
||||
requestId: string,
|
||||
workflowId?: string,
|
||||
authHeaders?: { cookie?: string; authorization?: string }
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
// Call the knowledge base search API directly
|
||||
const searchUrl = `${getInternalApiBaseUrl()}/api/knowledge/search`
|
||||
|
||||
const response = await fetch(searchUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(authHeaders?.cookie ? { Cookie: authHeaders.cookie } : {}),
|
||||
...(authHeaders?.authorization ? { Authorization: authHeaders.authorization } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
knowledgeBaseIds: [knowledgeBaseId],
|
||||
query,
|
||||
topK,
|
||||
workflowId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(`[${requestId}] Knowledge base query failed`, {
|
||||
status: response.status,
|
||||
})
|
||||
return []
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const results = result.data?.results || []
|
||||
|
||||
const chunks = results.map((r: any) => r.content || '').filter((c: string) => c.length > 0)
|
||||
|
||||
return chunks
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error querying knowledge base`, {
|
||||
error: error.message,
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use an LLM to score confidence based on RAG context
|
||||
* Returns a confidence score from 0-10 where:
|
||||
* - 0 = full hallucination (completely unsupported)
|
||||
* - 10 = fully grounded (completely supported)
|
||||
*/
|
||||
async function scoreHallucinationWithLLM(
|
||||
userInput: string,
|
||||
ragContext: string[],
|
||||
model: string,
|
||||
apiKey: string | undefined,
|
||||
providerCredentials: HallucinationValidationInput['providerCredentials'],
|
||||
workspaceId: string | undefined,
|
||||
requestId: string
|
||||
): Promise<{ score: number; reasoning: string; cost: number }> {
|
||||
try {
|
||||
const contextText = ragContext.join('\n\n---\n\n')
|
||||
|
||||
const systemPrompt = `You are a confidence scoring system. Your job is to evaluate how well a user's input is supported by the provided reference context from a knowledge base.
|
||||
|
||||
Score the input on a confidence scale from 0 to 10:
|
||||
- 0-2: Full hallucination - completely unsupported by context, contradicts the context
|
||||
- 3-4: Low confidence - mostly unsupported, significant claims not in context
|
||||
- 5-6: Medium confidence - partially supported, some claims not in context
|
||||
- 7-8: High confidence - mostly supported, minor details not in context
|
||||
- 9-10: Very high confidence - fully supported by context, all claims verified
|
||||
|
||||
Respond ONLY with valid JSON in this exact format:
|
||||
{
|
||||
"score": <number between 0-10>,
|
||||
"reasoning": "<brief explanation of your score>"
|
||||
}
|
||||
|
||||
Do not include any other text, markdown formatting, or code blocks. Only output the raw JSON object. Be strict - only give high scores (7+) if the input is well-supported by the context.`
|
||||
|
||||
const userPrompt = `Reference Context:
|
||||
${contextText}
|
||||
|
||||
User Input to Evaluate:
|
||||
${userInput}
|
||||
|
||||
Evaluate the consistency and provide your score and reasoning in JSON format.`
|
||||
|
||||
logger.info(`[${requestId}] Calling LLM for hallucination scoring`, {
|
||||
model,
|
||||
contextChunks: ragContext.length,
|
||||
})
|
||||
|
||||
const providerId = getProviderFromModel(model)
|
||||
|
||||
let finalApiKey: string | undefined = apiKey
|
||||
if (providerId === 'vertex' && providerCredentials?.vertexCredential) {
|
||||
const credential = await db.query.account.findFirst({
|
||||
where: eq(account.id, providerCredentials.vertexCredential),
|
||||
})
|
||||
if (credential) {
|
||||
const { accessToken } = await refreshTokenIfNeeded(
|
||||
requestId,
|
||||
credential,
|
||||
providerCredentials.vertexCredential
|
||||
)
|
||||
if (accessToken) {
|
||||
finalApiKey = accessToken
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await executeProviderRequest(providerId, {
|
||||
model,
|
||||
systemPrompt,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: userPrompt,
|
||||
},
|
||||
],
|
||||
temperature: 0.1, // Low temperature for consistent scoring
|
||||
apiKey: finalApiKey,
|
||||
azureEndpoint: providerCredentials?.azureEndpoint,
|
||||
azureApiVersion: providerCredentials?.azureApiVersion,
|
||||
vertexProject: providerCredentials?.vertexProject,
|
||||
vertexLocation: providerCredentials?.vertexLocation,
|
||||
bedrockAccessKeyId: providerCredentials?.bedrockAccessKeyId,
|
||||
bedrockSecretKey: providerCredentials?.bedrockSecretKey,
|
||||
bedrockRegion: providerCredentials?.bedrockRegion,
|
||||
workspaceId,
|
||||
})
|
||||
|
||||
if (response instanceof ReadableStream || ('stream' in response && 'execution' in response)) {
|
||||
throw new Error('Unexpected streaming response from LLM')
|
||||
}
|
||||
|
||||
// executeProviderRequest already zeroes cost for BYOK / non-hosted models,
|
||||
// so this is the billable amount as-is.
|
||||
const cost = typeof response.cost?.total === 'number' ? response.cost.total : 0
|
||||
|
||||
const content = response.content.trim()
|
||||
|
||||
let jsonContent = content
|
||||
|
||||
if (content.includes('```')) {
|
||||
const jsonMatch = content.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/)
|
||||
if (jsonMatch) {
|
||||
jsonContent = jsonMatch[1]
|
||||
}
|
||||
}
|
||||
|
||||
const result = JSON.parse(jsonContent)
|
||||
|
||||
if (typeof result.score !== 'number' || result.score < 0 || result.score > 10) {
|
||||
throw new Error('Invalid score format from LLM')
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Confidence score: ${result.score}/10`, {
|
||||
reasoning: result.reasoning,
|
||||
})
|
||||
|
||||
return {
|
||||
score: result.score,
|
||||
reasoning: result.reasoning || 'No reasoning provided',
|
||||
cost,
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error scoring with LLM`, {
|
||||
error: error.message,
|
||||
})
|
||||
throw new Error(`Failed to score confidence: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate user input against knowledge base using RAG + LLM scoring
|
||||
*/
|
||||
export async function validateHallucination(
|
||||
input: HallucinationValidationInput
|
||||
): Promise<HallucinationValidationResult> {
|
||||
const {
|
||||
userInput,
|
||||
knowledgeBaseId,
|
||||
threshold,
|
||||
topK,
|
||||
model,
|
||||
apiKey,
|
||||
providerCredentials,
|
||||
workflowId,
|
||||
workspaceId,
|
||||
authHeaders,
|
||||
requestId,
|
||||
} = input
|
||||
|
||||
try {
|
||||
if (!userInput || userInput.trim().length === 0) {
|
||||
return {
|
||||
passed: false,
|
||||
error: 'User input is required',
|
||||
}
|
||||
}
|
||||
|
||||
if (!knowledgeBaseId) {
|
||||
return {
|
||||
passed: false,
|
||||
error: 'Knowledge base ID is required',
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Query knowledge base with RAG
|
||||
const ragContext = await queryKnowledgeBase(
|
||||
knowledgeBaseId,
|
||||
userInput,
|
||||
topK,
|
||||
requestId,
|
||||
workflowId,
|
||||
authHeaders
|
||||
)
|
||||
|
||||
if (ragContext.length === 0) {
|
||||
return {
|
||||
passed: false,
|
||||
error: 'No relevant context found in knowledge base',
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Use LLM to score confidence
|
||||
const { score, reasoning, cost } = await scoreHallucinationWithLLM(
|
||||
userInput,
|
||||
ragContext,
|
||||
model,
|
||||
apiKey,
|
||||
providerCredentials,
|
||||
workspaceId,
|
||||
requestId
|
||||
)
|
||||
|
||||
logger.info(`[${requestId}] Confidence score: ${score}`, {
|
||||
reasoning,
|
||||
threshold,
|
||||
})
|
||||
|
||||
// Step 3: Check against threshold. Lower scores = less confidence = fail validation
|
||||
const passed = score >= threshold
|
||||
|
||||
return {
|
||||
passed,
|
||||
score,
|
||||
reasoning,
|
||||
cost,
|
||||
error: passed
|
||||
? undefined
|
||||
: `Low confidence: score ${score}/10 is below threshold ${threshold}`,
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Hallucination validation error`, {
|
||||
error: error.message,
|
||||
})
|
||||
return {
|
||||
passed: false,
|
||||
error: `Validation error: ${error.message}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Validate if input is valid JSON
|
||||
*/
|
||||
export interface ValidationResult {
|
||||
passed: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function validateJson(inputStr: string): ValidationResult {
|
||||
try {
|
||||
JSON.parse(inputStr)
|
||||
return { passed: true }
|
||||
} catch (error: any) {
|
||||
if (error instanceof SyntaxError) {
|
||||
return { passed: false, error: `Invalid JSON: ${error.message}` }
|
||||
}
|
||||
return { passed: false, error: `Validation error: ${error.message}` }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { maskPIIBatch, validatePII } from '@/lib/guardrails/validate_pii'
|
||||
|
||||
interface Span {
|
||||
entity_type: string
|
||||
start: number
|
||||
end: number
|
||||
score: number
|
||||
}
|
||||
|
||||
/** Mimic the Presidio anonymizer's default `replace`: each span → `<ENTITY_TYPE>`. */
|
||||
function applyReplace(text: string, results: Span[]): string {
|
||||
let out = text
|
||||
for (const s of [...results].sort((a, b) => b.start - a.start)) {
|
||||
out = `${out.slice(0, s.start)}<${s.entity_type}>${out.slice(s.end)}`
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Analyzer mock: flags `a@b.com` as EMAIL_ADDRESS when that entity is in scope. */
|
||||
function emailSpans(text: string, entities: string[] | undefined): Span[] {
|
||||
if (entities && !entities.includes('EMAIL_ADDRESS')) return []
|
||||
const idx = text.indexOf('a@b.com')
|
||||
return idx === -1 ? [] : [{ entity_type: 'EMAIL_ADDRESS', start: idx, end: idx + 7, score: 0.9 }]
|
||||
}
|
||||
|
||||
describe('validate_pii (Presidio service)', () => {
|
||||
let analyzeBodies: Array<{ text: string; language: string; entities?: string[] }>
|
||||
let fetchMock: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
analyzeBodies = []
|
||||
fetchMock = vi.fn(async (url: string, init: { body: string }) => {
|
||||
const body = JSON.parse(init.body)
|
||||
if (url.includes('/redact_batch')) {
|
||||
for (const text of body.texts as string[]) {
|
||||
analyzeBodies.push({ text, language: body.language, entities: body.entities })
|
||||
}
|
||||
const texts = (body.texts as string[]).map((t) =>
|
||||
applyReplace(t, emailSpans(t, body.entities))
|
||||
)
|
||||
return new Response(JSON.stringify({ texts }), { status: 200 })
|
||||
}
|
||||
if (url.includes('/analyze_batch')) {
|
||||
for (const text of body.texts as string[]) {
|
||||
analyzeBodies.push({ text, language: body.language, entities: body.entities })
|
||||
}
|
||||
const spans = (body.texts as string[]).map((t) => emailSpans(t, body.entities))
|
||||
return new Response(JSON.stringify(spans), { status: 200 })
|
||||
}
|
||||
if (url.includes('/anonymize_batch')) {
|
||||
const texts = (body.items as Array<{ text: string; analyzer_results: Span[] }>).map((i) =>
|
||||
applyReplace(i.text, i.analyzer_results)
|
||||
)
|
||||
return new Response(JSON.stringify({ texts }), { status: 200 })
|
||||
}
|
||||
if (url.includes('/analyze')) {
|
||||
analyzeBodies.push({ text: body.text, language: body.language, entities: body.entities })
|
||||
return new Response(JSON.stringify(emailSpans(body.text, body.entities)), { status: 200 })
|
||||
}
|
||||
// /anonymize
|
||||
return new Response(
|
||||
JSON.stringify({ text: applyReplace(body.text, body.analyzer_results) }),
|
||||
{
|
||||
status: 200,
|
||||
}
|
||||
)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('maskPIIBatch', () => {
|
||||
it('masks detected entities, preserving input order', async () => {
|
||||
const out = await maskPIIBatch(['email a@b.com', 'nothing here'], [])
|
||||
expect(out[0]).toBe('email <EMAIL_ADDRESS>')
|
||||
expect(out[1]).toBe('nothing here')
|
||||
})
|
||||
|
||||
it('forwards entityTypes (and language) to the analyzer; empty ⇒ omitted (all)', async () => {
|
||||
await maskPIIBatch(['mail a@b.com'], ['EMAIL_ADDRESS', 'PERSON'], 'es')
|
||||
expect(analyzeBodies[0].entities).toEqual(['EMAIL_ADDRESS', 'PERSON'])
|
||||
expect(analyzeBodies[0].language).toBe('es')
|
||||
|
||||
analyzeBodies.length = 0
|
||||
await maskPIIBatch(['mail a@b.com'], [])
|
||||
expect(analyzeBodies[0].entities).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns [] for empty input and leaves empty strings untouched', async () => {
|
||||
expect(await maskPIIBatch([], [])).toEqual([])
|
||||
expect(await maskPIIBatch([''], [])).toEqual([''])
|
||||
})
|
||||
|
||||
it('throws on a service failure so the caller can scrub', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response('boom', { status: 500 }))
|
||||
await expect(maskPIIBatch(['email a@b.com'], [])).rejects.toThrow(
|
||||
/Presidio redact_batch failed/
|
||||
)
|
||||
})
|
||||
|
||||
// Runs last: a 404 permanently flips the module's combined-endpoint flag off.
|
||||
it('falls back to legacy analyze+anonymize when /redact_batch is absent (404)', async () => {
|
||||
fetchMock.mockImplementation(async (url: string, init: { body: string }) => {
|
||||
const body = JSON.parse(init.body)
|
||||
if (url.includes('/redact_batch')) return new Response('Not Found', { status: 404 })
|
||||
if (url.includes('/analyze_batch')) {
|
||||
const spans = (body.texts as string[]).map((t) => emailSpans(t, body.entities))
|
||||
return new Response(JSON.stringify(spans), { status: 200 })
|
||||
}
|
||||
// /anonymize_batch
|
||||
const texts = (body.items as Array<{ text: string; analyzer_results: Span[] }>).map((i) =>
|
||||
applyReplace(i.text, i.analyzer_results)
|
||||
)
|
||||
return new Response(JSON.stringify({ texts }), { status: 200 })
|
||||
})
|
||||
|
||||
const out = await maskPIIBatch(['email a@b.com', 'clean'], [])
|
||||
expect(out).toEqual(['email <EMAIL_ADDRESS>', 'clean'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('validatePII', () => {
|
||||
it('block mode fails with a summary when PII is detected', async () => {
|
||||
const res = await validatePII({
|
||||
text: 'reach me at a@b.com',
|
||||
entityTypes: [],
|
||||
mode: 'block',
|
||||
requestId: 'r1',
|
||||
})
|
||||
expect(res.passed).toBe(false)
|
||||
expect(res.error).toContain('EMAIL_ADDRESS')
|
||||
expect(res.detectedEntities).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('mask mode returns masked text', async () => {
|
||||
const res = await validatePII({
|
||||
text: 'mail a@b.com',
|
||||
entityTypes: [],
|
||||
mode: 'mask',
|
||||
requestId: 'r2',
|
||||
})
|
||||
expect(res.passed).toBe(true)
|
||||
expect(res.maskedText).toBe('mail <EMAIL_ADDRESS>')
|
||||
})
|
||||
|
||||
it('passes clean text', async () => {
|
||||
const res = await validatePII({
|
||||
text: 'nothing to see',
|
||||
entityTypes: [],
|
||||
mode: 'block',
|
||||
requestId: 'r3',
|
||||
})
|
||||
expect(res.passed).toBe(true)
|
||||
expect(res.detectedEntities).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,327 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
|
||||
import { chunkIndicesByBudget } from '@/lib/guardrails/pii-batching'
|
||||
|
||||
const logger = createLogger('PIIValidator')
|
||||
|
||||
/**
|
||||
* Concurrent chunk requests in flight from a single mask-batch call. Each chunk is
|
||||
* itself a batched service call (spaCy `nlp.pipe` over many strings). Default 4;
|
||||
* raise via `PII_SERVICE_CHUNK_CONCURRENCY` for a scaled Presidio fleet (this is
|
||||
* the route → Presidio fan-out, inner to the app → route `PII_MASK_CHUNK_CONCURRENCY`).
|
||||
*/
|
||||
const CHUNK_CONCURRENCY = env.PII_SERVICE_CHUNK_CONCURRENCY ?? 4
|
||||
|
||||
/** Presidio service serving /analyze, /anonymize, and combined /redact (VIN is native there). */
|
||||
const PII_URL = env.PII_URL || 'http://localhost:5001'
|
||||
|
||||
export interface PIIValidationInput {
|
||||
text: string
|
||||
entityTypes: string[] // e.g., ["PERSON", "EMAIL_ADDRESS", "CREDIT_CARD"]
|
||||
mode: 'block' | 'mask' // block = fail if PII found, mask = return masked text
|
||||
language?: string // default: "en"
|
||||
requestId: string
|
||||
}
|
||||
|
||||
interface DetectedPIIEntity {
|
||||
type: string
|
||||
start: number
|
||||
end: number
|
||||
score: number
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface PIIValidationResult {
|
||||
passed: boolean
|
||||
error?: string
|
||||
detectedEntities: DetectedPIIEntity[]
|
||||
maskedText?: string
|
||||
}
|
||||
|
||||
interface AnalyzerSpan {
|
||||
entity_type: string
|
||||
start: number
|
||||
end: number
|
||||
score: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect PII spans via the Presidio analyzer. An empty `entityTypes` ⇒ detect all.
|
||||
* Throws on transport/HTTP failure so callers can apply their own fail-safe.
|
||||
*/
|
||||
async function analyze(
|
||||
text: string,
|
||||
entityTypes: string[],
|
||||
language: string
|
||||
): Promise<AnalyzerSpan[]> {
|
||||
const entities = entityTypes.length > 0 ? entityTypes : undefined
|
||||
|
||||
// boundary-raw-fetch: internal call to the Presidio analyzer service via PII_URL
|
||||
const response = await fetch(`${PII_URL}/analyze`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ text, language, ...(entities ? { entities } : {}) }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '')
|
||||
throw new Error(`Presidio analyze failed (${response.status}): ${detail.slice(0, 200)}`)
|
||||
}
|
||||
return (await response.json()) as AnalyzerSpan[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect PII spans for many texts in a single analyzer pass (spaCy `nlp.pipe`),
|
||||
* the batched counterpart to {@link analyze}. Returns one span array per input,
|
||||
* in order. An empty `entityTypes` ⇒ detect all. Throws on transport/HTTP failure.
|
||||
*/
|
||||
async function analyzeBatch(
|
||||
texts: string[],
|
||||
entityTypes: string[],
|
||||
language: string
|
||||
): Promise<AnalyzerSpan[][]> {
|
||||
const entities = entityTypes.length > 0 ? entityTypes : undefined
|
||||
|
||||
// boundary-raw-fetch: internal call to the Presidio analyzer service via PII_URL
|
||||
const response = await fetch(`${PII_URL}/analyze_batch`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ texts, language, ...(entities ? { entities } : {}) }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '')
|
||||
throw new Error(`Presidio analyze failed (${response.status}): ${detail.slice(0, 200)}`)
|
||||
}
|
||||
return (await response.json()) as AnalyzerSpan[][]
|
||||
}
|
||||
|
||||
interface AnonymizeBatchItem {
|
||||
text: string
|
||||
analyzer_results: AnalyzerSpan[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask many texts in a single anonymizer pass, the batched counterpart to
|
||||
* {@link anonymize}. Each item carries its own detected spans; callers must omit
|
||||
* items with no spans (those texts pass through unchanged). Returns masked text
|
||||
* per item, in order. Throws on failure.
|
||||
*/
|
||||
async function anonymizeBatch(items: AnonymizeBatchItem[]): Promise<string[]> {
|
||||
if (items.length === 0) return []
|
||||
|
||||
// boundary-raw-fetch: internal call to the Presidio anonymizer service via PII_URL
|
||||
const response = await fetch(`${PII_URL}/anonymize_batch`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ items }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '')
|
||||
throw new Error(`Presidio anonymize failed (${response.status}): ${detail.slice(0, 200)}`)
|
||||
}
|
||||
const data = (await response.json()) as { texts: string[] }
|
||||
return data.texts
|
||||
}
|
||||
|
||||
/**
|
||||
* Flips to `false` the first time `/redact_batch` returns 404 — an older Presidio
|
||||
* image without the combined endpoint — so subsequent chunks skip straight to the
|
||||
* legacy analyze+anonymize path instead of re-probing. Reset on process restart
|
||||
* (a deploy), so a newly-rolled Presidio is picked up.
|
||||
*/
|
||||
let combinedRedactAvailable = true
|
||||
|
||||
/**
|
||||
* Analyze + anonymize a batch in ONE round-trip via `/redact_batch`, returning
|
||||
* masked texts in request order. Halves the app↔service round-trips (and avoids
|
||||
* shipping the text back up for anonymize) vs {@link analyzeBatch} + {@link anonymizeBatch}.
|
||||
*
|
||||
* Returns `null` when the endpoint is absent (404 — an older Presidio image), so
|
||||
* the caller falls back to the legacy two-call path. Any other non-2xx or a
|
||||
* length mismatch throws so the caller applies its fail-safe (never leaks).
|
||||
*/
|
||||
async function redactBatch(
|
||||
texts: string[],
|
||||
entityTypes: string[],
|
||||
language: string
|
||||
): Promise<string[] | null> {
|
||||
const entities = entityTypes.length > 0 ? entityTypes : undefined
|
||||
|
||||
// boundary-raw-fetch: internal call to the Presidio combined redact service via PII_URL
|
||||
const response = await fetch(`${PII_URL}/redact_batch`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ texts, language, ...(entities ? { entities } : {}) }),
|
||||
})
|
||||
if (response.status === 404) return null
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '')
|
||||
throw new Error(`Presidio redact_batch failed (${response.status}): ${detail.slice(0, 200)}`)
|
||||
}
|
||||
const data = (await response.json()) as { texts: string[] }
|
||||
if (data.texts.length !== texts.length) {
|
||||
throw new Error(
|
||||
`Presidio redact_batch returned ${data.texts.length} result(s) for ${texts.length} input(s)`
|
||||
)
|
||||
}
|
||||
return data.texts
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask spans via the Presidio anonymizer service. Omitting `anonymizers` uses the
|
||||
* default `replace` operator, which yields `<ENTITY_TYPE>`. Throws on failure.
|
||||
*/
|
||||
async function anonymize(text: string, spans: AnalyzerSpan[]): Promise<string> {
|
||||
if (spans.length === 0) return text
|
||||
|
||||
// boundary-raw-fetch: internal call to the Presidio anonymizer service via PII_URL
|
||||
const response = await fetch(`${PII_URL}/anonymize`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ text, analyzer_results: spans }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '')
|
||||
throw new Error(`Presidio anonymize failed (${response.status}): ${detail.slice(0, 200)}`)
|
||||
}
|
||||
const data = (await response.json()) as { text: string }
|
||||
return data.text
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate text for PII using the Presidio service.
|
||||
*
|
||||
* - block: fails validation if any PII is detected
|
||||
* - mask: passes and returns masked text with PII replaced by `<ENTITY_TYPE>`
|
||||
*/
|
||||
export async function validatePII(input: PIIValidationInput): Promise<PIIValidationResult> {
|
||||
const { text, entityTypes, mode, language = 'en', requestId } = input
|
||||
|
||||
logger.info(`[${requestId}] Starting PII validation`, {
|
||||
textLength: text.length,
|
||||
entityTypes,
|
||||
mode,
|
||||
language,
|
||||
})
|
||||
|
||||
try {
|
||||
const spans = await analyze(text, entityTypes, language)
|
||||
|
||||
const detectedEntities: DetectedPIIEntity[] = spans.map((s) => ({
|
||||
type: s.entity_type,
|
||||
start: s.start,
|
||||
end: s.end,
|
||||
score: s.score,
|
||||
text: text.slice(s.start, s.end),
|
||||
}))
|
||||
|
||||
if (spans.length === 0) {
|
||||
logger.info(`[${requestId}] PII validation completed`, { passed: true, detectedCount: 0 })
|
||||
return { passed: true, detectedEntities: [], maskedText: mode === 'mask' ? text : undefined }
|
||||
}
|
||||
|
||||
if (mode === 'block') {
|
||||
const counts = new Map<string, number>()
|
||||
for (const e of detectedEntities) counts.set(e.type, (counts.get(e.type) ?? 0) + 1)
|
||||
const summary = Array.from(counts.entries())
|
||||
.map(([type, count]) => `${count} ${type}`)
|
||||
.join(', ')
|
||||
logger.info(`[${requestId}] PII validation completed`, {
|
||||
passed: false,
|
||||
detectedCount: detectedEntities.length,
|
||||
})
|
||||
return { passed: false, error: `PII detected: ${summary}`, detectedEntities }
|
||||
}
|
||||
|
||||
// mask mode: the anonymizer replaces every span with `<ENTITY_TYPE>`.
|
||||
const maskedText = await anonymize(text, spans)
|
||||
logger.info(`[${requestId}] PII validation completed`, {
|
||||
passed: true,
|
||||
detectedCount: detectedEntities.length,
|
||||
hasMaskedText: true,
|
||||
})
|
||||
return { passed: true, detectedEntities, maskedText }
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] PII validation failed`, { error: getErrorMessage(error) })
|
||||
return {
|
||||
passed: false,
|
||||
error: `PII validation failed: ${getErrorMessage(error)}`,
|
||||
detectedEntities: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask PII across many strings via the Presidio service, preserving input order.
|
||||
*
|
||||
* Strings are grouped into byte/count-budgeted chunks (see {@link chunkIndicesByBudget}),
|
||||
* and each chunk is masked via the combined `/redact_batch` endpoint (one analyze +
|
||||
* anonymize round-trip). Against an older Presidio without that endpoint, it falls
|
||||
* back to the legacy `analyze_batch` + `anonymize_batch` pair — so the app is safe
|
||||
* to deploy before or after the service. Chunks run with bounded concurrency.
|
||||
* Strings with no detected PII pass through unchanged. Rejects on any service
|
||||
* failure (which fails the whole batch) so callers can apply their own fail-safe (scrub).
|
||||
*/
|
||||
export async function maskPIIBatch(
|
||||
texts: string[],
|
||||
entityTypes: string[],
|
||||
language = 'en'
|
||||
): Promise<string[]> {
|
||||
if (texts.length === 0) return []
|
||||
|
||||
const result = new Array<string>(texts.length)
|
||||
|
||||
await mapWithConcurrency(chunkIndicesByBudget(texts), CHUNK_CONCURRENCY, async (indices) => {
|
||||
const chunkTexts = indices.map((i) => texts[i])
|
||||
|
||||
if (combinedRedactAvailable) {
|
||||
const masked = await redactBatch(chunkTexts, entityTypes, language)
|
||||
if (masked) {
|
||||
indices.forEach((originalIndex, pos) => {
|
||||
result[originalIndex] = masked[pos]
|
||||
})
|
||||
return
|
||||
}
|
||||
// 404: older Presidio image; stop probing and use the legacy path below.
|
||||
combinedRedactAvailable = false
|
||||
}
|
||||
|
||||
const spansPerText = await analyzeBatch(chunkTexts, entityTypes, language)
|
||||
|
||||
// A short/misaligned batch response would silently leave the unmatched
|
||||
// strings unmasked (fail-open). Throw so the caller applies its fail-safe
|
||||
// (scrub for logs, abort for in-flight stages) instead of leaking PII.
|
||||
if (spansPerText.length !== chunkTexts.length) {
|
||||
throw new Error(
|
||||
`Presidio analyze_batch returned ${spansPerText.length} result(s) for ${chunkTexts.length} input(s)`
|
||||
)
|
||||
}
|
||||
|
||||
const toAnonymize: AnonymizeBatchItem[] = []
|
||||
const anonymizePositions: number[] = []
|
||||
indices.forEach((originalIndex, pos) => {
|
||||
const spans = spansPerText[pos] ?? []
|
||||
if (spans.length === 0) {
|
||||
result[originalIndex] = chunkTexts[pos]
|
||||
return
|
||||
}
|
||||
toAnonymize.push({ text: chunkTexts[pos], analyzer_results: spans })
|
||||
anonymizePositions.push(pos)
|
||||
})
|
||||
|
||||
const masked = await anonymizeBatch(toAnonymize)
|
||||
if (masked.length !== toAnonymize.length) {
|
||||
throw new Error(
|
||||
`Presidio anonymize_batch returned ${masked.length} result(s) for ${toAnonymize.length} input(s)`
|
||||
)
|
||||
}
|
||||
anonymizePositions.forEach((pos, k) => {
|
||||
result[indices[pos]] = masked[k]
|
||||
})
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export { type PIIEntityType, SUPPORTED_PII_ENTITIES } from '@/lib/guardrails/pii-entities'
|
||||
@@ -0,0 +1,31 @@
|
||||
import safe from 'safe-regex2'
|
||||
|
||||
/**
|
||||
* Validate if input matches regex pattern
|
||||
*/
|
||||
export interface ValidationResult {
|
||||
passed: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function validateRegex(inputStr: string, pattern: string): ValidationResult {
|
||||
let regex: RegExp
|
||||
try {
|
||||
regex = new RegExp(pattern)
|
||||
} catch (error: any) {
|
||||
return { passed: false, error: `Invalid regex pattern: ${error.message}` }
|
||||
}
|
||||
|
||||
if (!safe(pattern)) {
|
||||
return {
|
||||
passed: false,
|
||||
error: 'Regex pattern rejected: potentially unsafe (catastrophic backtracking)',
|
||||
}
|
||||
}
|
||||
|
||||
const match = regex.test(inputStr)
|
||||
if (match) {
|
||||
return { passed: true }
|
||||
}
|
||||
return { passed: false, error: 'Input does not match regex pattern' }
|
||||
}
|
||||
Reference in New Issue
Block a user