fix(adapter-utils): let the header scanner own the bearer header

The bearer-only rule stopped its value at a quote but not at a backslash, so
inside a serialized command it consumed the backslash of the escaped closing
quote and the stored JSON string no longer parsed (upstream #11037). The
header scanner already covers Authorization with any scheme: it reads the
serialization layer off the delimiter, keeps the scheme word, and redacts the
whole header word, so a value carrying a backslash or an escaped quote is
redacted whole instead of cut. The bearer rule is removed; its byte-for-byte
pin stays, and four tests pin serialized commands at depths 1 to 3, a JSON
object carrying the command, a backslash inside the value, and a raw escaped
quote.

Claude-Session: https://claude.ai/code/session_01RYigf3eMFJjey9iKRApPGE
This commit is contained in:
Michel Tomas 2026-09-06 20:06:14 +02:00
parent 6610e72825
commit 1aae093b51
No known key found for this signature in database
GPG Key ID: 0878846631FFD1E0
2 changed files with 61 additions and 10 deletions

View File

@ -95,6 +95,55 @@ second-line\" status=401`;
});
});
describe("redactCommandText bearer headers", () => {
const command = 'curl -H "Authorization: Bearer abc" https://example.test';
const expected = `curl -H "Authorization: Bearer ${REDACTED_COMMAND_TEXT_VALUE}" https://example.test`;
it("keeps a serialized command parseable after redacting its bearer header", () => {
// The former bearer-only rule consumed the backslash of the escaped
// closing quote, and the enclosing JSON string stopped parsing.
let serialized = command;
for (let depth = 1; depth <= 3; depth += 1) {
serialized = JSON.stringify(serialized);
const output = redactCommandText(serialized);
expect(output).not.toContain("abc");
expect(redactCommandText(output)).toBe(output);
let decoded: string = output;
for (let layer = 0; layer < depth; layer += 1) {
decoded = JSON.parse(decoded);
}
expect(decoded).toBe(expected);
}
});
it("keeps a serialized JSON object carrying a bearer command parseable", () => {
const input = JSON.stringify({ command, cwd: "/tmp" });
const output = redactCommandText(input);
expect(output).not.toContain("abc");
expect(JSON.parse(output)).toEqual({ command: expected, cwd: "/tmp" });
});
it("redacts a bearer value that carries a backslash whole", () => {
const input = String.raw`curl -H "Authorization: Bearer abc\tail" https://example.test`;
const output = redactCommandText(input);
expect(output).not.toContain("abc");
expect(output).not.toContain("tail");
expect(output).toBe(expected);
});
it("redacts a raw bearer value that carries an escaped quote whole", () => {
// `abc\"def` is one shell word. Read as a serialized opener with no closer,
// the escaped quote runs the value to the end of the line, so the union
// takes the following argument too. Over-redaction, never a leak.
const input = String.raw`curl -H Authorization: Bearer abc\"def https://example.test`;
const output = redactCommandText(input);
expect(output).not.toContain("abc");
expect(output).not.toContain("def");
expect(output).toBe(`curl -H Authorization: Bearer ${REDACTED_COMMAND_TEXT_VALUE}`);
expect(redactCommandText(output)).toBe(output);
});
});
describe("redactCommandText header secrets", () => {
it("redacts a double-quoted X-API-Key header value", () => {
const input = 'curl -H "X-API-Key: abc" https://example.test/api/agents/me';
@ -135,8 +184,8 @@ describe("redactCommandText header secrets", () => {
});
it("keeps the bearer header output byte for byte identical", () => {
// The bearer rule already redacted this shape. The header rule keeps the
// scheme, so the output must not change.
// The former bearer-only rule redacted this shape. The header rule keeps
// the scheme, so the output must not change.
const input = 'curl -H "Authorization: Bearer abc" https://example.test';
expect(redactCommandText(input)).toBe(
`curl -H "Authorization: Bearer ${REDACTED_COMMAND_TEXT_VALUE}" https://example.test`,

View File

@ -16,8 +16,13 @@ const COMMAND_ENV_SECRET_ASSIGNMENT_RE = new RegExp(
String.raw`]+))`,
"gi",
);
const COMMAND_AUTHORIZATION_BEARER_RE =
/(\bAuthorization\s*:\s*Bearer\s+)[^\s"'`]+/gi;
// `Authorization: Bearer <value>` is owned by the header rule below. It used to
// have a rule of its own whose value class stopped at a quote but not at a
// backslash, so inside a serialized command it consumed the backslash of the
// escaped closing quote and the stored JSON string no longer parsed. The header
// rule reads the serialization layer off the delimiter instead, keeps the
// scheme word, and redacts the whole header word, so a value that itself
// carries a backslash or an escaped quote is redacted whole rather than cut.
// A secret-bearing header names a credential in its own header name. The public
// Paperclip API documents `X-API-Key`, and a run log can also carry `Api-Key`,
// `X-Auth-Token`, `X-Paperclip-Api-Key`, or a bare `Authorization`. This rule
@ -71,8 +76,8 @@ const COMMAND_SECRET_HEADER_CANDIDATE_RE = new RegExp(
// A scheme may also follow the value's own escaped-quote delimiter, as in
// `Authorization:\"Digest username=...\"`. An optional auth scheme stays in the
// output: it is not a secret, and it tells a reader which credential form the
// command used. This also makes the rule agree with the bearer rule above for a
// well-formed bearer header.
// command used, and it keeps the output of a well-formed bearer header
// byte-identical to what the former bearer-only rule produced.
const COMMAND_SECRET_HEADER_SCHEME_AT_RE = new RegExp(
String.raw`(?:${COMMAND_AUTH_SCHEMES.join("|")})[ \t]+`,
"iy",
@ -842,10 +847,7 @@ export function redactCommandText(
redactedValue = REDACTED_COMMAND_TEXT_VALUE,
): string {
if (!maybeContainsSecretText(command)) return command;
return redactCommandSecretHeaders(
command.replace(COMMAND_AUTHORIZATION_BEARER_RE, `$1${redactedValue}`),
redactedValue,
)
return redactCommandSecretHeaders(command, redactedValue)
.replace(COMMAND_CLI_SECRET_OPTION_RE, `$1${redactedValue}$3`)
.replace(
COMMAND_ENV_SECRET_ASSIGNMENT_RE,