diff --git a/packages/adapter-utils/src/command-redaction.test.ts b/packages/adapter-utils/src/command-redaction.test.ts index 1f80f44eb2..b58d37aad8 100644 --- a/packages/adapter-utils/src/command-redaction.test.ts +++ b/packages/adapter-utils/src/command-redaction.test.ts @@ -306,6 +306,42 @@ describe("redactCommandText header secrets", () => { ); }); + it("consumes a suffix segment adjacent to a serialized quoted header argument", () => { + // The suffix is part of the same shell word as the header, so it is part + // of the credential at every serialization depth. + let text = 'curl -H "X-API-Key: SECRET"TAILMARK;echo safe'; + for (let depth = 1; depth <= 3; depth += 1) { + text = JSON.stringify(text); + const output = redactCommandText(text); + expect(output).not.toContain("SECRET"); + expect(output).not.toContain("TAILMARK"); + expect(output).toContain(";echo safe"); + expect(() => JSON.parse(output)).not.toThrow(); + expect(redactCommandText(output)).toBe(output); + } + }); + + 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. + const cuts = [ + 'curl -H "X-API-Key: SECRET', + 'curl -H X-API-Key:"SECRET', + 'curl -H "X-API-Key: SECRET\\', + 'curl -H "X-API-Key: SECRET\\\\', + 'curl -H X-API-Key:"SECRET\\', + ]; + for (const cut of cuts) { + 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("redacts a bare apikey header value", () => { // Supabase sends the key under an unhyphenated `apikey` header. expect(redactCommandText("apikey: abc")).toBe( diff --git a/packages/adapter-utils/src/command-redaction.ts b/packages/adapter-utils/src/command-redaction.ts index abf7ab022e..0223179264 100644 --- a/packages/adapter-utils/src/command-redaction.ts +++ b/packages/adapter-utils/src/command-redaction.ts @@ -185,16 +185,27 @@ const commandSecretHeaderEscapedOpener = (run: string) => String.raw`(?(?:\\\\)*\\)"`; const commandSecretHeaderEscapedBody = (run: string) => String.raw`(?:(?!(?")[^\s\r\n])(?:(?!(?")[^\r\n])*`; -const commandSecretHeaderEscapedCloser = (run: string, close: string) => - String.raw`(?:(?<${close}>\k<${run}>")|(?=[\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])*`; +const commandSecretHeaderEscapedValue = (run: string, close: string) => + `(?:${commandSecretHeaderEscapedBody(run)}(?\\k<${run}>")` + + `|${commandSecretHeaderEscapedTruncatedBody(run)}` + + String.raw`(?=[\r\n]|$|${COMMAND_SECRET_HEADER_BARE_QUOTE_PATTERN}))`; 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}` + String.raw`|(?\$'${COMMAND_SECRET_HEADER_PREFIX_PATTERN})(?:\\.|[^\s'\\])(?:\\.|[^'\\\r\n])*\\?(?:(?')|(?=[\r\n]|$))${COMMAND_SECRET_HEADER_CONTINUATION_PATTERN}` + `|(?${commandSecretHeaderEscapedOpener("serRun")}${COMMAND_SECRET_HEADER_PREFIX_PATTERN})` + - `${commandSecretHeaderEscapedBody("serRun")}${commandSecretHeaderEscapedCloser("serRun", "serClose")}` + + `${commandSecretHeaderEscapedValue("serRun", "serClose")}${COMMAND_SECRET_HEADER_CONTINUATION_PATTERN}` + String.raw`|(?\b${COMMAND_SECRET_HEADER_NAME_PATTERN}${COMMAND_SECRET_HEADER_COLON_PATTERN}${COMMAND_SECRET_HEADER_SCHEME_PATTERN}${commandSecretHeaderEscapedOpener("evRun")}${COMMAND_SECRET_HEADER_SCHEME_PATTERN})` + - `${commandSecretHeaderEscapedBody("evRun")}${commandSecretHeaderEscapedCloser("evRun", "evClose")}${COMMAND_SECRET_HEADER_CONTINUATION_PATTERN}` + + `${commandSecretHeaderEscapedValue("evRun", "evClose")}${COMMAND_SECRET_HEADER_CONTINUATION_PATTERN}` + String.raw`|(?\b${COMMAND_SECRET_HEADER_PREFIX_PATTERN})${COMMAND_SECRET_HEADER_UNQUOTED_VALUE_PATTERN}${COMMAND_SECRET_HEADER_CONTINUATION_PATTERN}`, "gi", );