From 03b3e4b3ff5760ca14c760dc07b68f201d9740fc Mon Sep 17 00:00:00 2001 From: Michel Tomas Date: Sat, 5 Sep 2026 17:50:05 +0200 Subject: [PATCH] fix(adapter-utils): read the first unquoted header segment as a raw token The first segment of an unquoted header value may open on an escape pair, so a value whose first byte is an escaped space is consumed, while an escaped-quote opener still falls to the caller's own rules. That first segment is bounded by whitespace only: a raw HTTP diagnostic carries an opaque credential the same way, so a shell metacharacter inside it is a credential byte. Only a continuation segment after a closing quote stops at a metacharacter, which keeps a following separator or command intact. Claude-Session: https://claude.ai/code/session_01RYigf3eMFJjey9iKRApPGE --- .../src/command-redaction.test.ts | 40 +++++++++++++++++++ .../adapter-utils/src/command-redaction.ts | 31 +++++++++----- 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 6f216652d3..ff1ddf3ffa 100644 --- a/packages/adapter-utils/src/command-redaction.test.ts +++ b/packages/adapter-utils/src/command-redaction.test.ts @@ -460,6 +460,46 @@ describe("redactCommandText header secrets", () => { ); }); + it("redacts a value that opens with an escape pair", () => { + // `X-API-Key:\ abc123` is one shell word whose first value byte is escaped. + const input = String.raw`curl -H X-API-Key:\ abc123 https://example.test`; + const output = redactCommandText(input); + expect(output).not.toContain("abc123"); + expect(output).toBe( + `curl -H X-API-Key:${REDACTED_COMMAND_TEXT_VALUE} https://example.test`, + ); + }); + + it("redacts a raw header value that contains a shell metacharacter", () => { + // A raw HTTP diagnostic carries an opaque credential, so `;` inside the + // value is a credential byte and the whole token goes. + const input = "tool: X-API-Key: abc;def status=401"; + const output = redactCommandText(input); + expect(output).not.toContain("def"); + expect(output).toBe( + `tool: X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE} status=401`, + ); + expect(redactDiagnosticText(input)).toBe( + `tool: X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE} status=401`, + ); + }); + + it("takes the whole raw token when a command shares that shape", () => { + // The same bytes read as a shell command would end the word at `;`. The + // raw-token reading wins, which over-redacts here and never under-redacts. + expect(redactCommandText("X-API-Key:abc;echo done")).toBe( + `X-API-Key:${REDACTED_COMMAND_TEXT_VALUE} done`, + ); + }); + + it("stops a continuation segment at a shell metacharacter", () => { + // After a closing quote the word really does end at `;`, so the next + // command survives. + expect(redactCommandText(`curl -H "X-API-Key: abc"123;echo done`)).toBe( + `curl -H "X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE}";echo done`, + ); + }); + it("keeps a shell separator after a quoted header argument", () => { // A metacharacter ends the shell word, so the pipeline and the next command // survive the redaction. diff --git a/packages/adapter-utils/src/command-redaction.ts b/packages/adapter-utils/src/command-redaction.ts index 7061639c89..d66262b5dd 100644 --- a/packages/adapter-utils/src/command-redaction.ts +++ b/packages/adapter-utils/src/command-redaction.ts @@ -40,9 +40,11 @@ const COMMAND_AUTHORIZATION_BEARER_RE = // shell metacharacter end the word, so the following argument survives. A // quoted segment that opens the value may also end at a line break or at the // end of the input, because a truncated run log writes an argument whose -// closing quote never arrives. The first segment of an unquoted value never -// opens on a backslash, which leaves an escaped-quote opener such as -// `Authorization: \"Bearer ...\"` to the caller's own rules. The unquoted +// closing quote never arrives. The first segment of an unquoted value is a raw +// token: it may open on an escape pair but never on an escaped quote, which +// leaves an opener such as `Authorization: \"Bearer ...\"` to the caller's own +// rules, and a metacharacter inside it is a credential byte rather than a +// separator. Only a continuation segment stops at one. The unquoted // branch also declines a name preceded by another name character or by an // unescaped quote: such a name sits inside a longer name or inside a quoted // argument that the quoted branches already own. @@ -56,7 +58,7 @@ const COMMAND_AUTHORIZATION_BEARER_RE = // // An optional auth scheme stays in the output. The scheme is not a secret, and // it tells a reader which credential form the command used. This also makes the -// rule agree byte for byte with the bearer rule above, so +// rule agree with the bearer rule above for a well-formed bearer header, so // `Authorization: Bearer ` produces the same output as before. // // Each branch treats the backslash the way its quoting context does. A @@ -122,9 +124,19 @@ const COMMAND_SHELL_QUOTED_SEGMENT_PATTERNS = [ String.raw`\$'(?:\\.|[^'\\\r\n])*'`, ] as const; const COMMAND_SHELL_ESCAPE_PAIR_PATTERN = String.raw`\\[^\r\n]`; -// A plain run stops at a shell metacharacter as well as at whitespace: `;`, -// `|`, `&`, `<`, `>`, and the parentheses end the word, so a redaction never -// swallows a separator, a redirection, or the next command. +// An opening escape pair carries the first byte of an unquoted value, as in +// `X-API-Key:\ abc`. It excludes the escaped quote, so a serialized `\"` +// opener stays with the caller's own rules. +const COMMAND_SHELL_OPENING_ESCAPE_PAIR_PATTERN = String.raw`\\[^"\r\n]`; +// The first segment of an unquoted value is a raw token, bounded only by +// whitespace, a quote, a backtick, or a backslash. A raw HTTP diagnostic +// carries an opaque credential the same way, so a `;`, `|`, or `&` inside it +// is a credential byte rather than a command separator. +const COMMAND_SHELL_RAW_TOKEN_PATTERN = + String.raw`[^\s"'` + "`" + String.raw`\\]+`; +// A continuation segment follows a closing quote inside one shell word, where +// a metacharacter does end the word. Stopping there keeps a redaction from +// swallowing a separator, a redirection, or the next command. const COMMAND_SHELL_PLAIN_SEGMENT_PATTERN = String.raw`[^\s"'` + "`" + String.raw`\\;|&<>()]+`; const COMMAND_SHELL_SEGMENT_PATTERN = `(?:${[ @@ -132,11 +144,10 @@ const COMMAND_SHELL_SEGMENT_PATTERN = `(?:${[ COMMAND_SHELL_ESCAPE_PAIR_PATTERN, COMMAND_SHELL_PLAIN_SEGMENT_PATTERN, ].join("|")})`; -// The first segment of an unquoted value never opens on a backslash, so an -// escaped-quote opener stays with the caller's own rules. const COMMAND_SHELL_FIRST_SEGMENT_PATTERN = `(?:${[ ...COMMAND_SHELL_QUOTED_SEGMENT_PATTERNS, - COMMAND_SHELL_PLAIN_SEGMENT_PATTERN, + COMMAND_SHELL_OPENING_ESCAPE_PAIR_PATTERN, + COMMAND_SHELL_RAW_TOKEN_PATTERN, ].join("|")})`; const COMMAND_SECRET_HEADER_CONTINUATION_PATTERN = `${COMMAND_SHELL_SEGMENT_PATTERN}*`; const COMMAND_SECRET_HEADER_UNQUOTED_VALUE_PATTERN = `(?:${COMMAND_SECRET_HEADER_PARAM_LIST_PATTERN}|${COMMAND_SHELL_FIRST_SEGMENT_PATTERN})`;