fix(adapter-utils): bound header redaction by quoting context

The header rule stopped at the first whitespace or quote, so a multi-part
credential such as a Digest or AWS SigV4 authorization value lost only its
first token. It also matched any bare word carrying a credential hint, so
prose and paths like `auth: failed` or `/v1/tokens:list` were redacted.

The value is now bounded by its context: to the closing quote inside a
quoted shell argument, and to the end of a comma-separated `key=value` list
or a single token when unquoted. A header name must be hyphenated or
underscored, or be the bare `authorization` or `apikey`; the
`www-authenticate` and `proxy-authenticate` challenge headers are excluded.
The recognized scheme list follows the IANA registry plus
`AWS4-HMAC-SHA256` and `Token`.

Claude-Session: https://claude.ai/code/session_01RYigf3eMFJjey9iKRApPGE
This commit is contained in:
Michel Tomas 2026-09-05 14:27:46 +02:00
parent e8aaf25d2c
commit 3399fc784f
No known key found for this signature in database
GPG Key ID: 0878846631FFD1E0
2 changed files with 184 additions and 20 deletions

View File

@ -178,6 +178,104 @@ describe("redactCommandText header secrets", () => {
expect(redactDiagnosticText(once)).toBe(once);
});
it("redacts an entire quoted digest credential, not just its first parameter", () => {
const input =
`curl -H 'Authorization: Digest username="alice", realm="r", nonce="n", uri="/x", response="deadbeef"' https://example.test`;
const output = redactCommandText(input);
expect(output).not.toContain("alice");
expect(output).not.toContain("deadbeef");
expect(output).not.toContain("nonce");
expect(output).toBe(
`curl -H 'Authorization: Digest ${REDACTED_COMMAND_TEXT_VALUE}' https://example.test`,
);
});
it("redacts an unquoted digest credential and stops at the next field", () => {
// A log line carries the header without shell quoting. The parameter list
// ends at the last comma-joined `key=value`, so the trailing status field
// survives.
const input =
'Authorization: Digest username="alice", nonce="n", response="deadbeef" status=401';
const output = redactCommandText(input);
expect(output).not.toContain("alice");
expect(output).not.toContain("deadbeef");
expect(output).toBe(
`Authorization: Digest ${REDACTED_COMMAND_TEXT_VALUE} status=401`,
);
});
it("redacts an entire quoted sigv4 credential, not just the scheme name", () => {
const input =
'curl -H "Authorization: AWS4-HMAC-SHA256 Credential=AKIAEXAMPLE/20260903/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=abc123"';
const output = redactCommandText(input);
expect(output).not.toContain("AKIAEXAMPLE");
expect(output).not.toContain("abc123");
expect(output).toBe(
`curl -H "Authorization: AWS4-HMAC-SHA256 ${REDACTED_COMMAND_TEXT_VALUE}"`,
);
});
it("redacts an unquoted sigv4 credential and stops at the next word", () => {
const input =
"Authorization: AWS4-HMAC-SHA256 Credential=AKIAEXAMPLE/20260903/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=abc123 retry";
const output = redactCommandText(input);
expect(output).not.toContain("AKIAEXAMPLE");
expect(output).not.toContain("abc123");
expect(output).toBe(
`Authorization: AWS4-HMAC-SHA256 ${REDACTED_COMMAND_TEXT_VALUE} retry`,
);
});
it("keeps an already redacted unquoted header bounded", () => {
// The server redaction feeds this shape in after its own rules. The trailing
// word must survive.
const input = `prefix Authorization: ${REDACTED_COMMAND_TEXT_VALUE} suffix`;
expect(redactCommandText(input)).toBe(input);
});
it("keeps hint words that are not header names untouched", () => {
expect(redactCommandText("GET /v1/tokens:list")).toBe("GET /v1/tokens:list");
expect(redactCommandText("auth: failed")).toBe("auth: failed");
});
it("keeps a www-authenticate challenge untouched", () => {
// The challenge parameters are diagnostics, not credentials.
const input =
'WWW-Authenticate: Bearer realm="paperclip", error="invalid_token"';
expect(redactCommandText(input)).toBe(input);
});
it("keeps an empty quoted header argument untouched", () => {
// A quoted value must open with a non-blank character, so there is nothing
// to hide here and the argument stays byte for byte.
const input = 'curl -H "X-API-Key: " -H "X-Auth-Token:" https://example.test';
expect(redactCommandText(input)).toBe(input);
});
it("redacts a bare apikey header value", () => {
// Supabase sends the key under an unhyphenated `apikey` header.
expect(redactCommandText("apikey: abc")).toBe(
`apikey: ${REDACTED_COMMAND_TEXT_VALUE}`,
);
});
it("redacts a proxy-authorization header value", () => {
const input = 'curl -H "Proxy-Authorization: Basic dXNlcjpwdw=="';
const output = redactCommandText(input);
expect(output).not.toContain("dXNlcjpwdw==");
expect(output).toBe(
`curl -H "Proxy-Authorization: Basic ${REDACTED_COMMAND_TEXT_VALUE}"`,
);
});
it("is idempotent over a multi-part credential", () => {
const input =
`curl -H 'Authorization: Digest username="alice", response="deadbeef"' https://example.test`;
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);

View File

@ -18,29 +18,80 @@ const COMMAND_ENV_SECRET_ASSIGNMENT_RE = new RegExp(
);
const COMMAND_AUTHORIZATION_BEARER_RE =
/(\bAuthorization\s*:\s*Bearer\s+)[^\s"'`]+/gi;
// 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`, or `X-Paperclip-Api-Key`. The command redaction
// handled `Authorization: Bearer <value>` only, so a `curl -H "X-API-Key:
// <value>"` command kept the credential in clear. This rule redacts the value
// of any header whose name contains an api-key, token, secret, or auth hint.
// 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
// redacts the value of such a header wherever it appears in command text.
//
// The rule keeps an optional auth scheme in the output. The scheme is not a
// secret, and it tells a reader which credential form the command used. This
// also makes the rule agree byte for byte with the bearer rule above, so
// A header name is either a hyphenated or underscored word carrying an api-key,
// token, secret, or auth hint, or one of the bare names `authorization` and
// `apikey`. Requiring the hinted form to open with an alphanumeric run and then
// a `-` or `_` keeps the rule off prose and paths that merely contain a hint
// word, such as `GET /v1/tokens:list` or `auth: failed`, and keeps a match from
// opening at the hyphen inside a longer name. `www-authenticate` and
// `proxy-authenticate` are excluded: they are response headers whose
// `error="invalid_token"` parameters are diagnostics worth keeping.
//
// The value is bounded by its context, so a multi-part credential stays covered
// end to end. Inside a double- or single-quoted shell argument the value runs to
// the closing quote. Unquoted, the value is either a comma-separated `key=value`
// list, the shape a `Digest` or `AWS4-HMAC-SHA256` credential takes, or a single
// whitespace-delimited token. A continuation parameter must itself carry an `=`,
// so a bare word after the last parameter (`... response="r" status=401`)
// survives.
//
// An optional auth scheme stays in the output. The scheme is not a secret, and
// it tells a reader which credential form the command used. This also makes the
// rule agree byte for byte with the bearer rule above, so
// `Authorization: Bearer <value>` produces the same output as before.
//
// The header name and the colon match on one line only, and the value ends at
// the first quote, backslash, or whitespace. The rule therefore stops at the
// end of one header argument and never runs past it. Excluding the backslash
// also keeps the rule off an escaped-quote opener such as
// `Authorization: \"Bearer ...\"` in a serialized diagnostic, which the
// caller's own authorization rules already redact.
const COMMAND_SECRET_HEADER_NAME_PATTERN = String.raw`[A-Za-z0-9_-]*(?:api[-_]?key|token|secret|auth)[A-Za-z0-9_-]*`;
// Every value branch excludes the backslash. That keeps the rule off an
// escaped-quote opener such as `Authorization: \"Bearer ...\"` in a serialized
// diagnostic, which the caller's own authorization rules already redact. 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
// longer alternative precedes a shorter one that shares its prefix.
const COMMAND_AUTH_SCHEMES = [
"AWS4-HMAC-SHA256",
"Basic",
"Bearer",
"Digest",
"DPoP",
"GNAP",
"Hawk",
"HOBA",
"Mutual",
"Negotiate",
"OAuth",
"PrivateToken",
"SCRAM-SHA-256",
"SCRAM-SHA-1",
"Token",
"vapid",
] as const;
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_PREFIX_PATTERN =
COMMAND_SECRET_HEADER_NAME_PATTERN +
String.raw`[ \t]*:[ \t]*(?:(?:${COMMAND_AUTH_SCHEMES.join("|")})[ \t]+)?`;
const COMMAND_SECRET_HEADER_PARAM_PATTERN =
String.raw`[^\s"'` +
"`" +
String.raw`\\,=]+=(?:"[^"\\\r\n]*"|'[^'\\\r\n]*'|[^\s"'` +
"`" +
String.raw`\\,]*)`;
const COMMAND_SECRET_HEADER_UNQUOTED_VALUE_PATTERN =
String.raw`(?:${COMMAND_SECRET_HEADER_PARAM_PATTERN}(?:[ \t]*,[ \t]*${COMMAND_SECRET_HEADER_PARAM_PATTERN})*|[^\s\\"'` +
"`" +
String.raw`]+)`;
const COMMAND_SECRET_HEADER_RE = new RegExp(
String.raw`(\b${COMMAND_SECRET_HEADER_NAME_PATTERN}[ \t]*:[ \t]*(?:(?:Bearer|Basic|Digest|Token)[ \t]+)?)[^\s\\"'` +
"`" +
String.raw`]+`,
String.raw`("${COMMAND_SECRET_HEADER_PREFIX_PATTERN})[^\s"\\][^"\\\r\n]*(")` +
String.raw`|('${COMMAND_SECRET_HEADER_PREFIX_PATTERN})[^\s'\\][^'\\\r\n]*(')` +
String.raw`|(\b${COMMAND_SECRET_HEADER_PREFIX_PATTERN})${COMMAND_SECRET_HEADER_UNQUOTED_VALUE_PATTERN}`,
"gi",
);
const COMMAND_OPENAI_KEY_RE = /\bsk-[A-Za-z0-9_-]{12,}\b/g;
@ -83,7 +134,22 @@ export function redactCommandText(
if (!maybeContainsSecretText(command)) return command;
return command
.replace(COMMAND_AUTHORIZATION_BEARER_RE, `$1${redactedValue}`)
.replace(COMMAND_SECRET_HEADER_RE, `$1${redactedValue}`)
.replace(
COMMAND_SECRET_HEADER_RE,
(
_match,
doubleQuotedPrefix: string | undefined,
doubleQuoteClose: string | undefined,
singleQuotedPrefix: string | undefined,
singleQuoteClose: string | undefined,
unquotedPrefix: string | undefined,
) => {
const prefix =
doubleQuotedPrefix ?? singleQuotedPrefix ?? unquotedPrefix ?? "";
const closingQuote = doubleQuoteClose ?? singleQuoteClose ?? "";
return `${prefix}${redactedValue}${closingQuote}`;
},
)
.replace(COMMAND_CLI_SECRET_OPTION_RE, `$1${redactedValue}$3`)
.replace(
COMMAND_ENV_SECRET_ASSIGNMENT_RE,