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
This commit is contained in:
Michel Tomas 2026-09-05 17:50:05 +02:00
parent 2c58c2c297
commit 03b3e4b3ff
No known key found for this signature in database
GPG Key ID: 0878846631FFD1E0
2 changed files with 61 additions and 10 deletions

View File

@ -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.

View File

@ -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 <value>` 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})`;