diff --git a/extensions/llm/src/http-transport.test.ts b/extensions/llm/src/http-transport.test.ts index 4edaff09..ebc72d97 100644 --- a/extensions/llm/src/http-transport.test.ts +++ b/extensions/llm/src/http-transport.test.ts @@ -1,5 +1,123 @@ -import { describe, it, expect } from "vitest"; -import type { HttpStreamEvent } from "./http-transport.js"; +import { afterEach, describe, it, expect, vi } from "vitest"; +import { + FetchHttpTransport, + type HttpRequest, + type HttpStreamEvent, + type HttpTraceRecord, +} from "./http-transport.js"; + +describe("FetchHttpTransport trace header redaction", () => { + afterEach(() => vi.restoreAllMocks()); + + it.each([ + [false, "success"], + [false, "http-error"], + [false, "network-error"], + [true, "success"], + [true, "http-error"], + [true, "empty"], + [true, "network-error"], + ] as const)( + "redacts credentials (stream=%s, outcome=%s)", + async (stream, outcome) => { + const requestHeaders = Object.freeze({ + Authorization: "Bearer test-bearer-secret", + "PrOxY-AuThOrIzAtIoN": "Basic test-proxy-secret", + "X-API-Key": "test-anthropic-secret", + "api-key": "test-api-secret", + Cookie: "session=test-cookie-secret", + "content-type": "application/json", + "x-request-id": "request-123", + }); + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async () => { + if (outcome === "network-error") + throw new Error("network unavailable"); + return new Response( + outcome === "empty" + ? null + : stream && outcome === "success" + ? "data: {}\n\n" + : "{}", + { + status: + outcome === "http-error" + ? 401 + : outcome === "empty" + ? 204 + : 200, + headers: { + "Set-Cookie": "response=test-response-cookie-secret", + "x-request-id": "response-123", + }, + }, + ); + }); + const traces: HttpTraceRecord[] = []; + const transport = new FetchHttpTransport({ + traceRecorder: { + record: (record) => { + traces.push(record); + }, + }, + }); + const request: HttpRequest = { + url: "https://example.invalid/v1/messages", + method: "POST", + headers: requestHeaders, + body: "{}", + timeoutMs: 1000, + trace: { + sessionId: "session-1", + spanId: "span-1", + provider: "anthropic", + model: "test-model", + }, + }; + const work = stream + ? transport.requestStream(request, () => undefined) + : transport.request(request); + if (outcome === "network-error") { + await expect(work).rejects.toThrow("network unavailable"); + } else { + await work; + } + + const sentHeaders = fetchMock.mock.calls[0]?.[1]?.headers; + expect(sentHeaders).toMatchObject(requestHeaders); + expect(request.headers).toEqual(requestHeaders); + expect(traces).toHaveLength(1); + const trace = traces[0]!; + expect(trace.request.headers).toMatchObject({ + Authorization: ["[REDACTED]"], + "PrOxY-AuThOrIzAtIoN": ["[REDACTED]"], + "X-API-Key": ["[REDACTED]"], + "api-key": ["[REDACTED]"], + Cookie: ["[REDACTED]"], + "content-type": ["application/json"], + "x-request-id": ["request-123"], + }); + if (outcome !== "network-error") { + expect(trace.response?.headers).toMatchObject({ + "set-cookie": ["[REDACTED]"], + "x-request-id": ["response-123"], + }); + } + const serialized = JSON.stringify(trace); + for (const secret of [ + "test-bearer-secret", + "test-proxy-secret", + "test-anthropic-secret", + "test-api-secret", + "test-cookie-secret", + "test-response-cookie-secret", + ]) { + expect(serialized).not.toContain(secret); + } + }, + ); +}); // --------------------------------------------------------------------------- // http-transport.ts (test exported class behavior via mocks) diff --git a/extensions/llm/src/http-transport.ts b/extensions/llm/src/http-transport.ts index 4f3e3aca..8fc8e7a6 100644 --- a/extensions/llm/src/http-transport.ts +++ b/extensions/llm/src/http-transport.ts @@ -444,18 +444,34 @@ function cloneRequestHeaders( headers: Record, ): Record { return Object.fromEntries( - Object.entries(headers).map(([key, value]) => [key, [value]]), + Object.entries(headers).map(([key, value]) => [ + key, + [redactTraceHeader(key, value)], + ]), ); } function cloneFetchHeaders(headers: Headers): Record { const cloned: Record = {}; headers.forEach((value, key) => { - cloned[key] = [value]; + cloned[key] = [redactTraceHeader(key, value)]; }); return cloned; } +const SENSITIVE_TRACE_HEADERS = new Set([ + "authorization", + "proxy-authorization", + "x-api-key", + "api-key", + "cookie", + "set-cookie", +]); + +function redactTraceHeader(name: string, value: string): string { + return SENSITIVE_TRACE_HEADERS.has(name.toLowerCase()) ? "[REDACTED]" : value; +} + function truncateTraceText(text: string, maxBytes: number): string { if (text.length <= maxBytes) { return text; diff --git a/tests/integration/session-trace.test.ts b/tests/integration/session-trace.test.ts new file mode 100644 index 00000000..85a1e052 --- /dev/null +++ b/tests/integration/session-trace.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { FetchHttpTransport } from "../../extensions/llm/src/http-transport.js"; +import { SessionTraceStore } from "../../src/gateway/session/session-trace-store.js"; +import { BUILTIN_STORAGE_LAYOUT_DEFAULTS } from "../../src/bootstrap/config/defaults.js"; +import { + getSessionTraceDirectory, + resolveStorageLayout, +} from "../../src/gateway/storage/layout.js"; + +describe("session trace credential persistence", () => { + afterEach(() => vi.restoreAllMocks()); + + it("writes redacted headers while retaining useful diagnostic metadata", async () => { + const tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), "step-trace-redaction-"), + ); + const layout = resolveStorageLayout( + tempDir, + BUILTIN_STORAGE_LAYOUT_DEFAULTS, + ); + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response("{}")); + try { + const transport = new FetchHttpTransport({ + traceRecorder: new SessionTraceStore(layout), + }); + await transport.request({ + url: "https://example.invalid/v1/chat/completions", + method: "POST", + headers: { + authorization: "Bearer test-key-not-for-persistence", + "x-api-key": "test-anthropic-key-not-for-persistence", + "content-type": "application/json", + }, + body: JSON.stringify({ model: "test-model" }), + timeoutMs: 1000, + trace: { + sessionId: "session-1", + spanId: "span-1", + provider: "openai", + model: "test-model", + }, + }); + expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({ + authorization: "Bearer test-key-not-for-persistence", + "x-api-key": "test-anthropic-key-not-for-persistence", + }); + const saved = await fs.readFile( + path.join(getSessionTraceDirectory(layout, "session-1"), "span-1.json"), + "utf8", + ); + expect(saved).not.toContain("test-key-not-for-persistence"); + expect(saved).not.toContain("test-anthropic-key-not-for-persistence"); + expect(JSON.parse(saved)).toMatchObject({ + sessionId: "session-1", + spanId: "span-1", + request: { + headers: { + authorization: ["[REDACTED]"], + "x-api-key": ["[REDACTED]"], + }, + body: JSON.stringify({ model: "test-model" }), + }, + response: { status: 200, body: "{}" }, + }); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); +});