From ddc62d0077a99978750a04c80178a1edcf79a399 Mon Sep 17 00:00:00 2001 From: protosphinx <133899485+protosphinx@users.noreply.github.com> Date: Tue, 19 May 2026 16:10:36 +0000 Subject: [PATCH] feat(redact): add value-pattern redaction for JWTs and PEM blocks The README documents JWT and PEM block stripping as default record-time behavior, but the implementation only covered key-name patterns. This adds DEFAULT_REDACT_VALUE_PATTERNS (JWT three-segment base64url, PEM BEGIN block) and redactValues(), wires both into the stdio record path, and extends the redact test suite with 11 new cases. --- src/record.ts | 6 ++-- src/redact.ts | 42 ++++++++++++++++++++++++++ test/redact.test.ts | 72 ++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 116 insertions(+), 4 deletions(-) diff --git a/src/record.ts b/src/record.ts index 2a7b172..dc13b4d 100644 --- a/src/record.ts +++ b/src/record.ts @@ -1,7 +1,7 @@ import { spawn } from "node:child_process"; import { createWriteStream } from "node:fs"; import { lineFrames } from "./transport.js"; -import { redactDeep, DEFAULT_REDACT_PATTERNS } from "./redact.js"; +import { redactDeep, DEFAULT_REDACT_PATTERNS, redactValues, DEFAULT_REDACT_VALUE_PATTERNS } from "./redact.js"; import type { Frame, JsonRpcMessage } from "./types.js"; export interface RecordOptions { @@ -69,7 +69,7 @@ function passthroughIn( childStdin.write(line + "\n"); try { const msg = JSON.parse(line) as JsonRpcMessage; - const safe = redactDeep(msg, redact) as JsonRpcMessage; + const safe = redactValues(redactDeep(msg, redact), DEFAULT_REDACT_VALUE_PATTERNS) as JsonRpcMessage; append({ t: (Date.now() - start) / 1000, dir: "→", msg: safe }); } catch { // not JSON - passed through, not recorded @@ -85,7 +85,7 @@ function passthroughOut( process.stdout.write(line + "\n"); try { const msg = JSON.parse(line) as JsonRpcMessage; - const safe = redactDeep(msg, redact) as JsonRpcMessage; + const safe = redactValues(redactDeep(msg, redact), DEFAULT_REDACT_VALUE_PATTERNS) as JsonRpcMessage; append({ t: (Date.now() - start) / 1000, dir: "←", msg: safe }); } catch { // not JSON - passed through, not recorded diff --git a/src/redact.ts b/src/redact.ts index 8b3aedd..14b9fba 100644 --- a/src/redact.ts +++ b/src/redact.ts @@ -11,6 +11,18 @@ export const DEFAULT_REDACT_PATTERNS: string[] = [ "*_secret", ]; +/** + * Value-content patterns applied automatically on every `record` run. + * Any string value (at any depth) whose content matches one of these + * patterns is replaced with `` regardless of key name. + * + * Covers JWT bearer tokens (header starts with `eyJ`) and PEM blocks. + */ +export const DEFAULT_REDACT_VALUE_PATTERNS: RegExp[] = [ + /^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/, + /-----BEGIN [A-Z0-9 ]+-----/, +]; + /** * Replace values whose keys match any of the given patterns with * ``. Patterns may use `*` as a wildcard; matching is @@ -50,3 +62,33 @@ function toRegex(pattern: string): RegExp { const withWild = escaped.replace(/\*/g, ".*"); return new RegExp(`^${withWild}$`, "i"); } + +/** + * Replace any string value (at any depth) that matches one of the + * given regexes with ``. Key names are not inspected; only + * the content of string values is tested. + * + * Combine with `redactDeep` to get both key-name and value-content + * redaction: `redactValues(redactDeep(msg, keyPatterns), valuePatterns)`. + */ +export function redactValues(value: unknown, patterns: RegExp[]): unknown { + if (patterns.length === 0) return value; + return walkValues(value, patterns); +} + +function walkValues(v: unknown, patterns: RegExp[]): unknown { + if (v === null || v === undefined) return v; + if (Array.isArray(v)) return v.map((x) => walkValues(x, patterns)); + if (typeof v === "object") { + const obj = v as Record; + const out: Record = {}; + for (const [k, val] of Object.entries(obj)) { + out[k] = walkValues(val, patterns); + } + return out; + } + if (typeof v === "string" && patterns.some((r) => r.test(v))) { + return ""; + } + return v; +} diff --git a/test/redact.test.ts b/test/redact.test.ts index 411c5c0..6cb74c8 100644 --- a/test/redact.test.ts +++ b/test/redact.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { redactDeep, DEFAULT_REDACT_PATTERNS } from "../src/redact.js"; +import { redactDeep, DEFAULT_REDACT_PATTERNS, redactValues, DEFAULT_REDACT_VALUE_PATTERNS } from "../src/redact.js"; describe("redactDeep", () => { it("redacts top-level keys", () => { @@ -99,3 +99,73 @@ describe("DEFAULT_REDACT_PATTERNS", () => { ).toEqual({ method: "tools/call", q: "hello" }); }); }); + +const TEST_JWT = + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; +const TEST_PEM = + "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASC\n-----END PRIVATE KEY-----"; + +describe("redactValues", () => { + it("redacts a JWT-shaped string value", () => { + expect(redactValues({ bearer: TEST_JWT }, DEFAULT_REDACT_VALUE_PATTERNS)).toEqual({ + bearer: "", + }); + }); + + it("redacts a PEM block value", () => { + expect(redactValues({ cert: TEST_PEM }, DEFAULT_REDACT_VALUE_PATTERNS)).toEqual({ + cert: "", + }); + }); + + it("does not redact ordinary strings", () => { + expect(redactValues({ q: "hello world", n: 42 }, DEFAULT_REDACT_VALUE_PATTERNS)).toEqual({ + q: "hello world", + n: 42, + }); + }); + + it("redacts nested JWT values regardless of key name", () => { + expect( + redactValues({ params: { data: TEST_JWT } }, DEFAULT_REDACT_VALUE_PATTERNS), + ).toEqual({ params: { data: "" } }); + }); + + it("redacts JWT values inside arrays", () => { + expect( + redactValues([{ t: TEST_JWT }, { t: "plain" }], DEFAULT_REDACT_VALUE_PATTERNS), + ).toEqual([{ t: "" }, { t: "plain" }]); + }); + + it("does nothing with empty patterns", () => { + expect(redactValues({ a: TEST_JWT }, [])).toEqual({ a: TEST_JWT }); + }); + + it("does not redact a near-JWT (only two segments)", () => { + expect( + redactValues({ t: "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0" }, DEFAULT_REDACT_VALUE_PATTERNS), + ).toEqual({ t: "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0" }); + }); + + it("redacts both JWT and PEM values in the same object", () => { + expect( + redactValues({ jwt: TEST_JWT, pem: TEST_PEM, safe: "ok" }, DEFAULT_REDACT_VALUE_PATTERNS), + ).toEqual({ jwt: "", pem: "", safe: "ok" }); + }); +}); + +describe("DEFAULT_REDACT_VALUE_PATTERNS", () => { + it("matches a JWT string", () => { + expect(DEFAULT_REDACT_VALUE_PATTERNS.some((r) => r.test(TEST_JWT))).toBe(true); + }); + + it("matches a PEM block", () => { + expect(DEFAULT_REDACT_VALUE_PATTERNS.some((r) => r.test(TEST_PEM))).toBe(true); + }); + + it("does not match a plain bearer token prefix without JWT structure", () => { + expect( + DEFAULT_REDACT_VALUE_PATTERNS.every((r) => !r.test("Bearer ghp_abc123")), + ).toBe(true); + }); +});