From 0f80131810b48981ffad724eaf6cf4c9b6136491 Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Ducasse Date: Thu, 1 Oct 2026 11:05:07 +0200 Subject: [PATCH] fix pr --- packages/services/src/agent-tools/monitor.ts | 16 ++--- .../__tests__/response-logs-internal.test.ts | 40 +++++++++--- .../src/monitor/response-logs-internal.ts | 61 ++++++++++++------- 3 files changed, 80 insertions(+), 37 deletions(-) diff --git a/packages/services/src/agent-tools/monitor.ts b/packages/services/src/agent-tools/monitor.ts index e1d26fd21..91e24cde1 100644 --- a/packages/services/src/agent-tools/monitor.ts +++ b/packages/services/src/agent-tools/monitor.ts @@ -294,7 +294,7 @@ const ListResponseLogsInputShape = z.object({ .enum(monitorTimeRange) .default("1d") .describe( - "Lookback window: 1d (default), 7d, 14d. Anchored at now. Ignored when `from` is set.", + "Lookback window: 1d (default), 7d, 14d. Ends at `to` (default now). Ignored when `from` is set.", ), from: z.iso .datetime({ offset: true }) @@ -376,12 +376,10 @@ export const listResponseLogsTool: AgentTool< inputSchema: ListResponseLogsInputShape, outputSchema: ListResponseLogsOutput, async run({ ctx, input }) { + const to = input.to ? Date.parse(input.to) : Date.now(); const window = input.from - ? { - from: Date.parse(input.from), - to: input.to ? Date.parse(input.to) : Date.now(), - } - : agentTimeRangeToTimestampWindow(input.timeRange); + ? { from: Date.parse(input.from), to } + : agentTimeRangeToTimestampWindow(input.timeRange, to); const result = await listResponseLogsInfinite({ ctx, input: { @@ -487,12 +485,14 @@ function truncateBody(body: string | null): string | null { return `${body.slice(0, RESPONSE_BODY_MAX_CHARS)}… [truncated, ${body.length} chars total]`; } -function agentTimeRangeToTimestampWindow(value: MonitorTimeRange): { +function agentTimeRangeToTimestampWindow( + value: MonitorTimeRange, + to = Date.now(), +): { from: number; to: number; } { const day = 24 * 60 * 60_000; - const to = Date.now(); const ms = value === "1d" ? day : value === "7d" ? 7 * day : 14 * day; return { from: to - ms, to }; } diff --git a/packages/services/src/monitor/__tests__/response-logs-internal.test.ts b/packages/services/src/monitor/__tests__/response-logs-internal.test.ts index 306d64cc1..d483f1bdf 100644 --- a/packages/services/src/monitor/__tests__/response-logs-internal.test.ts +++ b/packages/services/src/monitor/__tests__/response-logs-internal.test.ts @@ -27,20 +27,44 @@ describe("redactSensitiveBody", () => { items: [{ apiKey: "sk_live_123" }], }), ); - expect(redacted).not.toContain("hunter2"); - expect(redacted).not.toContain("sk_live_123"); - expect(redacted).not.toContain('"abc"'); - expect(redacted).toContain('"name":"ada"'); + expect(redacted).toBe( + JSON.stringify({ + user: { name: "ada", password: "[redacted]" }, + session: "[redacted]", + items: [{ apiKey: "[redacted]" }], + }), + ); }); test("redacts tokens in non-JSON bodies", () => { const redacted = redactSensitiveBody( "error: Bearer abc.def-ghi rejected; token=s3cr3t&page=2 jwt eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.sig", ); - expect(redacted).not.toContain("abc.def-ghi"); - expect(redacted).not.toContain("s3cr3t"); - expect(redacted).not.toContain("eyJhbGciOiJIUzI1NiJ9"); - expect(redacted).toContain("page=2"); + expect(redacted).toBe( + "error: Bearer [redacted] rejected; token=[redacted]&page=2 jwt [redacted]", + ); + }); + + test("redacts whole quoted values in non-JSON bodies", () => { + const redacted = redactSensitiveBody( + `{"api_key": "sk live 123", password='correct horse battery' (truncated`, + ); + expect(redacted).toBe( + `{"api_key": "[redacted]", password='[redacted]' (truncated`, + ); + }); + + test("only matches sensitive words, not substrings", () => { + expect( + redactSensitiveBody( + "monkey=1&turkey=2&obsession=3&author=ada&accessToken=abc&x-auth-token=def", + ), + ).toBe( + "monkey=1&turkey=2&obsession=3&author=ada&accessToken=[redacted]&x-auth-token=[redacted]", + ); + expect( + redactSensitiveBody(JSON.stringify({ monkey: "1", client_secret: "s" })), + ).toBe(JSON.stringify({ monkey: "1", client_secret: "[redacted]" })); }); test("passes empty bodies through", () => { diff --git a/packages/services/src/monitor/response-logs-internal.ts b/packages/services/src/monitor/response-logs-internal.ts index 8d5e0b116..a664c4873 100644 --- a/packages/services/src/monitor/response-logs-internal.ts +++ b/packages/services/src/monitor/response-logs-internal.ts @@ -9,22 +9,34 @@ const SENSITIVE_HEADER_NAMES = new Set([ "x-auth-token", ]); -const SENSITIVE_HEADER_PARTS = [ - "auth", - "cookie", - "credential", - "key", - "secret", - "session", - "token", -]; +// Matched against whole words of a name split on `-`, `_` and camelCase, so +// `api_key`, `accessToken` and `x-auth-token` match but `monkey` does not. +const SENSITIVE_WORD = + /^(?:auth(?:orization|entication|n|z)?|cookies?|credentials?|keys?|secrets?|sessions?|tokens?|passwords?|passwd)$/; -function isSensitiveHeader(name: string) { - const normalized = name.toLowerCase(); - return ( - SENSITIVE_HEADER_NAMES.has(normalized) || - SENSITIVE_HEADER_PARTS.some((part) => normalized.includes(part)) - ); +// Common sensitive names written as a single lowercase word. +const SENSITIVE_COMPOUND_WORDS = new Set([ + "apikey", + "accesskey", + "secretkey", + "privatekey", + "accesstoken", + "refreshtoken", + "idtoken", + "authtoken", + "clientsecret", + "sessionid", +]); + +function isSensitiveName(name: string) { + if (SENSITIVE_HEADER_NAMES.has(name.toLowerCase())) return true; + return name + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .toLowerCase() + .split(/[^a-z0-9]+/) + .some( + (word) => SENSITIVE_WORD.test(word) || SENSITIVE_COMPOUND_WORDS.has(word), + ); } export function redactSensitiveHeaders( @@ -34,7 +46,7 @@ export function redactSensitiveHeaders( return Object.fromEntries( Object.entries(headers).map(([name, value]) => [ name, - isSensitiveHeader(name) ? REDACTED : value, + isSensitiveName(name) ? REDACTED : value, ]), ); } @@ -43,8 +55,10 @@ export function redactSensitiveHeaders( const BEARER_PATTERN = /\bBearer\s+[\w.~+/=-]+/gi; const JWT_PATTERN = /\beyJ[\w-]+\.[\w-]+\.[\w-]+/g; // `token=…`, `"api_key": "…"`, `secret: …` in form-encoded or plain text bodies. +// The pattern only pre-filters candidate keys; `isSensitiveName` decides. +// Quoted values are consumed whole so secrets containing spaces don't leak. const KEY_VALUE_PATTERN = - /\b([\w-]*(?:auth|cookie|credential|key|secret|session|token|password)[\w-]*)(["']?\s*[:=]\s*["']?)[^\s"'&,;}]+/gi; + /\b([\w-]*(?:auth|cookie|credential|key|secret|session|token|passw)[\w-]*)(["']?\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s"'&,;}]+)/gi; function redactJsonValue(value: unknown): unknown { if (Array.isArray(value)) return value.map(redactJsonValue); @@ -52,9 +66,7 @@ function redactJsonValue(value: unknown): unknown { return Object.fromEntries( Object.entries(value).map(([key, inner]) => [ key, - isSensitiveHeader(key) || key.toLowerCase().includes("password") - ? REDACTED - : redactJsonValue(inner), + isSensitiveName(key) ? REDACTED : redactJsonValue(inner), ]), ); } @@ -66,7 +78,14 @@ function redactText(text: string): string { return text .replace(BEARER_PATTERN, `Bearer ${REDACTED}`) .replace(JWT_PATTERN, REDACTED) - .replace(KEY_VALUE_PATTERN, `$1$2${REDACTED}`); + .replace( + KEY_VALUE_PATTERN, + (match, key: string, sep: string, value: string) => { + if (!isSensitiveName(key)) return match; + const quote = value[0] === '"' || value[0] === "'" ? value[0] : ""; + return `${key}${sep}${quote}${REDACTED}${quote}`; + }, + ); } /** -- 2.51.2