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:
parent
03b3e4b3ff
commit
5959803e5c
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue