diff --git a/packages/adapter-utils/src/command-redaction.test.ts b/packages/adapter-utils/src/command-redaction.test.ts index ff1ddf3ffa..649714f2fa 100644 --- a/packages/adapter-utils/src/command-redaction.test.ts +++ b/packages/adapter-utils/src/command-redaction.test.ts @@ -152,12 +152,45 @@ describe("redactCommandText header secrets", () => { ); }); - it("does not start a match at an escaped quote after the colon", () => { - // A serialized diagnostic writes a quoted header value as `\"`. The value - // pattern excludes the backslash, so the rule leaves this shape to the - // caller's own authorization rules instead of redacting the escape itself. + it("redacts an escaped-quoted value and keeps its escaped quotes", () => { + // An outer shell writes quote syntax for an inner shell this way, and the + // caller's own authorization rules write the same shape. Keeping the + // escaped quotes makes both agree on the result. const input = String.raw`prefix Authorization: \"Bearer nested\" suffix`; - expect(redactCommandText(input)).toBe(input); + const output = redactCommandText(input); + expect(output).not.toContain("nested"); + expect(output).toBe( + String.raw`prefix Authorization: \"Bearer ` + + REDACTED_COMMAND_TEXT_VALUE + + String.raw`\" suffix`, + ); + // This is exactly what the caller's chain feeds back in, so it must not + // move again. + const settled = + String.raw`prefix Authorization: \"` + + REDACTED_COMMAND_TEXT_VALUE + + String.raw`\" suffix`; + expect(redactCommandText(settled)).toBe(settled); + }); + + it("redacts an escaped-quoted value passed to a nested shell", () => { + const input = String.raw`sh -c "curl -H X-API-Key:\"abc123\" https://example.test"`; + const output = redactCommandText(input); + expect(output).not.toContain("abc123"); + expect(output).toBe( + String.raw`sh -c "curl -H X-API-Key:\"` + + REDACTED_COMMAND_TEXT_VALUE + + String.raw`\" https://example.test"`, + ); + }); + + it("redacts a truncated escaped-quoted value", () => { + const input = String.raw`X-API-Key:\"abc`; + const output = redactCommandText(input); + expect(output).not.toContain("abc"); + expect(output).toBe( + String.raw`X-API-Key:\"` + REDACTED_COMMAND_TEXT_VALUE, + ); }); it("redacts a header secret inside a serialized command string", () => { @@ -354,12 +387,28 @@ describe("redactCommandText header secrets", () => { it("redacts a value whose quotes cover only the value", () => { // `X-API-Key:"abc123"` is one shell word, so the quoted part is the value. + // The value keeps its own delimiters, which makes a second pass a no-op. + const R = REDACTED_COMMAND_TEXT_VALUE; const input = `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`, + expect(output).toBe(`curl -H X-API-Key:"${R}" https://example.test`); + expect(redactCommandText(`curl -H X-API-Key:'abc' https://x`)).toBe( + `curl -H X-API-Key:'${R}' https://x`, ); + expect(redactCommandText(`curl -H X-API-Key:$'abc' https://x`)).toBe( + `curl -H X-API-Key:$'${R}' https://x`, + ); + }); + + it("is stable over a value-only quoted header with a following command", () => { + // The preserved delimiters keep the second pass from reading the + // placeholder as a bare token and eating the separator. + const R = REDACTED_COMMAND_TEXT_VALUE; + const once = redactCommandText(`curl -H X-API-Key:"abc"123;echo done`); + expect(once).toBe(`curl -H X-API-Key:"${R}";echo done`); + expect(redactCommandText(once)).toBe(once); + expect(redactDiagnosticText(once)).toBe(once); }); it("redacts a segment adjacent to a quoted header argument", () => { @@ -524,6 +573,11 @@ describe("redactCommandText header secrets", () => { String.raw`curl -H $'X-API-Key: abc\'123' https://example.test`, ]; const pinnedForms = [ + `curl -H X-API-Key:"abc"123;echo done`, + `curl -H X-API-Key:'abc' https://x`, + `curl -H X-API-Key:$'abc' https://x`, + String.raw`sh -c "curl -H X-API-Key:\"abc123\" https://example.test"`, + String.raw`X-API-Key:\"abc`, `curl -H "Authorization: Bearer abc" https://example.test`, `curl -H "X-API-Key: " -H "X-Auth-Token:" https://example.test`, `prefix Authorization: ${REDACTED_COMMAND_TEXT_VALUE} suffix`, diff --git a/packages/adapter-utils/src/command-redaction.ts b/packages/adapter-utils/src/command-redaction.ts index d66262b5dd..4f285cb6d8 100644 --- a/packages/adapter-utils/src/command-redaction.ts +++ b/packages/adapter-utils/src/command-redaction.ts @@ -70,7 +70,13 @@ const COMMAND_AUTHORIZATION_BEARER_RE = // delimiters: it opens at an unescaped `\"`, consumes the doubled escape // sequences an embedded `\"` or `\\` becomes, and closes at the next bare // `\"`. A multi-part credential in a serialized diagnostic is therefore covered -// end to end, not truncated at its first escape. A single-quoted value takes a +// end to end, not truncated at its first escape. A separate branch owns a value +// whose own quotes are escaped after the colon, the shape an outer shell writes +// to pass quote syntax to an inner one. It keeps those escaped quotes in the +// output, so the caller's own authorization redaction and this rule settle on +// the same text. A value quoted after the colon likewise keeps its own +// delimiters, which leaves the placeholder readable as a quoted value on a +// second pass instead of as a bare token. A single-quoted value takes a // backslash literally, because a shell single quote has no escapes, while an // ANSI-C value has escapes of its own. A quoted value must open with a // non-blank character, so an empty header argument such as `-H "X-API-Key: "` @@ -102,9 +108,13 @@ const COMMAND_AUTH_SCHEMES = [ const COMMAND_SECRET_HEADER_HINT_PATTERN = String.raw`(?:api[-_]?key|token|secret|auth)`; const COMMAND_SECRET_HEADER_NAME_PATTERN = String.raw`(?!(?:www|proxy)-authenticate\b)(?:(?=[A-Za-z0-9]+[-_])[A-Za-z0-9_-]*${COMMAND_SECRET_HEADER_HINT_PATTERN}[A-Za-z0-9_-]*|authorization|apikey)`; +const COMMAND_SECRET_HEADER_COLON_PATTERN = String.raw`[ \t]*:[ \t]*`; +const COMMAND_SECRET_HEADER_SCHEME_PATTERN = + String.raw`(?:(?:${COMMAND_AUTH_SCHEMES.join("|")})[ \t]+)?`; const COMMAND_SECRET_HEADER_PREFIX_PATTERN = COMMAND_SECRET_HEADER_NAME_PATTERN + - String.raw`[ \t]*:[ \t]*(?:(?:${COMMAND_AUTH_SCHEMES.join("|")})[ \t]+)?`; + COMMAND_SECRET_HEADER_COLON_PATTERN + + COMMAND_SECRET_HEADER_SCHEME_PATTERN; const COMMAND_SECRET_HEADER_PARAM_PATTERN = String.raw`[^\s"'` + "`" + @@ -144,13 +154,21 @@ const COMMAND_SHELL_SEGMENT_PATTERN = `(?:${[ COMMAND_SHELL_ESCAPE_PAIR_PATTERN, COMMAND_SHELL_PLAIN_SEGMENT_PATTERN, ].join("|")})`; -const COMMAND_SHELL_FIRST_SEGMENT_PATTERN = `(?:${[ - ...COMMAND_SHELL_QUOTED_SEGMENT_PATTERNS, +const COMMAND_SECRET_HEADER_CONTINUATION_PATTERN = `${COMMAND_SHELL_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. +const COMMAND_SECRET_HEADER_QUOTED_VALUE_PATTERNS = [ + String.raw`(")(?:\\\r?\n|\\.|[^"\\\r\n])*(")`, + String.raw`(')[^'\r\n]*(')`, + String.raw`(\$')(?:\\.|[^'\\\r\n])*(')`, +] as const; +const COMMAND_SECRET_HEADER_UNQUOTED_VALUE_PATTERN = `(?:${[ + COMMAND_SECRET_HEADER_PARAM_LIST_PATTERN, + ...COMMAND_SECRET_HEADER_QUOTED_VALUE_PATTERNS, 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})`; // The escape units a serialized command writes inside an escaped-quoted // argument: an escaped backslash followed by another escape (an embedded // `\"` or `\\`), an escaped backslash followed by a plain character, or an @@ -164,6 +182,9 @@ const COMMAND_SECRET_HEADER_RE = new RegExp( String.raw`|(? { - // Exactly one branch matches, so exactly one prefix is defined. - const prefix = - doubleQuotedPrefix ?? - singleQuotedPrefix ?? - ansiCQuotedPrefix ?? - serializedPrefix ?? - unquotedPrefix ?? - ""; - const closingQuote = - doubleQuoteClose ?? - singleQuoteClose ?? - ansiCQuoteClose ?? - serializedClose ?? - ""; - return `${prefix}${redactedValue}${closingQuote}`; - }, - ) + .replace(COMMAND_SECRET_HEADER_RE, (...matchArgs: unknown[]) => { + // Each branch captures its prefix, then an optional opener for a value + // that keeps its own quotes, then an optional closer. Only one branch + // matches, so the groups it defined read in that order. + const captured = matchArgs + .slice(1, -2) + .filter((group): group is string => typeof group === "string"); + const prefix = captured[0] ?? ""; + const opener = captured.length > 2 ? captured[1] : ""; + const closing = captured.length > 1 ? captured[captured.length - 1] : ""; + return `${prefix}${opener}${redactedValue}${closing}`; + }) .replace(COMMAND_CLI_SECRET_OPTION_RE, `$1${redactedValue}$3`) .replace( COMMAND_ENV_SECRET_ASSIGNMENT_RE,