Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions docs/reference/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/FEATURE_FLAGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
19 changes: 10 additions & 9 deletions docs/security/GUARDRAILS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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` →
Expand Down Expand Up @@ -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
Expand Down
27 changes: 24 additions & 3 deletions src/lib/guardrails/piiMasker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ type PiiDetection = {
type JsonRecord = Record<string, unknown>;

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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
96 changes: 75 additions & 21 deletions src/shared/utils/inputSanitizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,20 +278,20 @@ 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) {
logger.warn?.(
`[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;
}
}

Expand All @@ -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) {
const redactContentValue = (value) => {
if (typeof value === "string") {
return processPII(value, true).text;
}
if (Array.isArray(value)) {
return value.map((part) => {
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;
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 };
52 changes: 46 additions & 6 deletions tests/unit/guardrails-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).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: [
{
Expand All @@ -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<string, unknown>).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"
Expand All @@ -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() {
Expand Down
Loading
Loading