diff options
| -rw-r--r-- | packages/provider-openai-compat/src/stream.test.ts | 144 | ||||
| -rw-r--r-- | packages/provider-openai-compat/src/stream.ts | 11 | ||||
| -rw-r--r-- | tasks.md | 14 |
3 files changed, 156 insertions, 13 deletions
diff --git a/packages/provider-openai-compat/src/stream.test.ts b/packages/provider-openai-compat/src/stream.test.ts index ad0f37e..25fd84e 100644 --- a/packages/provider-openai-compat/src/stream.test.ts +++ b/packages/provider-openai-compat/src/stream.test.ts @@ -663,9 +663,14 @@ describe("streamChat — record-mode redaction (trace-replay)", () => { headers: { "content-type": "text/event-stream" }, }), (fx) => { - const redactedHeaders = { ...fx.request.headers }; - if (redactedHeaders.authorization) { - redactedHeaders.authorization = `Bearer ${maskSecret(redactedHeaders.authorization.replace(/^Bearer\s+/, ""))}`; + const redactedHeaders: Record<string, string> = {}; + for (const [key, value] of Object.entries(fx.request.headers)) { + if (key.toLowerCase() === "authorization") { + const token = value.replace(/^Bearer\s+/i, ""); + redactedHeaders[key] = `Bearer ${maskSecret(token)}`; + } else { + redactedHeaders[key] = value; + } } capturedFixture = { request: { ...fx.request, headers: redactedHeaders }, @@ -703,14 +708,141 @@ describe("streamChat — record-mode redaction (trace-replay)", () => { expect(serialized).toContain("hi"); }); + it("redacts capitalized Authorization header (the real leak casing)", async () => { + const apiKey = "sk-LIVEKEY1234567890abcdef"; + const responseBody = "data: [DONE]\n"; + + let capturedFixture: HttpExchangeFixture | undefined; + const wrappedFetch = recordFetch( + async () => + new Response(responseBody, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + (fx) => { + const redactedHeaders: Record<string, string> = {}; + for (const [key, value] of Object.entries(fx.request.headers)) { + if (key.toLowerCase() === "authorization") { + const token = value.replace(/^Bearer\s+/i, ""); + redactedHeaders[key] = `Bearer ${maskSecret(token)}`; + } else { + redactedHeaders[key] = value; + } + } + capturedFixture = { + request: { ...fx.request, headers: redactedHeaders }, + response: fx.response, + }; + }, + ); + + await wrappedFetch("https://api.example.com/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: '{"model":"test","messages":[],"stream":true}', + }); + + assertDefined(capturedFixture); + expect(capturedFixture.request.headers.Authorization).toBe("Bearer sk-…redacted…def"); + expect(capturedFixture.request.headers.Authorization).not.toContain("LIVEKEY1234567890abc"); + + const serialized = serializeFixture(capturedFixture); + expect(serialized).not.toContain("LIVEKEY1234567890abc"); + expect(serialized).toContain("Bearer sk-…redacted…def"); + }); + + it("redacts lowercase authorization header", async () => { + const apiKey = "sk-abcdefghijkmnop"; + + let capturedFixture: HttpExchangeFixture | undefined; + const wrappedFetch = recordFetch( + async () => new Response("data: [DONE]\n", { status: 200 }), + (fx) => { + const redactedHeaders: Record<string, string> = {}; + for (const [key, value] of Object.entries(fx.request.headers)) { + if (key.toLowerCase() === "authorization") { + const token = value.replace(/^Bearer\s+/i, ""); + redactedHeaders[key] = `Bearer ${maskSecret(token)}`; + } else { + redactedHeaders[key] = value; + } + } + capturedFixture = { + request: { ...fx.request, headers: redactedHeaders }, + response: fx.response, + }; + }, + ); + + await wrappedFetch("https://api.example.com/v1/chat/completions", { + method: "POST", + headers: { authorization: `Bearer ${apiKey}` }, + body: null, + }); + + assertDefined(capturedFixture); + expect(capturedFixture.request.headers.authorization).toBe("Bearer sk-…redacted…nop"); + expect(capturedFixture.request.headers.authorization).not.toContain("abcdefghijkm"); + }); + + it("guard: no header named authorization (any case) survives with a raw sk- token", async () => { + const apiKey = "sk-REALKEY_1234567890abcdef"; + + let capturedFixture: HttpExchangeFixture | undefined; + const wrappedFetch = recordFetch( + async () => new Response("data: [DONE]\n", { status: 200 }), + (fx) => { + const redactedHeaders: Record<string, string> = {}; + for (const [key, value] of Object.entries(fx.request.headers)) { + if (key.toLowerCase() === "authorization") { + const token = value.replace(/^Bearer\s+/i, ""); + redactedHeaders[key] = `Bearer ${maskSecret(token)}`; + } else { + redactedHeaders[key] = value; + } + } + capturedFixture = { + request: { ...fx.request, headers: redactedHeaders }, + response: fx.response, + }; + }, + ); + + await wrappedFetch("https://api.example.com/v1/chat/completions", { + method: "POST", + headers: { Authorization: `Bearer ${apiKey}` }, + body: null, + }); + + assertDefined(capturedFixture); + for (const [key, value] of Object.entries(capturedFixture.request.headers)) { + if (key.toLowerCase() === "authorization") { + expect(value).not.toContain(apiKey); + expect(value).not.toMatch(/sk-[A-Za-z0-9]{10,}/); + } + } + + const serialized = serializeFixture(capturedFixture); + expect(serialized).not.toContain(apiKey); + expect(serialized).not.toMatch(/sk-[A-Za-z0-9]{10,}/); + }); + it("redacts a short API key (≤7 chars → full mask)", async () => { let capturedFixture: HttpExchangeFixture | undefined; const wrappedFetch = recordFetch( async () => new Response("data: [DONE]\n", { status: 200 }), (fx) => { - const redactedHeaders = { ...fx.request.headers }; - if (redactedHeaders.authorization) { - redactedHeaders.authorization = `Bearer ${maskSecret(redactedHeaders.authorization.replace(/^Bearer\s+/, ""))}`; + const redactedHeaders: Record<string, string> = {}; + for (const [key, value] of Object.entries(fx.request.headers)) { + if (key.toLowerCase() === "authorization") { + const token = value.replace(/^Bearer\s+/i, ""); + redactedHeaders[key] = `Bearer ${maskSecret(token)}`; + } else { + redactedHeaders[key] = value; + } } capturedFixture = { request: { ...fx.request, headers: redactedHeaders }, diff --git a/packages/provider-openai-compat/src/stream.ts b/packages/provider-openai-compat/src/stream.ts index 7d1f2c8..7cefb25 100644 --- a/packages/provider-openai-compat/src/stream.ts +++ b/packages/provider-openai-compat/src/stream.ts @@ -106,9 +106,14 @@ export async function* streamChat( const { recordFetch: rf, saveFixture } = await import("@dispatch/trace-replay"); effectiveFetch = rf(effectiveFetch, (fx: HttpExchangeFixture) => { try { - const redactedHeaders = { ...fx.request.headers }; - if (redactedHeaders.authorization) { - redactedHeaders.authorization = `Bearer ${maskSecret(redactedHeaders.authorization.replace(/^Bearer\s+/, ""))}`; + const redactedHeaders: Record<string, string> = {}; + for (const [key, value] of Object.entries(fx.request.headers)) { + if (key.toLowerCase() === "authorization") { + const token = value.replace(/^Bearer\s+/i, ""); + redactedHeaders[key] = `Bearer ${maskSecret(token)}`; + } else { + redactedHeaders[key] = value; + } } const redacted: HttpExchangeFixture = { request: { ...fx.request, headers: redactedHeaders }, @@ -255,10 +255,16 @@ independent of the SQLite trace-store; the lib is redaction-free (caller self-re 44→48 tests → **331 vitest**, typecheck + biome 0/0. CR: none. prompts/provider-trace-replay.md, reports/provider-trace-replay.md. - [x] **Build wiring** (orchestrator): root tsconfig ref ✓; provider dep `@dispatch/trace-replay` ✓; `bun install` ✓. -- [ ] **Live capture** (orchestrator): boot with `DISPATCH_RECORD_FIXTURE` + real key → - capture a real flash exchange → secret-free check → re-summon provider to swap the - synthetic text-turn fixture for the real one + update expected text (validates our SSE - assumptions against reality — D5). +- [~] **Live capture** (orchestrator): FOUND A BUG (the whole point of D5). The real + capture leaked the live API key — record-mode self-redaction MISSED the `Authorization` + header (capital A + `Bearer ` prefix; redaction matched lowercase `authorization`). + Committed span/journal path is SAFE (journal + trace-DB = ZERO_LEAKS); only the + record-mode fixture path leaked, caught PRE-COMMIT (fixture was /tmp-only, scrubbed). +- [ ] **Record-mode redaction fix** (provider owner): case-insensitive auth-header mask + + a test using the REAL `Authorization: Bearer …` representation (must fail before, pass + after). prompts/provider-trace-replay-fix.md. +- [ ] **Re-capture + swap** (orchestrator): after the fix, re-run live capture → secret + check → re-summon to commit the clean real fixture + update assertions. Summons: prompts/phase-a-{kernel-logging,journal-sink}.md; reports/phase-a-{kernel-logging,journal-sink}.md. |
