diff --git a/packages/adapter-utils/src/command-redaction.test.ts b/packages/adapter-utils/src/command-redaction.test.ts index 2293c6eaf4..6dbb6f8b53 100644 --- a/packages/adapter-utils/src/command-redaction.test.ts +++ b/packages/adapter-utils/src/command-redaction.test.ts @@ -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`, ); }); diff --git a/packages/adapter-utils/src/command-redaction.ts b/packages/adapter-utils/src/command-redaction.ts index 9d4d6f6c54..ccba563c93 100644 --- a/packages/adapter-utils/src/command-redaction.ts +++ b/packages/adapter-utils/src/command-redaction.ts @@ -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.