fix(adapter-utils): own an escaped-quoted header value and keep its delimiters

A value written with escaped quotes after the colon, the form an outer
shell uses to pass quote syntax to `sh -c`, had no owner: the unquoted
branch declines an escaped-quote opener so the server's own authorization
rule keeps its shape. A dedicated branch now redacts that value and keeps
the escaped quotes, so both rules agree on the same text. The unquoted
branch also keeps a value's own delimiters when the value is quoted after
the colon, which makes the rule idempotent across the log-writer and UI
passes. The replace callback selects prefix, opener, and closer from the
defined capture groups.

Claude-Session: https://claude.ai/code/session_01RYigf3eMFJjey9iKRApPGE
This commit is contained in:
Michel Tomas 2026-09-05 18:18:02 +02:00
parent 03b3e4b3ff
commit 5959803e5c
No known key found for this signature in database
GPG Key ID: 0878846631FFD1E0
2 changed files with 100 additions and 44 deletions

View File

@ -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`,

View File

@ -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`|(?<!\\)(\\"${COMMAND_SECRET_HEADER_PREFIX_PATTERN})` +
String.raw`(?:${COMMAND_SECRET_HEADER_JSON_ESCAPE_PATTERN}|[^\s"\\])` +
String.raw`(?:${COMMAND_SECRET_HEADER_JSON_ESCAPE_PATTERN}|[^"\\\r\n])*(\\")` +
String.raw`|(?<![A-Za-z0-9_-])(\b${COMMAND_SECRET_HEADER_NAME_PATTERN}${COMMAND_SECRET_HEADER_COLON_PATTERN}\\"${COMMAND_SECRET_HEADER_SCHEME_PATTERN})` +
String.raw`(?:${COMMAND_SECRET_HEADER_JSON_ESCAPE_PATTERN}|[^\s"\\])` +
String.raw`(?:${COMMAND_SECRET_HEADER_JSON_ESCAPE_PATTERN}|[^"\\\r\n])*(?:(\\")|(?=[\r\n]|$))${COMMAND_SECRET_HEADER_CONTINUATION_PATTERN}` +
String.raw`|(?<![A-Za-z0-9_-])(?<!(?<!\\)["'])(\b${COMMAND_SECRET_HEADER_PREFIX_PATTERN})${COMMAND_SECRET_HEADER_UNQUOTED_VALUE_PATTERN}${COMMAND_SECRET_HEADER_CONTINUATION_PATTERN}`,
"gi",
);
@ -207,37 +228,18 @@ export function redactCommandText(
if (!maybeContainsSecretText(command)) return command;
return command
.replace(COMMAND_AUTHORIZATION_BEARER_RE, `$1${redactedValue}`)
.replace(
COMMAND_SECRET_HEADER_RE,
(
_match,
doubleQuotedPrefix: string | undefined,
doubleQuoteClose: string | undefined,
singleQuotedPrefix: string | undefined,
singleQuoteClose: string | undefined,
ansiCQuotedPrefix: string | undefined,
ansiCQuoteClose: string | undefined,
serializedPrefix: string | undefined,
serializedClose: string | undefined,
unquotedPrefix: string | undefined,
) => {
// 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,