From 1aae093b51c9ace27298634166edac0995e9e8a3 Mon Sep 17 00:00:00 2001 From: Michel Tomas Date: Sun, 6 Sep 2026 20:06:14 +0200 Subject: [PATCH] fix(adapter-utils): let the header scanner own the bearer header The bearer-only rule stopped its value at a quote but not at a backslash, so inside a serialized command it consumed the backslash of the escaped closing quote and the stored JSON string no longer parsed (upstream #11037). The header scanner already covers Authorization with any scheme: it reads the serialization layer off the delimiter, keeps the scheme word, and redacts the whole header word, so a value carrying a backslash or an escaped quote is redacted whole instead of cut. The bearer rule is removed; its byte-for-byte pin stays, and four tests pin serialized commands at depths 1 to 3, a JSON object carrying the command, a backslash inside the value, and a raw escaped quote. Claude-Session: https://claude.ai/code/session_01RYigf3eMFJjey9iKRApPGE --- .../src/command-redaction.test.ts | 53 ++++++++++++++++++- .../adapter-utils/src/command-redaction.ts | 18 ++++--- 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/packages/adapter-utils/src/command-redaction.test.ts b/packages/adapter-utils/src/command-redaction.test.ts index ca2273ecc9..763344256c 100644 --- a/packages/adapter-utils/src/command-redaction.test.ts +++ b/packages/adapter-utils/src/command-redaction.test.ts @@ -95,6 +95,55 @@ second-line\" status=401`; }); }); +describe("redactCommandText bearer headers", () => { + const command = 'curl -H "Authorization: Bearer abc" https://example.test'; + const expected = `curl -H "Authorization: Bearer ${REDACTED_COMMAND_TEXT_VALUE}" https://example.test`; + + it("keeps a serialized command parseable after redacting its bearer header", () => { + // The former bearer-only rule consumed the backslash of the escaped + // closing quote, and the enclosing JSON string stopped parsing. + let serialized = command; + for (let depth = 1; depth <= 3; depth += 1) { + serialized = JSON.stringify(serialized); + const output = redactCommandText(serialized); + expect(output).not.toContain("abc"); + expect(redactCommandText(output)).toBe(output); + let decoded: string = output; + for (let layer = 0; layer < depth; layer += 1) { + decoded = JSON.parse(decoded); + } + expect(decoded).toBe(expected); + } + }); + + it("keeps a serialized JSON object carrying a bearer command parseable", () => { + const input = JSON.stringify({ command, cwd: "/tmp" }); + const output = redactCommandText(input); + expect(output).not.toContain("abc"); + expect(JSON.parse(output)).toEqual({ command: expected, cwd: "/tmp" }); + }); + + it("redacts a bearer value that carries a backslash whole", () => { + const input = String.raw`curl -H "Authorization: Bearer abc\tail" https://example.test`; + const output = redactCommandText(input); + expect(output).not.toContain("abc"); + expect(output).not.toContain("tail"); + expect(output).toBe(expected); + }); + + it("redacts a raw bearer value that carries an escaped quote whole", () => { + // `abc\"def` is one shell word. Read as a serialized opener with no closer, + // the escaped quote runs the value to the end of the line, so the union + // takes the following argument too. Over-redaction, never a leak. + const input = String.raw`curl -H Authorization: Bearer abc\"def https://example.test`; + const output = redactCommandText(input); + expect(output).not.toContain("abc"); + expect(output).not.toContain("def"); + expect(output).toBe(`curl -H Authorization: Bearer ${REDACTED_COMMAND_TEXT_VALUE}`); + expect(redactCommandText(output)).toBe(output); + }); +}); + describe("redactCommandText header secrets", () => { it("redacts a double-quoted X-API-Key header value", () => { const input = 'curl -H "X-API-Key: abc" https://example.test/api/agents/me'; @@ -135,8 +184,8 @@ describe("redactCommandText header secrets", () => { }); it("keeps the bearer header output byte for byte identical", () => { - // The bearer rule already redacted this shape. The header rule keeps the - // scheme, so the output must not change. + // The former bearer-only rule redacted this shape. The header rule keeps + // the scheme, so the output must not change. const input = 'curl -H "Authorization: Bearer abc" https://example.test'; expect(redactCommandText(input)).toBe( `curl -H "Authorization: Bearer ${REDACTED_COMMAND_TEXT_VALUE}" https://example.test`, diff --git a/packages/adapter-utils/src/command-redaction.ts b/packages/adapter-utils/src/command-redaction.ts index 2201e071ef..b071068b41 100644 --- a/packages/adapter-utils/src/command-redaction.ts +++ b/packages/adapter-utils/src/command-redaction.ts @@ -16,8 +16,13 @@ const COMMAND_ENV_SECRET_ASSIGNMENT_RE = new RegExp( String.raw`]+))`, "gi", ); -const COMMAND_AUTHORIZATION_BEARER_RE = - /(\bAuthorization\s*:\s*Bearer\s+)[^\s"'`]+/gi; +// `Authorization: Bearer ` is owned by the header rule below. It used to +// have a rule of its own whose value class stopped at a quote but not at a +// backslash, so inside a serialized command it consumed the backslash of the +// escaped closing quote and the stored JSON string no longer parsed. The header +// rule reads the serialization layer off the delimiter instead, keeps the +// scheme word, and redacts the whole header word, so a value that itself +// carries a backslash or an escaped quote is redacted whole rather than cut. // A secret-bearing header names a credential in its own header name. The public // Paperclip API documents `X-API-Key`, and a run log can also carry `Api-Key`, // `X-Auth-Token`, `X-Paperclip-Api-Key`, or a bare `Authorization`. This rule @@ -71,8 +76,8 @@ const COMMAND_SECRET_HEADER_CANDIDATE_RE = new RegExp( // A scheme may also follow the value's own escaped-quote delimiter, as in // `Authorization:\"Digest username=...\"`. An optional auth scheme stays in the // output: it is not a secret, and it tells a reader which credential form the -// command used. This also makes the rule agree with the bearer rule above for a -// well-formed bearer header. +// command used, and it keeps the output of a well-formed bearer header +// byte-identical to what the former bearer-only rule produced. const COMMAND_SECRET_HEADER_SCHEME_AT_RE = new RegExp( String.raw`(?:${COMMAND_AUTH_SCHEMES.join("|")})[ \t]+`, "iy", @@ -842,10 +847,7 @@ export function redactCommandText( redactedValue = REDACTED_COMMAND_TEXT_VALUE, ): string { if (!maybeContainsSecretText(command)) return command; - return redactCommandSecretHeaders( - command.replace(COMMAND_AUTHORIZATION_BEARER_RE, `$1${redactedValue}`), - redactedValue, - ) + return redactCommandSecretHeaders(command, redactedValue) .replace(COMMAND_CLI_SECRET_OPTION_RE, `$1${redactedValue}$3`) .replace( COMMAND_ENV_SECRET_ASSIGNMENT_RE,