diff --git a/.env.example b/.env.example index 9e0ae47260..25112cd644 100644 --- a/.env.example +++ b/.env.example @@ -353,7 +353,7 @@ ALLOW_API_KEY_REVEAL=false # Scans incoming messages for prompt injection patterns before routing. # Used by: src/middleware/promptInjectionGuard.ts # INPUT_SANITIZER_ENABLED=true -# INPUT_SANITIZER_MODE=warn # warn = log only | block = reject request | redact = strip patterns +# INPUT_SANITIZER_MODE=warn # warn = log only | block = reject request | redact = legacy (does NOT strip injection; use PII_REDACTION_ENABLED for request PII) # Legacy alias for INPUT_SANITIZER_MODE (same effect). # INJECTION_GUARD_MODE=warn diff --git a/SECURITY.md b/SECURITY.md index 9bafdbd286..c0cea8de2b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -91,7 +91,7 @@ Configure via dashboard (Settings → Security) or `.env`: ```env INPUT_SANITIZER_ENABLED=true -INPUT_SANITIZER_MODE=block # warn | block | redact +INPUT_SANITIZER_MODE=block # warn | block (injection policy; legacy "redact" does not strip injection text) ``` ### 🔒 PII Redaction @@ -108,7 +108,8 @@ Automatic detection and optional redaction of personally identifiable informatio | SSN (US) | `123-45-6789` | `[SSN_REDACTED]` | ```env -PII_REDACTION_ENABLED=true +PII_REDACTION_ENABLED=true # request PII rewrite; independent of INPUT_SANITIZER_MODE +PII_RESPONSE_SANITIZATION=true # optional: redact PII in provider responses returned to clients ``` ### 🌐 Network Security diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 2f1780353d..c34f1ad63f 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -218,9 +218,9 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp | Variable | Default | Source File | Description | | ------------------------- | --------- | ---------------------------------------- | ------------------------------------------------------------------------------------------- | | `INPUT_SANITIZER_ENABLED` | `true` | `src/middleware/promptInjectionGuard.ts` | Enable scanning of incoming messages for prompt injection patterns. | -| `INPUT_SANITIZER_MODE` | `warn` | `src/middleware/promptInjectionGuard.ts` | `warn` = log only, `block` = reject request with 400, `redact` = strip suspicious patterns. | +| `INPUT_SANITIZER_MODE` | `warn` | `src/middleware/promptInjectionGuard.ts` | Injection policy: `warn` = log only, `block` = reject request with 400. Legacy `redact` does **not** strip injection text; use `PII_REDACTION_ENABLED` for request PII rewrite. | | `INJECTION_GUARD_MODE` | _(unset)_ | `src/middleware/promptInjectionGuard.ts` | Legacy alias for `INPUT_SANITIZER_MODE` — same behavior. | -| `PII_REDACTION_ENABLED` | `false` | `src/middleware/promptInjectionGuard.ts` | Detect PII (emails, phones, SSNs) in incoming requests. | +| `PII_REDACTION_ENABLED` | `false` | `src/lib/guardrails/piiMasker.ts` | When `true`, redact PII in incoming requests (independent of injection mode). | | `CREDENTIAL_REDACTION_ENABLED` | `false` | `src/lib/guardrails/credentialMasker.ts` | Redact well-known API-key / secret-token patterns from request/response payloads. Opt-in; mirrors `PII_REDACTION_ENABLED`. | ### Response-Side: PII Sanitizer @@ -240,7 +240,7 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp | Scenario | Configuration | | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| **Enterprise compliance** | `INPUT_SANITIZER_ENABLED=true`, `INPUT_SANITIZER_MODE=block`, `PII_REDACTION_ENABLED=true`, `PII_RESPONSE_SANITIZATION=true` | +| **Enterprise compliance** | `INPUT_SANITIZER_ENABLED=true`, `INPUT_SANITIZER_MODE=block`, `PII_REDACTION_ENABLED=true`, `PII_RESPONSE_SANITIZATION=true` (injection blocks + request/response PII redaction; modes are independent) | | **Monitoring only** | `INPUT_SANITIZER_ENABLED=true`, `INPUT_SANITIZER_MODE=warn` — logs but never blocks | | **Personal use** | Leave all disabled — zero overhead | diff --git a/docs/reference/FEATURE_FLAGS.md b/docs/reference/FEATURE_FLAGS.md index e126a33257..f6fad8e32b 100644 --- a/docs/reference/FEATURE_FLAGS.md +++ b/docs/reference/FEATURE_FLAGS.md @@ -56,7 +56,7 @@ used when neither a DB override nor an environment variable is present. | `REQUIRE_API_KEY` | boolean | `false` | Require an API key for all incoming requests. | | `INPUT_SANITIZER_ENABLED` | boolean | `true` | Enable input sanitization for all requests. | | `INJECTION_GUARD_MODE` | enum | `off` | Prompt injection guard mode. Values: `off`, `warn`, `block`, `redact`. | -| `PII_REDACTION_ENABLED` | boolean | `false` | Redact personally identifiable information from requests. | +| `PII_REDACTION_ENABLED` | boolean | `false` | Redact PII from requests (independent of `INPUT_SANITIZER_MODE`). | | `PII_RESPONSE_SANITIZATION` | boolean | `false` | Sanitize PII from provider responses. | | `PII_RESPONSE_SANITIZATION_MODE` | enum | `redact` | Mode for PII response sanitization. Values: `redact`, `warn`, `block`, `off`. | | `OUTBOUND_SSRF_GUARD_ENABLED` | boolean | `true` | Block outbound requests to private/internal IP ranges. | diff --git a/docs/security/GUARDRAILS.md b/docs/security/GUARDRAILS.md index 21ab7f7f28..8491caa211 100644 --- a/docs/security/GUARDRAILS.md +++ b/docs/security/GUARDRAILS.md @@ -60,12 +60,13 @@ exposes a `deps` constructor option so tests can inject fake `getSettings` and Runs on **both** stages. -- **`preCall`** clones the payload, walks `system`, `messages`, and `input` - arrays, and applies `processPII()` (from `@/shared/utils/inputSanitizer`) to - string `content`/`text` fields. When `PII_REDACTION_ENABLED=true` **and** - `INPUT_SANITIZER_MODE=redact`, detected PII is stripped/redacted in the - outbound payload. Otherwise the call records detection counts without - rewriting content. +- **`preCall`** clones the payload, walks `system`, `messages`, `input`, and + `prompt` (including plain string items), and applies `processPII()` (from + `@/shared/utils/inputSanitizer`) to string `content`/`text` fields. When + `PII_REDACTION_ENABLED=true`, detected PII is redacted in the outbound + payload. This is independent of `INPUT_SANITIZER_MODE` (which only controls + prompt-injection policy). When redaction is off, the call records detection + counts without rewriting content. - **`postCall`** deep-clones the response, runs `sanitizePIIResponse()` plus the Responses-API-shape masker (`maskResponsesOutput` — covers `output_text` and `output[].content[].text`). If any redaction occurs, the @@ -83,7 +84,7 @@ options: | Setting | Env var | Default | Effect | | --------------- | ----------------------------------------------- | ------- | --------------------------------------- | | Enabled | `INPUT_SANITIZER_ENABLED` | `true` | When `false`, guardrail short-circuits. | -| Mode | `INJECTION_GUARD_MODE` / `INPUT_SANITIZER_MODE` | `warn` | `block`, `warn`, or `log`. | +| Mode | `INJECTION_GUARD_MODE` / `INPUT_SANITIZER_MODE` | `warn` | Injection policy: `block`, `warn`, or `log`. (`redact` is accepted for back-compat but does **not** strip injection text; request PII rewrite is controlled by `PII_REDACTION_ENABLED`.) | | Block threshold | `blockThreshold` option | `high` | Minimum severity required to block. | **Mode precedence** (`getMode`): caller `options.mode` → @@ -223,9 +224,9 @@ Environment variables read by the built-in guardrails: | Variable | Used by | Effect | | ------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------ | | `INPUT_SANITIZER_ENABLED` | `prompt-injection` | Set `false` to disable detection entirely. | -| `INPUT_SANITIZER_MODE` | `prompt-injection`, `pii-masker` | Shared mode: `warn`, `block`, `log`, or `redact`. | +| `INPUT_SANITIZER_MODE` | `prompt-injection` | Injection policy: `warn`, `block`, or `log`. Legacy value `redact` does not rewrite injection text. | | `INJECTION_GUARD_MODE` | `prompt-injection` | Mode for the injection guard; also a DB feature flag that **overrides** the env vars (DB > ENV). | -| `PII_REDACTION_ENABLED` | `pii-masker` | When `true` + mode `redact`, request PII is stripped. | +| `PII_REDACTION_ENABLED` | `pii-masker` | When `true`, request PII is redacted (independent of injection mode). | | `PII_RESPONSE_SANITIZATION` / `_MODE` | `pii-masker` (downstream) | Controls response-side masker behavior. | The Vision Bridge reads runtime config from the DB-backed settings store diff --git a/src/lib/guardrails/piiMasker.ts b/src/lib/guardrails/piiMasker.ts index b4f22b9eb5..05f7e70e69 100644 --- a/src/lib/guardrails/piiMasker.ts +++ b/src/lib/guardrails/piiMasker.ts @@ -10,9 +10,9 @@ type PiiDetection = { type JsonRecord = Record; function isRequestPiiMaskingEnabled() { - return ( - process.env.PII_REDACTION_ENABLED === "true" && process.env.INPUT_SANITIZER_MODE === "redact" - ); + // Request PII redaction is controlled solely by PII_REDACTION_ENABLED. + // INPUT_SANITIZER_MODE only governs prompt-injection policy (warn/block/log). + return process.env.PII_REDACTION_ENABLED === "true"; } function sanitizeStringValue(text: string) { @@ -84,6 +84,13 @@ function cloneAndMaskRequestPayload(payload: unknown) { const sanitizeMessageLikeList = (list: unknown) => { if (!Array.isArray(list)) return list; return list.map((entry) => { + // Responses API can pass plain strings in input[] + if (typeof entry === "string") { + const result = sanitizeStringValue(entry); + detections.push(...result.detections); + modified ||= result.modified; + return result.text; + } if (!entry || typeof entry !== "object") return entry; const record = { ...(entry as JsonRecord) }; if ("content" in record) { @@ -116,6 +123,20 @@ function cloneAndMaskRequestPayload(payload: unknown) { if (Array.isArray(clonedPayload.input)) { clonedPayload.input = sanitizeMessageLikeList(clonedPayload.input); + } else if (typeof clonedPayload.input === "string") { + const result = sanitizeStringValue(clonedPayload.input); + detections.push(...result.detections); + modified ||= result.modified; + clonedPayload.input = result.text; + } + + if (typeof clonedPayload.prompt === "string") { + const result = sanitizeStringValue(clonedPayload.prompt); + detections.push(...result.detections); + modified ||= result.modified; + clonedPayload.prompt = result.text; + } else if (Array.isArray(clonedPayload.prompt)) { + clonedPayload.prompt = sanitizeMessageLikeList(clonedPayload.prompt); } return { diff --git a/src/shared/utils/inputSanitizer.ts b/src/shared/utils/inputSanitizer.ts index bdc927f5d8..581ec0c99b 100644 --- a/src/shared/utils/inputSanitizer.ts +++ b/src/shared/utils/inputSanitizer.ts @@ -278,8 +278,10 @@ export function sanitizeRequest(body, logger = console) { } // ── PII Detection / Redaction ── + // PII rewrite is controlled by PII_REDACTION_ENABLED only. + // INPUT_SANITIZER_MODE is reserved for prompt-injection policy (warn/block/log). if (config.piiRedaction) { - const piiResult = processPII(fullText, config.mode === "redact"); + const piiResult = processPII(fullText, true); result.piiDetections = piiResult.detections; if (piiResult.detections.length > 0) { @@ -287,11 +289,9 @@ export function sanitizeRequest(body, logger = console) { `[SANITIZER] PII detected: ${piiResult.detections.map((d) => `${d.type}(${d.count})`).join(", ")}` ); - if (config.mode === "redact") { - // Deep clone and replace message contents with redacted versions - result.sanitizedBody = redactBody(body); - result.modified = true; - } + // Deep clone and replace message contents with redacted versions + result.sanitizedBody = redactBody(body); + result.modified = true; } } @@ -304,36 +304,90 @@ export function sanitizeRequest(body, logger = console) { * @returns {Object} */ function redactBody(body) { + // Deep clone to avoid mutating original const clone = JSON.parse(JSON.stringify(body)); const messageSource = clone.messages !== undefined ? clone.messages : clone.input; const messages = Array.isArray(messageSource) ? messageSource - : messageSource && typeof messageSource === "object" - ? [messageSource] - : []; + : messageSource === undefined || messageSource === null + ? [] + : [messageSource]; - for (const msg of messages) { - if (typeof msg.content === "string") { - msg.content = processPII(msg.content, true).text; - } else if (Array.isArray(msg.content)) { - for (const part of msg.content) { - if (typeof part === "string") { - const idx = msg.content.indexOf(part); - msg.content[idx] = processPII(part, true).text; - } else if (part.text) { - part.text = processPII(part.text, true).text; - } - } + const redactContentValue = (value) => { + if (typeof value === "string") { + return processPII(value, true).text; } + if (Array.isArray(value)) { + return value.map((part) => { + if (typeof part === "string") { + return processPII(part, true).text; + } + if (part && typeof part === "object") { + const next = { ...part }; + if (typeof next.text === "string") { + next.text = processPII(next.text, true).text; + } + if (typeof next.content === "string") { + next.content = processPII(next.content, true).text; + } + return next; + } + return part; + }); + } + return value; + }; + + const redactedMessages = messages.map((msg) => { + if (typeof msg === "string") { + return processPII(msg, true).text; + } + if (!msg || typeof msg !== "object") { + return msg; + } + const next = { ...msg }; + if ("content" in next) { + next.content = redactContentValue(next.content); + } + if (typeof next.text === "string") { + next.text = processPII(next.text, true).text; + } + return next; + }); + + if (clone.messages !== undefined) { + clone.messages = Array.isArray(clone.messages) ? redactedMessages : redactedMessages[0]; + } else if (clone.input !== undefined) { + clone.input = Array.isArray(clone.input) ? redactedMessages : redactedMessages[0]; } if (typeof clone.system === "string") { clone.system = processPII(clone.system, true).text; + } else if (Array.isArray(clone.system)) { + clone.system = clone.system.map((entry) => { + if (typeof entry === "string") return processPII(entry, true).text; + if (entry && typeof entry === "object") { + const next = { ...entry }; + if (typeof next.text === "string") next.text = processPII(next.text, true).text; + if (typeof next.content === "string") next.content = processPII(next.content, true).text; + return next; + } + return entry; + }); + } + + if (typeof clone.input === "string") { + clone.input = processPII(clone.input, true).text; + } + if (typeof clone.prompt === "string") { + clone.prompt = processPII(clone.prompt, true).text; + } else if (Array.isArray(clone.prompt)) { + clone.prompt = clone.prompt.map((entry) => + typeof entry === "string" ? processPII(entry, true).text : entry + ); } return clone; } -// ─── Exports for Testing ────────────────────────────────────────────── - export { detectInjection, processPII, extractMessageContents, INJECTION_PATTERNS, PII_PATTERNS }; diff --git a/tests/unit/guardrails-registry.test.ts b/tests/unit/guardrails-registry.test.ts index 6909896dce..bb3e48590a 100644 --- a/tests/unit/guardrails-registry.test.ts +++ b/tests/unit/guardrails-registry.test.ts @@ -119,26 +119,49 @@ test("prompt injection guardrail blocks suspicious content in block mode", async ); }); +type ChatLikePayload = { + messages?: Array<{ role?: string; content?: unknown }>; + input?: unknown; + choices?: Array<{ message?: { role?: string; content?: unknown } }>; +}; + test("pii masker guardrail redacts request and response payloads", async () => { + // Request PII rewrite depends only on PII_REDACTION_ENABLED (not injection mode). await withEnv( { - INPUT_SANITIZER_MODE: "redact", + INPUT_SANITIZER_MODE: "block", PII_REDACTION_ENABLED: "true", PII_RESPONSE_SANITIZATION: "true", PII_RESPONSE_SANITIZATION_MODE: "redact", }, async () => { const guardrail = new PIIMaskerGuardrail(); - const preCall = await guardrail.preCall({ messages: [{ role: "user", content: "Email me at dev@example.com" }], }); assert.ok(preCall?.modifiedPayload); + const preBody = preCall?.modifiedPayload as ChatLikePayload; + assert.match(String(preBody.messages?.[0]?.content), /\[EMAIL_REDACTED\]/); + + // Responses API can send plain string items in input[] + const stringInput = await guardrail.preCall({ + input: ["Contact us at support@example.com for help"], + }); + assert.ok(stringInput?.modifiedPayload); + const stringBody = stringInput?.modifiedPayload as ChatLikePayload; assert.match( - String((preCall?.modifiedPayload as Record).messages?.[0]?.content), + String(Array.isArray(stringBody.input) ? stringBody.input[0] : undefined), /\[EMAIL_REDACTED\]/ ); + // Top-level string input + const topLevelInput = await guardrail.preCall({ + input: "Reach alice@example.com", + }); + assert.ok(topLevelInput?.modifiedPayload); + const topBody = topLevelInput?.modifiedPayload as ChatLikePayload; + assert.match(String(topBody.input), /\[EMAIL_REDACTED\]/); + const postCall = await guardrail.postCall({ choices: [ { @@ -150,9 +173,8 @@ test("pii masker guardrail redacts request and response payloads", async () => { ], }); assert.ok(postCall?.modifiedResponse, "PII in response should trigger redaction"); - const redactedContent = String( - (postCall?.modifiedResponse as Record).choices?.[0]?.message?.content - ); + const postBody = postCall?.modifiedResponse as ChatLikePayload; + const redactedContent = String(postBody.choices?.[0]?.message?.content); assert.ok( redactedContent.includes("[EMAIL_REDACTED]") || redactedContent.includes("[PHONE_REDACTED]"), "email or phone should be redacted in response" @@ -161,6 +183,24 @@ test("pii masker guardrail redacts request and response payloads", async () => { ); }); +test("pii masker does not rewrite request PII when redaction flag is off", async () => { + await withEnv( + { + INPUT_SANITIZER_MODE: "redact", + PII_REDACTION_ENABLED: "false", + PII_RESPONSE_SANITIZATION: "false", + }, + async () => { + const guardrail = new PIIMaskerGuardrail(); + const preCall = await guardrail.preCall({ + messages: [{ role: "user", content: "Email me at dev@example.com" }], + }); + assert.equal(preCall?.modifiedPayload, undefined); + } + ); +}); + + test("guardrail registry fails open when a guardrail throws", async () => { class ExplodingGuardrail extends BaseGuardrail { constructor() { diff --git a/tests/unit/input-sanitizer-pii-redaction.test.ts b/tests/unit/input-sanitizer-pii-redaction.test.ts new file mode 100644 index 0000000000..6b49db106a --- /dev/null +++ b/tests/unit/input-sanitizer-pii-redaction.test.ts @@ -0,0 +1,88 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { sanitizeRequest } from "../../src/shared/utils/inputSanitizer.ts"; + +async function withEnv(overrides: Record, fn: () => Promise | void) { + const originals: Record = {}; + for (const [key, value] of Object.entries(overrides)) { + originals[key] = process.env[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + try { + await fn(); + } finally { + for (const [key, value] of Object.entries(originals)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + +const silentLogger = { + info() {}, + warn() {}, + error() {}, +} as Pick; + +type ChatBody = { + messages?: Array<{ role?: string; content?: unknown }>; + input?: unknown; +}; + +test("sanitizeRequest redacts PII when enabled even if injection mode is block", async () => { + await withEnv( + { + INPUT_SANITIZER_ENABLED: "true", + INPUT_SANITIZER_MODE: "block", + PII_REDACTION_ENABLED: "true", + }, + () => { + const result = sanitizeRequest( + { messages: [{ role: "user", content: "Email dev@example.com" }] }, + silentLogger + ); + assert.equal(result.modified, true); + assert.ok(result.sanitizedBody); + const body = result.sanitizedBody as ChatBody; + assert.match(String(body.messages?.[0]?.content), /\[EMAIL_REDACTED\]/); + } + ); +}); + +test("sanitizeRequest redacts Responses API string input items", async () => { + await withEnv( + { + INPUT_SANITIZER_ENABLED: "true", + INPUT_SANITIZER_MODE: "warn", + PII_REDACTION_ENABLED: "true", + }, + () => { + const result = sanitizeRequest( + { input: ["Please write to support@example.com"] }, + silentLogger + ); + assert.equal(result.modified, true); + const body = result.sanitizedBody as ChatBody; + assert.match(String(Array.isArray(body.input) ? body.input[0] : undefined), /\[EMAIL_REDACTED\]/); + } + ); +}); + +test("sanitizeRequest does not rewrite PII when PII_REDACTION_ENABLED is false", async () => { + await withEnv( + { + INPUT_SANITIZER_ENABLED: "true", + INPUT_SANITIZER_MODE: "redact", + PII_REDACTION_ENABLED: "false", + }, + () => { + const result = sanitizeRequest( + { messages: [{ role: "user", content: "Email dev@example.com" }] }, + silentLogger + ); + assert.equal(result.modified, false); + assert.equal(result.sanitizedBody, null); + } + ); +});