diff --git a/packages/adapter-utils/src/command-redaction.test.ts b/packages/adapter-utils/src/command-redaction.test.ts index 7ddb24c823..b3f8f3467b 100644 --- a/packages/adapter-utils/src/command-redaction.test.ts +++ b/packages/adapter-utils/src/command-redaction.test.ts @@ -319,6 +319,39 @@ describe("redactCommandText header secrets", () => { ); }); + it("redacts an entire serialized digest credential", () => { + // The header argument is escaped inside a JSON string, so its quotes read as + // `\"` and its own embedded quotes as `\\\"`. The value must still run to the + // end of the argument. + const input = String.raw`{"command":"curl -H \"Authorization: Digest username=\\\"alice\\\", response=\\\"deadbeef\\\"\" https://x"}`; + const output = redactCommandText(input); + expect(output).not.toContain("alice"); + expect(output).not.toContain("deadbeef"); + expect(output).toBe( + String.raw`{"command":"curl -H \"Authorization: Digest ` + + REDACTED_COMMAND_TEXT_VALUE + + String.raw`\" https://x"}`, + ); + }); + + it("redacts past an embedded escaped quote in a serialized header value", () => { + const input = String.raw`{"command":"curl -H \"X-API-Key: abc\\\"def\" https://x"}`; + const output = redactCommandText(input); + expect(output).not.toContain("def"); + expect(output).toBe( + String.raw`{"command":"curl -H \"X-API-Key: ` + + REDACTED_COMMAND_TEXT_VALUE + + String.raw`\" https://x"}`, + ); + }); + + it("is idempotent over a serialized multi-part credential", () => { + const input = String.raw`{"command":"curl -H \"Authorization: Digest username=\\\"alice\\\", response=\\\"deadbeef\\\"\" https://x"}`; + const once = redactCommandText(input); + expect(redactCommandText(once)).toBe(once); + expect(redactDiagnosticText(once)).toBe(once); + }); + it("redacts a header secret inside a diagnostic and keeps a JSON secret field working", () => { const input = 'command failed: curl -H "X-API-Key: abc" -> {"token":"opaque-value"}'; const output = redactDiagnosticText(input); diff --git a/packages/adapter-utils/src/command-redaction.ts b/packages/adapter-utils/src/command-redaction.ts index 0a2a573c6b..acd4a199bb 100644 --- a/packages/adapter-utils/src/command-redaction.ts +++ b/packages/adapter-utils/src/command-redaction.ts @@ -48,15 +48,19 @@ const COMMAND_AUTHORIZATION_BEARER_RE = // Each branch treats the backslash the way its quoting context does. A // double-quoted value consumes escape pairs, so an escaped quote inside the // argument (`"X-API-Key: abc\"def"`) does not end the value early, and neither -// does a backslash-newline line continuation. Its opening -// quote must itself be unescaped, which keeps the branch off a serialized -// diagnostic such as `\"X-API-Key: ...\"`, where the closing `\"` must survive. -// A single-quoted value takes a backslash literally, because a shell single -// quote has no escapes. Only the unquoted branch stops at a backslash, so an -// escaped-quote opener such as `Authorization: \"Bearer ...\"` is left to the -// caller's own authorization rules. A quoted value must open with a non-blank -// character, so an empty header argument such as `-H "X-API-Key: "` stays as it -// is. +// does a backslash-newline line continuation. Its opening quote must itself be +// unescaped. A serialized command writes that same argument with escaped +// quotes, so a fourth branch mirrors the double-quoted one over `\"` +// 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 +// backslash literally, because a shell single quote has no escapes. Only the +// unquoted branch stops at a backslash, so an escaped-quote opener that does +// not follow a header name, such as `Authorization: \"Bearer ...\"`, is left to +// the caller's own authorization rules. A quoted value must open with a +// non-blank character, so an empty header argument such as `-H "X-API-Key: "` +// stays as it is. // // The schemes come from the IANA HTTP Authentication Scheme Registry, plus // `AWS4-HMAC-SHA256` and `Token`, which are widely used but unregistered. A @@ -95,9 +99,18 @@ const COMMAND_SECRET_HEADER_UNQUOTED_VALUE_PATTERN = String.raw`(?:${COMMAND_SECRET_HEADER_PARAM_PATTERN}(?:[ \t]*,[ \t]*${COMMAND_SECRET_HEADER_PARAM_PATTERN})*|[^\s\\"'` + "`" + String.raw`]+)`; +// 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 +// ordinary escape such as `\n`. A bare `\"` is not a unit, so it closes the +// argument. +const COMMAND_SECRET_HEADER_JSON_ESCAPE_PATTERN = String.raw`\\\\\\.|\\\\[^\\]|\\[^"\\]`; const COMMAND_SECRET_HEADER_RE = new RegExp( String.raw`(? { const prefix = - doubleQuotedPrefix ?? singleQuotedPrefix ?? unquotedPrefix ?? ""; - const closingQuote = doubleQuoteClose ?? singleQuoteClose ?? ""; + doubleQuotedPrefix ?? + singleQuotedPrefix ?? + escapedQuotedPrefix ?? + unquotedPrefix ?? + ""; + const closingQuote = + doubleQuoteClose ?? singleQuoteClose ?? escapedQuoteClose ?? ""; return `${prefix}${redactedValue}${closingQuote}`; }, )