fix(adapter-utils): redact an escaped-quoted header value at any serialization depth

An escaped-quoted argument carries a backslash run before its quote that
doubles and grows by one with every serialization layer, so a rule that
requires exactly one backslash misses a command serialized twice. Both
escaped-quote branches now capture the odd backslash run of the opener and
close on the same run, with a tempered body that keeps deeper embedded
quotes and a dangling trailing backslash inside the value. A scheme word
may precede the escaped opener as well as follow it. Every capture group
is named and the replace callback reads the groups object.

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

View File

@ -285,6 +285,27 @@ describe("redactCommandText header secrets", () => {
expect(redactCommandText(input)).toBe(input);
});
it("redacts an escaped-quoted value that follows a scheme word", () => {
const input = String.raw`Authorization: Basic \"abc\"defg retry`;
const output = redactCommandText(input);
expect(output).not.toContain("abc");
expect(output).toBe(
String.raw`Authorization: Basic \"` + REDACTED_COMMAND_TEXT_VALUE + String.raw`\" retry`,
);
expect(redactCommandText(output)).toBe(output);
});
it("reads an even backslash run before a quote as a bare quote", () => {
// `\\"` is an escaped backslash followed by a real quote, not an escaped
// quote, so the escaped branches decline it and the value still redacts.
const input = String.raw`foo\\"X-API-Key: abc" bar`;
const output = redactCommandText(input);
expect(output).not.toContain("abc");
expect(output).toBe(
String.raw`foo\\"X-API-Key: ` + REDACTED_COMMAND_TEXT_VALUE + '" bar',
);
});
it("redacts a bare apikey header value", () => {
// Supabase sends the key under an unhyphenated `apikey` header.
expect(redactCommandText("apikey: abc")).toBe(
@ -603,6 +624,97 @@ describe("redactCommandText header secrets", () => {
}
});
it("redacts a serializer-nested value-only escaped-quoted credential", () => {
// `JSON.stringify` writes the inner shell's `\"` delimiter as `\\\"`. The
// value is delimited by the whole backslash run, so the extra layer changes
// nothing about which bytes belong to the credential.
const R = REDACTED_COMMAND_TEXT_VALUE;
const input = JSON.stringify({
command: String.raw`sh -c "curl -H Authorization:\"Digest username=alice, response=SECRETTAIL\" https://example.test"`,
status: "safe",
});
const output = redactCommandText(input);
expect(output).not.toContain("alice");
expect(output).not.toContain("SECRETTAIL");
expect(output).toContain(
String.raw`Authorization:\\\"Digest ` + R + String.raw`\\\"`,
);
const parsed = JSON.parse(output) as { command: string; status: string };
expect(parsed.status).toBe("safe");
expect(parsed.command).toBe(
String.raw`sh -c "curl -H Authorization:\"Digest ` +
R +
String.raw`\" https://example.test"`,
);
expect(redactCommandText(output)).toBe(output);
expect(redactDiagnosticText(output)).toBe(output);
});
it("redacts a serialized escaped-quoted argument one layer deeper", () => {
const R = REDACTED_COMMAND_TEXT_VALUE;
const input = JSON.stringify({
command: String.raw`curl -H \"X-API-Key: abc\" https://example.test`,
status: "safe",
});
const output = redactCommandText(input);
expect(output).not.toContain("abc");
expect(output).toContain(String.raw`\\\"X-API-Key: ` + R + String.raw`\\\"`);
const parsed = JSON.parse(output) as { command: string; status: string };
expect(parsed.status).toBe("safe");
expect(parsed.command).toBe(
String.raw`curl -H \"X-API-Key: ` + R + String.raw`\" https://example.test`,
);
expect(redactCommandText(output)).toBe(output);
expect(redactDiagnosticText(output)).toBe(output);
});
it("redacts an escaped-quoted argument three serialization layers deep", () => {
// Nothing in the rule counts layers, so a run of seven backslashes reads
// exactly like a run of one.
const R = REDACTED_COMMAND_TEXT_VALUE;
const input = JSON.stringify(
JSON.stringify({
command: String.raw`curl -H \"X-API-Key: abc\" https://example.test`,
status: "safe",
}),
);
const output = redactCommandText(input);
expect(output).not.toContain("abc");
const parsed = JSON.parse(JSON.parse(output) as string) as {
command: string;
status: string;
};
expect(parsed.status).toBe("safe");
expect(parsed.command).toBe(
String.raw`curl -H \"X-API-Key: ` + R + String.raw`\" https://example.test`,
);
expect(redactCommandText(output)).toBe(output);
expect(redactDiagnosticText(output)).toBe(output);
});
it("keeps a dangling trailing backslash inside an escaped-quoted value", () => {
// A truncated log can end mid-escape. The backslash does not begin the
// closer, so it belongs to the value.
const R = REDACTED_COMMAND_TEXT_VALUE;
const expected = String.raw`X-API-Key:\"` + R;
for (const tail of ["\\", "\\\\"]) {
const input = String.raw`X-API-Key:\"abc123` + tail;
const output = redactCommandText(input);
expect(output).not.toContain("abc123");
expect(output).toBe(expected);
expect(redactCommandText(output)).toBe(output);
expect(redactDiagnosticText(output)).toBe(output);
}
});
it("keeps an empty escaped-quoted header argument untouched", () => {
// The value must open with a non-blank character, so there is nothing to
// hide here and the argument stays byte for byte.
const input = String.raw`\"X-API-Key: \" https://example.test`;
expect(redactCommandText(input)).toBe(input);
expect(redactDiagnosticText(input)).toBe(input);
});
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);

View File

@ -42,12 +42,12 @@ const COMMAND_AUTHORIZATION_BEARER_RE =
// end of the input, because a truncated run log writes an argument whose
// closing quote never arrives. The first segment of an unquoted value is a raw
// token: it may open on an escape pair but never on an escaped quote, which
// leaves an opener such as `Authorization: \"Bearer ...\"` to the caller's own
// rules, and a metacharacter inside it is a credential byte rather than a
// separator. Only a continuation segment stops at one. The unquoted
// branch also declines a name preceded by another name character or by an
// unescaped quote: such a name sits inside a longer name or inside a quoted
// argument that the quoted branches already own.
// leaves an escaped-quoted argument to the two escaped branches below, and a
// metacharacter inside it is a credential byte rather than a separator. Only a
// continuation segment stops at one. The unquoted branch also declines a name
// preceded by another name character or by an unescaped quote: such a name sits
// inside a longer name or inside a quoted argument that the quoted branches
// already own.
//
// An unquoted value may instead open as a comma-separated `key=value` list, the
// shape a `Digest`, `Concealed`, or `AWS4-HMAC-SHA256` credential takes. A
@ -65,22 +65,24 @@ const COMMAND_AUTHORIZATION_BEARER_RE =
// 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. 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 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: "`
// stays as it is.
// unescaped. 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: "` stays as it is. A value quoted after the
// colon keeps its own delimiters, which leaves the placeholder readable as a
// quoted value on a second pass instead of as a bare token.
//
// An escaped-quoted argument belongs to the two escaped branches, whatever
// serialization depth wrote it: one opens at an escaped quote before the header
// name (`\"X-API-Key: abc\"`), the other at an escaped quote after the colon
// (`X-API-Key:\"abc\"`). Both keep the escaped quotes in the output, so the
// caller's own authorization redaction and this rule settle on the same text,
// which is why the first segment of an unquoted value never opens on one. Depth
// is read from the delimiter rather than counted: the opener is a whole run of
// backslashes followed by a quote, and the value ends at the first repeat of
// that run and quote which is not itself preceded by a further backslash. An
// embedded quote from a deeper layer carries a longer run, so it stays inside
// the value, and a dangling trailing backslash does too.
//
// The scheme list follows the IANA HTTP Authentication Scheme Registry as of
// the RFC 9729 `Concealed` addition, plus `AWS4-HMAC-SHA256`, `Hawk`, and
@ -135,8 +137,10 @@ const COMMAND_SHELL_QUOTED_SEGMENT_PATTERNS = [
] as const;
const COMMAND_SHELL_ESCAPE_PAIR_PATTERN = String.raw`\\[^\r\n]`;
// An opening escape pair carries the first byte of an unquoted value, as in
// `X-API-Key:\ abc`. It excludes the escaped quote, so a serialized `\"`
// opener stays with the caller's own rules.
// `X-API-Key:\ abc`. It excludes the escaped quote, so a `\"` opener falls to
// the escaped branches. A deeper run such as `\\\"` opens with an escaped
// backslash, which this pattern does accept; the escaped branches precede the
// unquoted one in the alternation and take that value first.
const COMMAND_SHELL_OPENING_ESCAPE_PAIR_PATTERN = String.raw`\\[^"\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
@ -159,9 +163,9 @@ const COMMAND_SECRET_HEADER_CONTINUATION_PATTERN = `${COMMAND_SHELL_SEGMENT_PATT
// 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])*(')`,
String.raw`(?<uqDqOpen>")(?:\\\r?\n|\\.|[^"\\\r\n])*(?<uqDqClose>")`,
String.raw`(?<uqSqOpen>')[^'\r\n]*(?<uqSqClose>')`,
String.raw`(?<uqAnsiOpen>\$')(?:\\.|[^'\\\r\n])*(?<uqAnsiClose>')`,
] as const;
const COMMAND_SECRET_HEADER_UNQUOTED_VALUE_PATTERN = `(?:${[
COMMAND_SECRET_HEADER_PARAM_LIST_PATTERN,
@ -169,25 +173,55 @@ const COMMAND_SECRET_HEADER_UNQUOTED_VALUE_PATTERN = `(?:${[
COMMAND_SHELL_OPENING_ESCAPE_PAIR_PATTERN,
COMMAND_SHELL_RAW_TOKEN_PATTERN,
].join("|")})`;
// 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`\\\\\\.|\\\\[^\\]|\\[^"\\]`;
// An escaped-quoted argument delimits its value with a whole run of backslashes
// followed by a quote, however many serialization layers wrote that run. The
// run is odd: each layer doubles the backslashes and adds one, so an even run
// is an escaped backslash before a bare quote, not an escaped quote. The
// opener captures the run so the closer can require the same one; the body
// takes every character on the line that does not begin the closer, which
// leaves a deeper embedded quote and a dangling trailing backslash inside the
// value. The body must still open with a non-blank character.
const commandSecretHeaderEscapedOpener = (run: string) =>
String.raw`(?<!\\)(?<${run}>(?:\\\\)*\\)"`;
const commandSecretHeaderEscapedBody = (run: string) =>
String.raw`(?:(?!(?<!\\)\k<${run}>")[^\s\r\n])(?:(?!(?<!\\)\k<${run}>")[^\r\n])*`;
const commandSecretHeaderEscapedCloser = (run: string, close: string) =>
String.raw`(?:(?<${close}>\k<${run}>")|(?=[\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}` +
String.raw`|(\$'${COMMAND_SECRET_HEADER_PREFIX_PATTERN})(?:\\.|[^\s'\\])(?:\\.|[^'\\\r\n])*\\?(?:(')|(?=[\r\n]|$))${COMMAND_SECRET_HEADER_CONTINUATION_PATTERN}` +
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}`,
String.raw`(?<!\\)(?<dqPrefix>"${COMMAND_SECRET_HEADER_PREFIX_PATTERN})(?:\\.|[^\s"\\])(?:\\\r?\n|\\.|[^"\\\r\n])*\\?(?:(?<dqClose>")|(?=[\r\n]|$))${COMMAND_SECRET_HEADER_CONTINUATION_PATTERN}` +
String.raw`|(?<sqPrefix>'${COMMAND_SECRET_HEADER_PREFIX_PATTERN})[^\s'][^'\r\n]*(?:(?<sqClose>')|(?=[\r\n]|$))${COMMAND_SECRET_HEADER_CONTINUATION_PATTERN}` +
String.raw`|(?<ansiPrefix>\$'${COMMAND_SECRET_HEADER_PREFIX_PATTERN})(?:\\.|[^\s'\\])(?:\\.|[^'\\\r\n])*\\?(?:(?<ansiClose>')|(?=[\r\n]|$))${COMMAND_SECRET_HEADER_CONTINUATION_PATTERN}` +
`|(?<serPrefix>${commandSecretHeaderEscapedOpener("serRun")}${COMMAND_SECRET_HEADER_PREFIX_PATTERN})` +
`${commandSecretHeaderEscapedBody("serRun")}${commandSecretHeaderEscapedCloser("serRun", "serClose")}` +
String.raw`|(?<![A-Za-z0-9_-])(?<evPrefix>\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}` +
String.raw`|(?<![A-Za-z0-9_-])(?<!(?<!\\)["'])(?<uqPrefix>\b${COMMAND_SECRET_HEADER_PREFIX_PATTERN})${COMMAND_SECRET_HEADER_UNQUOTED_VALUE_PATTERN}${COMMAND_SECRET_HEADER_CONTINUATION_PATTERN}`,
"gi",
);
// The groups the callback reads back, in branch order.
const COMMAND_SECRET_HEADER_PREFIX_GROUPS = [
"dqPrefix",
"sqPrefix",
"ansiPrefix",
"serPrefix",
"evPrefix",
"uqPrefix",
] as const;
const COMMAND_SECRET_HEADER_OPENER_GROUPS = [
"uqDqOpen",
"uqSqOpen",
"uqAnsiOpen",
] as const;
const COMMAND_SECRET_HEADER_CLOSER_GROUPS = [
"dqClose",
"sqClose",
"ansiClose",
"serClose",
"evClose",
"uqDqClose",
"uqSqClose",
"uqAnsiClose",
] as const;
const COMMAND_OPENAI_KEY_RE = /\bsk-[A-Za-z0-9_-]{12,}\b/g;
const COMMAND_GITHUB_TOKEN_RE = /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g;
const COMMAND_JWT_RE =
@ -229,15 +263,17 @@ export function redactCommandText(
return command
.replace(COMMAND_AUTHORIZATION_BEARER_RE, `$1${redactedValue}`)
.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] : "";
// One branch matches, so at most one group in each list is defined.
const groups = (matchArgs[matchArgs.length - 1] ?? {}) as Record<
string,
string | undefined
>;
const firstDefined = (names: readonly string[]) =>
names.map((name) => groups[name]).find((value) => value !== undefined) ??
"";
const prefix = firstDefined(COMMAND_SECRET_HEADER_PREFIX_GROUPS);
const opener = firstDefined(COMMAND_SECRET_HEADER_OPENER_GROUPS);
const closing = firstDefined(COMMAND_SECRET_HEADER_CLOSER_GROUPS);
return `${prefix}${opener}${redactedValue}${closing}`;
})
.replace(COMMAND_CLI_SECRET_OPTION_RE, `$1${redactedValue}$3`)