Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/record.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
42 changes: 42 additions & 0 deletions src/redact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<REDACTED>` 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_-]+$/,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Redact Bearer-prefixed JWT values

The new JWT value pattern only matches strings that are exactly header.payload.signature, so common values like "Bearer <jwt>" are not redacted. This leaves a real secret-leak path whenever a bearer token appears under a non-sensitive key name (so key-based redaction does not trigger), which is exactly the case this value-based pass is meant to cover.

Useful? React with 👍 / 👎.

/-----BEGIN [A-Z0-9 ]+-----/,
];

/**
* Replace values whose keys match any of the given patterns with
* `<REDACTED>`. Patterns may use `*` as a wildcard; matching is
Expand Down Expand Up @@ -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 `<REDACTED>`. 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<string, unknown>;
const out: Record<string, unknown> = {};
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 "<REDACTED>";
}
return v;
}
72 changes: 71 additions & 1 deletion test/redact.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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: "<REDACTED>",
});
});

it("redacts a PEM block value", () => {
expect(redactValues({ cert: TEST_PEM }, DEFAULT_REDACT_VALUE_PATTERNS)).toEqual({
cert: "<REDACTED>",
});
});

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: "<REDACTED>" } });
});

it("redacts JWT values inside arrays", () => {
expect(
redactValues([{ t: TEST_JWT }, { t: "plain" }], DEFAULT_REDACT_VALUE_PATTERNS),
).toEqual([{ t: "<REDACTED>" }, { 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: "<REDACTED>", pem: "<REDACTED>", 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);
});
});