diff --git a/packages/adapter-utils/src/command-redaction.test.ts b/packages/adapter-utils/src/command-redaction.test.ts index 7be371ff22..2293c6eaf4 100644 --- a/packages/adapter-utils/src/command-redaction.test.ts +++ b/packages/adapter-utils/src/command-redaction.test.ts @@ -321,10 +321,11 @@ describe("redactCommandText header secrets", () => { } }); - it("keeps a serializer's closing delimiter when the argument is truncated", () => { - // A run log can cut a serialized command inside the header argument. The - // truncated value stops before the enclosing string's own quote, even - // when the cut lands after a backslash, so the string stays well formed. + it("redacts a truncated serialized argument to the end of its line", () => { + // A run log can cut a serialized command inside the header argument. With + // no closer on the line, the value runs to the end of the line: a bare + // quote there may be a further segment of the same shell word, so the rule + // redacts it rather than keeping it as the enclosing string's delimiter. const cuts = [ 'curl -H "X-API-Key: SECRET', 'curl -H X-API-Key:"SECRET', @@ -336,12 +337,32 @@ describe("redactCommandText header secrets", () => { for (const text of [JSON.stringify(cut), JSON.stringify(JSON.stringify(cut))]) { const output = redactCommandText(text); expect(output).not.toContain("SECRET"); - expect(() => JSON.parse(output)).not.toThrow(); expect(redactCommandText(output)).toBe(output); } } }); + it("keeps an even backslash run before a quote out of the escape pair", () => { + // Two backslashes are an escaped backslash; the quote after them opens a + // further segment of the same word, which is consumed with the value. + const input = 'curl -H X-API-Key:SECRET\\\\"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 an escaped-quoted value", () => { + const input = 'curl -H X-API-Key:\\"SECRET"TAILMARK'; + const output = redactCommandText(input); + expect(output).not.toContain("SECRET"); + expect(output).not.toContain("TAILMARK"); + expect(output).toBe(`curl -H X-API-Key:\\"${REDACTED_COMMAND_TEXT_VALUE}`); + expect(redactCommandText(output)).toBe(output); + }); + it("consumes an escaped-space continuation at every serialization depth", () => { // A shell escape pair doubles its backslash with each serialization // layer; the continuation reads the whole run as one pair. diff --git a/packages/adapter-utils/src/command-redaction.ts b/packages/adapter-utils/src/command-redaction.ts index 3a534525e0..9d4d6f6c54 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]`; +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]`; +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 @@ -185,19 +185,14 @@ const commandSecretHeaderEscapedOpener = (run: string) => String.raw`(?(?:\\\\)*\\)"`; const commandSecretHeaderEscapedBody = (run: string) => String.raw`(?:(?!(?")[^\s\r\n])(?:(?!(?")[^\r\n])*`; -// A truncated argument has no closer on its line. Its body then also stops -// before a bare quote, one preceded by an even run of backslashes, which can -// only be an enclosing serializer's delimiter; that delimiter survives and the -// enclosing string stays well formed. The closer itself is unescaped, so -// backtracking never reads an escaped backslash as the closer. -const COMMAND_SECRET_HEADER_BARE_QUOTE_PATTERN = String.raw`(?<=(? - String.raw`(?:(?!(?")(?!${COMMAND_SECRET_HEADER_BARE_QUOTE_PATTERN})[^\s\r\n])` + - String.raw`(?:(?!(?")(?!${COMMAND_SECRET_HEADER_BARE_QUOTE_PATTERN})[^\r\n])*`; +// The closer itself is unescaped, so backtracking never reads an escaped +// backslash as the closer. A truncated argument has no closer on its line and +// the value then runs to the end of the line: a bare quote there may be a +// further segment of the same shell word, so it is redacted rather than kept. const commandSecretHeaderEscapedValue = (run: string, close: string) => `(?:${commandSecretHeaderEscapedBody(run)}(?\\k<${run}>")` + - `|${commandSecretHeaderEscapedTruncatedBody(run)}` + - String.raw`(?=[\r\n]|$|${COMMAND_SECRET_HEADER_BARE_QUOTE_PATTERN}))`; + `|${commandSecretHeaderEscapedBody(run)}` + + String.raw`(?=[\r\n]|$))`; const COMMAND_SECRET_HEADER_RE = new RegExp( String.raw`(?"${COMMAND_SECRET_HEADER_PREFIX_PATTERN})(?:\\.|[^\s"\\])(?:\\\r?\n|\\.|[^"\\\r\n])*\\?(?:(?")|(?=[\r\n]|$))${COMMAND_SECRET_HEADER_CONTINUATION_PATTERN}` + String.raw`|(?'${COMMAND_SECRET_HEADER_PREFIX_PATTERN})[^\s'][^'\r\n]*(?:(?')|(?=[\r\n]|$))${COMMAND_SECRET_HEADER_CONTINUATION_PATTERN}` +