import { describe, expect, it } from "vitest"; import { collectSensitiveStringValues, redactSensitive, redactSensitiveValueOccurrences, stripSecretBearingUrlParts, } from "../middleware/redact-sensitive.js"; describe("redactSensitive", () => { it("redacts a plaintext password field on a sign-in body", () => { const body = { email: "user@example.com", password: "founding6gomez6croaking", }; const out = redactSensitive(body) as Record; expect(out.email).toBe("user@example.com"); expect(out.password).toBe("[REDACTED]"); expect((body as Record).password).toBe( "founding6gomez6croaking", ); }); it("redacts password key regardless of casing", () => { expect( (redactSensitive({ Password: "x" }) as Record).Password, ).toBe("[REDACTED]"); expect( (redactSensitive({ PASSWORD: "x" }) as Record).PASSWORD, ).toBe("[REDACTED]"); }); it("redacts known credential-shaped keys", () => { const out = redactSensitive({ currentPassword: "a", newPassword: "b", access_token: "c", refresh_token: "d", api_key: "e", authorization: "Bearer f", }) as Record; for (const value of Object.values(out)) { expect(value).toBe("[REDACTED]"); } }); it("drops provider credential envelopes and redacts provider keys outside them", () => { const out = redactSensitive({ credentials: { botToken: "nested-bot-canary", futureCredential: "nested-future-canary", }, botToken: "bot-canary", signing_secret: "signing-canary", webhookSecret: "webhook-canary", app_secret: "app-canary", applicationSecret: "application-canary", }) as Record; expect(out).toEqual({ credentials: "[REDACTED]", botToken: "[REDACTED]", signing_secret: "[REDACTED]", webhookSecret: "[REDACTED]", app_secret: "[REDACTED]", applicationSecret: "[REDACTED]", }); expect(JSON.stringify(out)).not.toContain("canary"); }); it("removes raw, JSON-escaped, and URL-encoded submitted credentials from prose", () => { const credential = "secret value/with\nnewline"; const submittedValues = collectSensitiveStringValues({ action: "configure", credentials: { botToken: credential }, }); const out = redactSensitiveValueOccurrences( { raw: `Provider rejected ${credential}`, json: `Provider rejected ${JSON.stringify(credential).slice(1, -1)}`, url: `Provider rejected ${encodeURIComponent(credential)}`, form: `Provider rejected ${encodeURIComponent(credential).replaceAll("%20", "+")}`, scopes: ["chat:write", "reactions:write"], }, submittedValues, ); expect(JSON.stringify(out)).not.toContain("secret value"); expect(JSON.stringify(out)).not.toContain("secret%20value"); expect(out).toMatchObject({ scopes: ["chat:write", "reactions:write"] }); }); it("sanitizes malformed UTF-16 credential values without throwing", () => { const malformedCredential = "\ud800"; expect(() => redactSensitiveValueOccurrences( `Provider rejected ${malformedCredential}`, [malformedCredential], ), ).not.toThrow(); expect( redactSensitiveValueOccurrences( `Provider rejected ${malformedCredential}`, [malformedCredential], ), ).toBe("Provider rejected [REDACTED]"); }); it("removes a normalized provider echo of a whitespace-padded credential", () => { expect( redactSensitiveValueOccurrences("Provider rejected padded-token-canary", [ " padded-token-canary \n", ]), ).toBe("Provider rejected [REDACTED]"); }); it("redacts an OAuth provider's error_description and error_uri from a callback query", () => { const out = redactSensitive({ state: "paperclip-state", error: "access_denied", error_description: "\u001b[31mPaste your recovery key\u001b[0m sk-live-canary", error_uri: "https://attacker.example/explain?leak=sk-live-canary", }) as Record; // The `error` code is Paperclip's one allowlisted label, so it stays legible // in logs; the provider's prose does not. expect(out.error).toBe("access_denied"); expect(out.state).toBe("paperclip-state"); expect(out.error_description).toBe("[REDACTED]"); expect(out.error_uri).toBe("[REDACTED]"); expect(JSON.stringify(out)).not.toContain("sk-live-canary"); expect(JSON.stringify(out)).not.toContain("\\u001b"); }); it("redacts bare value and token fields recursively", () => { const out = redactSensitive({ token: "secret-token", nested: { value: "secret-value" }, entries: [{ value: "array-secret" }], limit: 20, }) as Record; expect(out.token).toBe("[REDACTED]"); expect((out.nested as Record).value).toBe("[REDACTED]"); expect((out.entries as Array>)[0].value).toBe( "[REDACTED]", ); expect(out.limit).toBe(20); expect(JSON.stringify(out)).not.toMatch( /secret-token|secret-value|array-secret/, ); }); it("strips secret-bearing query and fragment values from source URLs", () => { const out = redactSensitive({ source: "https://github.com/acme/private-skill?token=secret#token=secret", }) as Record; expect(out.source).toBe("https://github.com/acme/private-skill"); }); it("recurses into nested objects and arrays", () => { const out = redactSensitive({ user: { email: "user@example.com", password: "secret-pass" }, tokens: [{ access_token: "t1" }, { access_token: "t2" }], }) as Record; expect((out.user as Record).email).toBe( "user@example.com", ); expect((out.user as Record).password).toBe("[REDACTED]"); const tokens = out.tokens as Array>; expect(tokens[0].access_token).toBe("[REDACTED]"); expect(tokens[1].access_token).toBe("[REDACTED]"); }); it("leaves primitives and non-sensitive keys untouched", () => { const body = { email: "a@b.c", name: "Alice", count: 7, active: true, missing: null, }; expect(redactSensitive(body)).toEqual(body); }); it("returns primitives unchanged", () => { expect(redactSensitive("hello")).toBe("hello"); expect(redactSensitive(42)).toBe(42); expect(redactSensitive(null)).toBe(null); expect(redactSensitive(undefined)).toBe(undefined); }); it("caps recursion depth so cycles do not pin the logger", () => { const cycle: Record = { name: "root" }; cycle.self = cycle; expect(() => redactSensitive(cycle)).not.toThrow(); }); it("omits deeply-nested arrays at the depth cap instead of leaking null entries to JSON", () => { // Build an object whose array field is reached at MAX_DEPTH. Recursing // into the array elements would exceed the cap; without the array-level // guard, `value.map` would produce `[undefined, ...]` which JSON.stringify // renders as `[null, ...]`. Object properties at the same cap are // already absent from the JSON output (JSON.stringify skips undefined // values on objects), so this test pins the array path to the same // contract: silently absent, not visible as nulls. let payload: Record = { values: [1, 2, 3] }; for (let i = 0; i < 5; i++) payload = { nested: payload }; const out = redactSensitive(payload); const json = JSON.stringify(out); expect(json).not.toContain("null"); expect(json).not.toContain("[1,2,3]"); }); }); describe("stripSecretBearingUrlParts", () => { it("keeps a request path legible while dropping its complete query and fragment", () => { expect( stripSecretBearingUrlParts( "/api/tools/oauth/callback?code=authorization-code&error_description=provider-prose#fragment", ), ).toBe("/api/tools/oauth/callback"); }); });