From 3399fc784f2b846a8ca606ede3c3a6dd8f3a79e5 Mon Sep 17 00:00:00 2001 From: Michel Tomas Date: Sat, 5 Sep 2026 14:27:46 +0200 Subject: [PATCH] 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 --- .../src/command-redaction.test.ts | 98 ++++++++++++++++ .../adapter-utils/src/command-redaction.ts | 106 ++++++++++++++---- 2 files changed, 184 insertions(+), 20 deletions(-) diff --git a/packages/adapter-utils/src/command-redaction.test.ts b/packages/adapter-utils/src/command-redaction.test.ts index d384e3c325..9caa538c50 100644 --- a/packages/adapter-utils/src/command-redaction.test.ts +++ b/packages/adapter-utils/src/command-redaction.test.ts @@ -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); diff --git a/packages/adapter-utils/src/command-redaction.ts b/packages/adapter-utils/src/command-redaction.ts index 2d00e17f36..b761716b32 100644 --- a/packages/adapter-utils/src/command-redaction.ts +++ b/packages/adapter-utils/src/command-redaction.ts @@ -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 ` only, so a `curl -H "X-API-Key: -// "` 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 ` 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,