fix(adapter-utils): consume an even escape run whole and a truncated trailing segment

The even-run escape pair now requires a character that is neither a quote
nor a backslash after the run, so a run of any even length is consumed by
the odd alternative as pairs plus an escaped backslash and the following
quote opens a segment of the same word. The continuation may end with one
unterminated quoted segment that runs to the end of the line, so a log cut
inside the last segment of a header word still redacts it.

Claude-Session: https://claude.ai/code/session_01RYigf3eMFJjey9iKRApPGE
This commit is contained in:
Michel Tomas 2026-09-06 00:13:16 +02:00
parent bf30a05e64
commit 808854d9af
No known key found for this signature in database
GPG Key ID: 0878846631FFD1E0
2 changed files with 48 additions and 14 deletions

View File

@ -296,14 +296,37 @@ describe("redactCommandText header secrets", () => {
});
it("reads an even backslash run before a quote as a bare quote", () => {
// `\\"` is an escaped backslash followed by a real quote, not an escaped
// `\\\\"` is an escaped backslash followed by a real quote, not an escaped
// quote, so the escaped branches decline it and the value still redacts.
const input = String.raw`foo\\"X-API-Key: abc" bar`;
const output = redactCommandText(input);
expect(output).not.toContain("abc");
expect(output).toBe(
String.raw`foo\\"X-API-Key: ` + REDACTED_COMMAND_TEXT_VALUE + '" bar',
);
expect(output).toBe(String.raw`foo\\"X-API-Key: ` + REDACTED_COMMAND_TEXT_VALUE);
});
it("consumes an even backslash run of any length before a segment quote", () => {
for (const run of ["\\\\", "\\\\\\\\", "\\\\\\\\\\\\"]) {
const input = `curl -H X-API-Key:SECRET${run}"TAILMARK"MORE ;echo safe`;
const output = redactCommandText(input);
expect(output).not.toContain("SECRET");
expect(output).not.toContain("TAILMARK");
expect(output).not.toContain("MORE");
expect(output).toBe(`curl -H X-API-Key:${REDACTED_COMMAND_TEXT_VALUE} ;echo safe`);
expect(redactCommandText(output)).toBe(output);
}
});
it("redacts a truncated quoted tail after a closed escaped value", () => {
for (const tail of ["'TAILMARK", '"TAILMARK', "$'TAILMARK"]) {
let text = `curl -H X-API-Key:\\"SECRET\\"${tail}`;
for (let depth = 0; depth <= 2; depth += 1) {
if (depth > 0) text = JSON.stringify(text);
const output = redactCommandText(text);
expect(output).not.toContain("SECRET");
expect(output).not.toContain("TAILMARK");
expect(redactCommandText(output)).toBe(output);
}
}
});
it("consumes a suffix segment adjacent to a serialized quoted header argument", () => {
@ -540,13 +563,14 @@ describe("redactCommandText header secrets", () => {
);
});
it("keeps an unterminated quote out of the value", () => {
// A lone quote does not open a segment, so the word ends before it.
const input = String.raw`X-API-Key: abc"tail`;
const output = redactCommandText(input);
expect(output).not.toContain("abc");
expect(output).toBe(
`X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE}"tail`,
it("redacts an unterminated quoted tail as a truncated segment", () => {
// A quote with no closer on the line is read as a segment of the same
// word cut by the log, so its text is redacted rather than kept.
expect(redactCommandText('X-API-Key: abc"tail')).toBe(
`X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE}`,
);
expect(redactCommandText('curl -H "X-API-Key: abc" "other')).toBe(
`curl -H "X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE}" "other`,
);
});

View File

@ -135,13 +135,13 @@ const COMMAND_SHELL_QUOTED_SEGMENT_PATTERNS = [
String.raw`'[^'\r\n]*'`,
String.raw`\$'(?:\\.|[^'\\\r\n])*'`,
] as const;
const COMMAND_SHELL_ESCAPE_PAIR_PATTERN = String.raw`(?:(?:\\\\)+[^"\r\n]|(?:\\\\)*\\[^\r\n])`;
const COMMAND_SHELL_ESCAPE_PAIR_PATTERN = String.raw`(?:(?:\\\\)+[^"\\\r\n]|(?:\\\\)*\\[^\r\n])`;
// An opening escape pair carries the first byte of an unquoted value, as in
// `X-API-Key:\ abc`. The backslash run may be longer inside a serialized
// command, where each layer doubles it. A run followed by a quote is excluded,
// so a `\"` opener at any depth falls to the escaped branches, which precede
// the unquoted one in the alternation.
const COMMAND_SHELL_OPENING_ESCAPE_PAIR_PATTERN = String.raw`(?:(?:\\\\)+[^"\r\n]|(?:\\\\)*\\[^"\r\n])`;
const COMMAND_SHELL_OPENING_ESCAPE_PAIR_PATTERN = String.raw`(?:(?:\\\\)+[^"\\\r\n]|(?:\\\\)*\\[^"\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
@ -158,7 +158,17 @@ const COMMAND_SHELL_SEGMENT_PATTERN = `(?:${[
COMMAND_SHELL_ESCAPE_PAIR_PATTERN,
COMMAND_SHELL_PLAIN_SEGMENT_PATTERN,
].join("|")})`;
const COMMAND_SECRET_HEADER_CONTINUATION_PATTERN = `${COMMAND_SHELL_SEGMENT_PATTERN}*`;
// A run log can cut a line inside the last segment of the word. A quoted
// segment with no closer on the line is still part of the value, so the
// continuation may end with one unterminated quoted segment that runs to the
// end of the line.
const COMMAND_SHELL_TRUNCATED_SEGMENT_PATTERN =
String.raw`(?:"(?:\.|[^"\
])*|\$'(?:\.|[^'\
])*|'[^'
]*)(?=[
]|$)`;
const COMMAND_SECRET_HEADER_CONTINUATION_PATTERN = `${COMMAND_SHELL_SEGMENT_PATTERN}*(?:${COMMAND_SHELL_TRUNCATED_SEGMENT_PATTERN})?`;
// A value quoted after the colon keeps its own delimiters around the
// placeholder, so a second pass reads the same shape and leaves it alone. The
// opener and the closer are captured; the body is not.