summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/provider-openai-compat/src/stream.test.ts144
-rw-r--r--packages/provider-openai-compat/src/stream.ts11
-rw-r--r--tasks.md14
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 },
diff --git a/tasks.md b/tasks.md
index e645eca..55ea9ff 100644
--- a/tasks.md
+++ b/tasks.md
@@ -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.