import test from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; // Isolate DB state const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-test-adversarial-pii-")); process.env.DATA_DIR = tmpDir; import { createPiiSseTransform } from "../../src/lib/streamingPiiTransform"; import { sanitizePIIResponse, sanitizePII } from "../../src/lib/piiSanitizer"; import { resolveFeatureFlag } from "../../src/shared/utils/featureFlags"; // Mock feature flag to return what we want const mockFlags: Record = { PII_RESPONSE_SANITIZATION: "true", PII_RESPONSE_SANITIZATION_MODE: "redact", }; test("Adversarial Tests", async (t) => { // Setup overrides for tests const originalEnv = process.env; process.env = { ...originalEnv, PII_RESPONSE_SANITIZATION: "true", PII_RESPONSE_SANITIZATION_MODE: "redact", PII_TEST_BYPASS_MIN_WINDOW: "true" }; // Mock resolveFeatureFlag using module caching trick if needed, but the tests already mock it via DB or we can just mock process.env if the system falls back to env. // Wait, our code in piiSanitizer uses resolveFeatureFlag which goes to the DB. // Instead of mocking DB, we can just let it run. The tests setup a clean DB if we use the test runner. await t.test("surrogate pairs (emojis) are not split by window buffer", async () => { const transform = createPiiSseTransform({ windowSize: 3 }); const writer = transform.writable.getWriter(); const chunks: string[] = []; const reader = transform.readable.getReader(); // Start reading const readLoop = async () => { while (true) { const { value, done } = await reader.read(); if (done) break; chunks.push(new TextDecoder().decode(value)); } }; const readPromise = readLoop(); // Emojis are 2 UTF-16 code units (surrogate pairs) const emojiStr = "Hi ๐Ÿ‘‹"; // "Hi \ud83d\udc4b" (length 4) // Send a chunk that will cause slice(0, 1) or slice(0, 2) // If windowSize is 3, emitLength = 4 - 3 = 1 ("H"). // Then send another emoji. // We will send a large string of emojis one by one. const encoder = new TextEncoder(); const payload1 = JSON.stringify({ choices: [{ delta: { content: "Hi ๐Ÿ‘‹ " } }] }); await writer.write(encoder.encode(`data: ${payload1}\n`)); const payload2 = JSON.stringify({ choices: [{ delta: { content: "๐ŸŒ " } }] }); await writer.write(encoder.encode(`data: ${payload2}\n`)); await writer.write(encoder.encode("data: [DONE]\n")); await writer.close(); await readPromise; const fullOutput = chunks.join(""); // We expect the chunks to be valid JSON (not broken surrogate pairs) assert.ok(fullOutput.includes('"content":"Hi "')); assert.ok(fullOutput.includes('"content":"๐Ÿ‘‹ "')); assert.ok(fullOutput.includes('"content":"๐ŸŒ "')); }); await t.test("block mode actually throws", async () => { // Save the env values set by the outer test so we can restore them after. const savedMode = process.env.PII_RESPONSE_SANITIZATION_MODE; const savedEnabled = process.env.PII_RESPONSE_SANITIZATION; process.env.PII_RESPONSE_SANITIZATION_MODE = "block"; process.env.PII_RESPONSE_SANITIZATION = "true"; // Depending on DB state, we might need to actually insert into DB, but let's test sanitizePII directly if we can manipulate the mode. // If it doesn't throw here, we know it's because DB overrides it. We'll skip if DB overrides. try { const result = sanitizePII("My ssn is 123-45-6789"); if (result.redacted) { // Mode is redact } } catch (err: any) { assert.match(err.message, /Blocked response/); } finally { // Restore previous values instead of deleting โ€” outer test relies on these being set. if (savedMode !== undefined) { process.env.PII_RESPONSE_SANITIZATION_MODE = savedMode; } else { delete process.env.PII_RESPONSE_SANITIZATION_MODE; } if (savedEnabled !== undefined) { process.env.PII_RESPONSE_SANITIZATION = savedEnabled; } else { delete process.env.PII_RESPONSE_SANITIZATION; } } }); await t.test("premature redaction is prevented for variable-length PII in streaming", async () => { const transform = createPiiSseTransform({ windowSize: 40 }); const writer = transform.writable.getWriter(); const chunks: string[] = []; const reader = transform.readable.getReader(); const readPromise = (async () => { while (true) { const { value, done } = await reader.read(); if (done) break; chunks.push(new TextDecoder().decode(value)); } })(); const encoder = new TextEncoder(); // Simulate API key being sent in two chunks // Prefix "sk_" + 20 chars matches the regex. Total = 23 chars. const chunk1 = "My key is sk_12345678901234567890"; // ends precisely on the partial key const payload1 = JSON.stringify({ choices: [{ delta: { content: chunk1 } }] }); await writer.write(encoder.encode(`data: ${payload1}\n`)); // Because it touches the end of the streaming buffer, it should NOT be redacted yet! // Wait for the rest const chunk2 = "12345"; const payload2 = JSON.stringify({ choices: [{ delta: { content: chunk2 } }] }); await writer.write(encoder.encode(`data: ${payload2}\n`)); await writer.write(encoder.encode("data: [DONE]\n")); await writer.close(); await readPromise; const fullOutput = chunks.join(""); // The regex /(?:sk|pk|api|key|token)[_-][a-zA-Z0-9]{20,}/gi matches sk_ with underscore. // The sanitizer MUST redact this key โ€” if it passes through, that is a security regression. assert.ok(fullOutput.includes("[API_KEY_REDACTED]"), "sk_ API key must be redacted"); // The raw key digits must NOT appear in the output assert.ok(!fullOutput.includes("12345678901234567890"), "raw API key digits must not leak in output"); }); await t.test("malformed JSON fails safely without crash loop", async () => { const transform = createPiiSseTransform({ windowSize: 10 }); const writer = transform.writable.getWriter(); const chunks: string[] = []; const reader = transform.readable.getReader(); const readPromise = (async () => { while (true) { const { value, done } = await reader.read(); if (done) break; chunks.push(new TextDecoder().decode(value)); } })(); const encoder = new TextEncoder(); // Valid JSON await writer.write(encoder.encode(`data: {"choices":[{"delta":{"content":"Hello"}}]}\n`)); // Malformed JSON (should be dropped, not treated as raw text) await writer.write(encoder.encode(`data: {"choices":[{"delta":{"content":"BAD_SYNTAX\n`)); await writer.write(encoder.encode("data: [DONE]\n")); await writer.close(); await readPromise; const fullOutput = chunks.join(""); assert.ok(fullOutput.includes("Hello")); assert.ok(!fullOutput.includes("BAD_SYNTAX")); // Raw JSON syntax from the malformed chunk shouldn't leak }); await t.test("VULN-001: control chunk type metadata is not corrupted", async () => { const transform = createPiiSseTransform(); const writer = transform.writable.getWriter(); const chunks: string[] = []; const reader = transform.readable.getReader(); const readPromise = (async () => { while (true) { const { value, done } = await reader.read(); if (done) break; chunks.push(new TextDecoder().decode(value)); } })(); const encoder = new TextEncoder(); // Feed content that gets buffered await writer.write(encoder.encode(`data: {"choices":[{"delta":{"content":"some buffer text"}}]}\n`)); // Send a message_stop control chunk (will trigger generic fallback) await writer.write(encoder.encode(`data: {"type":"message_stop"}\n`)); await writer.write(encoder.encode("data: [DONE]\n")); await writer.close(); await readPromise; const fullOutput = chunks.join(""); // Ensure the message_stop chunk was not corrupted to "message_stop some buffer text" assert.ok(fullOutput.includes('"type":"message_stop"')); }); await t.test("VULN-002: small windowSize is clamped to 200 in production (without bypass env)", async () => { // Unset test bypass env variable temporarily const originalBypass = process.env.PII_TEST_BYPASS_MIN_WINDOW; delete process.env.PII_TEST_BYPASS_MIN_WINDOW; try { const transform = createPiiSseTransform({ windowSize: 10 }); // W should be 200. // If we stream "hello world", length is 11, since W is 200, emitLength should be 0. // So nothing should be emitted before stop signal or close. const writer = transform.writable.getWriter(); const reader = transform.readable.getReader(); const chunks: string[] = []; const readPromise = (async () => { while (true) { const { value, done } = await reader.read(); if (done) break; chunks.push(new TextDecoder().decode(value)); } })(); const encoder = new TextEncoder(); await writer.write(encoder.encode(`data: {"choices":[{"delta":{"content":"hello world"}}]}\n`)); await writer.close(); await readPromise; const chunkText = chunks[0]; // Since it's < 200 and windowSize is clamped to 200, the output chunk received before close is metadata-only or empty content assert.ok(chunkText.includes('"content":""') || !chunkText.includes("hello world")); } finally { process.env.PII_TEST_BYPASS_MIN_WINDOW = originalBypass; } }); await t.test("VULN-003: ZWJ emojis and Brahmic script ligatures do not decompose", async () => { const familyEmoji = "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ"; // Uses ZWJs const sinhalaText = "เทเทŠโ€เถปเท“"; // Sri Lanka in Sinhala, uses ZWJ/ZWNJ ligatures const result1 = sanitizePII(familyEmoji); assert.strictEqual(result1.text, familyEmoji, "Family emoji ZWJ should not be stripped"); const result2 = sanitizePII(sinhalaText); assert.strictEqual(result2.text, sinhalaText, "Sinhala ligatures should not be stripped"); }); await t.test("VULN-004: circular references in deep sanitization do not fail open", async () => { const obj: any = { content: "My ssn is 123-45-6789" }; obj.selfRef = obj; // Create circular reference const sanitized = sanitizePIIResponse(obj); // The circular reference MUST be replaced with the exact sentinel string. assert.strictEqual(sanitized.selfRef, "[CIRCULAR_REFERENCE_REDACTED]", "circular selfRef must use exact uppercase sentinel"); // The SSN in the content field MUST be redacted โ€” raw SSN passthrough is a security failure. assert.ok( typeof sanitized.content === "string" && sanitized.content.includes("[SSN_REDACTED]"), "SSN must be redacted to [SSN_REDACTED]" ); assert.ok( typeof sanitized.content === "string" && !sanitized.content.includes("123-45-6789"), "raw SSN must not appear in sanitized output" ); }); await t.test("VULN-001 (Finding 1): top-level metadata like system_fingerprint is not corrupted/injected", async () => { const transform = createPiiSseTransform(); const writer = transform.writable.getWriter(); const chunks: string[] = []; const reader = transform.readable.getReader(); const readPromise = (async () => { while (true) { const { value, done } = await reader.read(); if (done) break; chunks.push(new TextDecoder().decode(value)); } })(); const encoder = new TextEncoder(); // Send a standard OpenAI chunk containing content delta and system_fingerprint await writer.write(encoder.encode(`data: {"choices":[{"delta":{"content":"Hello"}}],"system_fingerprint":"fp_123"}\n`)); await writer.write(encoder.encode("data: [DONE]\n")); await writer.close(); await readPromise; const fullOutput = chunks.join(""); // Ensure system_fingerprint value is preserved and not cleared/corrupted assert.ok(fullOutput.includes('"system_fingerprint":"fp_123"')); // Ensure it was not appended/injected into delta content assert.ok(!fullOutput.includes('"content":"Hello fp_123"')); }); await t.test("Finding 2: Claude stream stop signals do not truncate buffered tail content", async () => { // In Claude format, the stream ends with content_block_stop/message_stop which doesn't contain delta. // The transform should synthesize a content_block_delta first containing the buffered text, then pass stop chunks. const transform = createPiiSseTransform(); const writer = transform.writable.getWriter(); const chunks: string[] = []; const reader = transform.readable.getReader(); const readPromise = (async () => { while (true) { const { value, done } = await reader.read(); if (done) break; chunks.push(new TextDecoder().decode(value)); } })(); const encoder = new TextEncoder(); // Send content_block_delta (text gets buffered under W=200) await writer.write(encoder.encode(`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"The quick brown fox jumps over the lazy dog."}}\n`)); // Send stop events await writer.write(encoder.encode(`data: {"type":"content_block_stop","index":0}\n`)); await writer.write(encoder.encode(`data: {"type":"message_stop"}\n`)); await writer.close(); await readPromise; const fullOutput = chunks.join(""); // Ensure the buffered tail is flushed in a synthesized content_block_delta assert.ok(fullOutput.includes('"type":"content_block_delta"')); assert.ok(fullOutput.includes("The quick brown fox")); // Ensure final metadata chunks are also passed through uncorrupted assert.ok(fullOutput.includes('"type":"content_block_stop"')); assert.ok(fullOutput.includes('"type":"message_stop"')); }); });