From e8aaf25d2ca095aadb1cb3da3a6c9e42eacf61f5 Mon Sep 17 00:00:00 2001 From: Michel Tomas Date: Thu, 3 Sep 2026 20:14:56 +0200 Subject: [PATCH 01/21] fix(adapter-utils): redact header-style secrets in command text The command redaction covered `Authorization: Bearer `, shell `NAME=value` assignments, and common token shapes. It did not cover a credential passed in any other header. A `curl -H "X-API-Key: "` command therefore kept the token in clear in a run log. A new rule redacts the value of any header whose name contains an api-key, token, secret, or auth hint. The rule keeps an optional auth scheme in the output, so `Authorization: Bearer ` produces the same text as before. `Authorization: Basic ` is now redacted too. The value ends at the first quote, backslash, or whitespace, so the rule stops at the end of one header argument. Claude-Session: https://claude.ai/code/session_01U9PF3d9SASC9tomDRjyeVt --- .../src/command-redaction.test.ts | 93 +++++++++++++++++++ .../adapter-utils/src/command-redaction.ts | 26 ++++++ 2 files changed, 119 insertions(+) diff --git a/packages/adapter-utils/src/command-redaction.test.ts b/packages/adapter-utils/src/command-redaction.test.ts index 68ea18478e..d384e3c325 100644 --- a/packages/adapter-utils/src/command-redaction.test.ts +++ b/packages/adapter-utils/src/command-redaction.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { REDACTED_COMMAND_TEXT_VALUE, + redactCommandText, redactDiagnosticText, } from "./command-redaction.js"; @@ -93,3 +94,95 @@ second-line\" status=401`; expect(output).toContain(REDACTED_COMMAND_TEXT_VALUE); }); }); + +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'; + const output = redactCommandText(input); + expect(output).not.toContain("abc"); + expect(output).toBe( + `curl -H "X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE}" https://example.test/api/agents/me`, + ); + }); + + it("redacts a single-quoted lowercase x-api-key header value", () => { + const input = "curl -H 'x-api-key: abc' https://example.test/api/agents/me"; + const output = redactCommandText(input); + expect(output).not.toContain("abc"); + expect(output).toBe( + `curl -H 'x-api-key: ${REDACTED_COMMAND_TEXT_VALUE}' https://example.test/api/agents/me`, + ); + }); + + it("redacts an unquoted header value and other credential header names", () => { + expect(redactCommandText("curl -H X-API-Key:abc https://example.test")).toBe( + `curl -H X-API-Key:${REDACTED_COMMAND_TEXT_VALUE} https://example.test`, + ); + expect(redactCommandText('curl -H "Api-Key: abc"')).toBe( + `curl -H "Api-Key: ${REDACTED_COMMAND_TEXT_VALUE}"`, + ); + expect(redactCommandText('curl -H "X-Auth-Token: abc"')).toBe( + `curl -H "X-Auth-Token: ${REDACTED_COMMAND_TEXT_VALUE}"`, + ); + expect(redactCommandText('curl -H "X-Paperclip-Api-Key: abc"')).toBe( + `curl -H "X-Paperclip-Api-Key: ${REDACTED_COMMAND_TEXT_VALUE}"`, + ); + }); + + it("keeps a non-secret header untouched", () => { + const input = 'curl -H "Content-Type: application/json" -H "Accept: application/json" https://example.test'; + expect(redactCommandText(input)).toBe(input); + }); + + 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. + 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`, + ); + }); + + it("now redacts a basic authorization header value", () => { + const input = 'curl -H "Authorization: Basic dXNlcjpwdw==" https://example.test'; + const output = redactCommandText(input); + expect(output).not.toContain("dXNlcjpwdw=="); + expect(output).toBe( + `curl -H "Authorization: Basic ${REDACTED_COMMAND_TEXT_VALUE}" https://example.test`, + ); + }); + + 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. + const input = String.raw`prefix Authorization: \"Bearer nested\" suffix`; + expect(redactCommandText(input)).toBe(input); + }); + + it("redacts a header secret inside a serialized command string", () => { + const input = String.raw`{"command":"curl -H \"X-API-Key: abc\" https://example.test"}`; + const output = redactCommandText(input); + expect(output).not.toContain("abc"); + expect(output).toBe( + String.raw`{"command":"curl -H \"X-API-Key: ` + + REDACTED_COMMAND_TEXT_VALUE + + String.raw`\" https://example.test"}`, + ); + }); + + it("is idempotent over a header secret", () => { + const input = 'curl -H "X-API-Key: abc" -H "Authorization: Bearer def"'; + 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); + expect(output).not.toContain("abc"); + expect(output).not.toContain("opaque-value"); + expect(output).toContain("command failed:"); + }); +}); diff --git a/packages/adapter-utils/src/command-redaction.ts b/packages/adapter-utils/src/command-redaction.ts index 5890973961..2d00e17f36 100644 --- a/packages/adapter-utils/src/command-redaction.ts +++ b/packages/adapter-utils/src/command-redaction.ts @@ -18,6 +18,31 @@ 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. +// +// 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 +// `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_-]*`; +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`]+`, + "gi", +); 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 = @@ -58,6 +83,7 @@ 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_CLI_SECRET_OPTION_RE, `$1${redactedValue}$3`) .replace( COMMAND_ENV_SECRET_ASSIGNMENT_RE, From 3399fc784f2b846a8ca606ede3c3a6dd8f3a79e5 Mon Sep 17 00:00:00 2001 From: Michel Tomas Date: Sat, 5 Sep 2026 14:27:46 +0200 Subject: [PATCH 02/21] 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, From d273feaa7f552a58f78a9ee80b63d98577c0c16f Mon Sep 17 00:00:00 2001 From: Michel Tomas Date: Sat, 5 Sep 2026 15:09:03 +0200 Subject: [PATCH 03/21] fix(adapter-utils): consume escape pairs inside a quoted header value A double-quoted header value stopped at the first backslash, so a credential with an embedded escaped quote such as `"X-API-Key: abc\"def"` kept its tail in the recorded text. The double-quoted branch now consumes escape pairs and requires an unescaped opening quote, which keeps it off a serialized diagnostic where `\"` is the JSON escape. The single-quoted branch takes a backslash literally. Only the unquoted branch still stops at a backslash. Claude-Session: https://claude.ai/code/session_01RYigf3eMFJjey9iKRApPGE --- .../src/command-redaction.test.ts | 29 +++++++++++++++++++ .../adapter-utils/src/command-redaction.ts | 20 ++++++++----- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/packages/adapter-utils/src/command-redaction.test.ts b/packages/adapter-utils/src/command-redaction.test.ts index 9caa538c50..b68a65ab8c 100644 --- a/packages/adapter-utils/src/command-redaction.test.ts +++ b/packages/adapter-utils/src/command-redaction.test.ts @@ -276,6 +276,35 @@ describe("redactCommandText header secrets", () => { expect(redactDiagnosticText(once)).toBe(once); }); + it("redacts past an escaped quote inside a double-quoted header value", () => { + // The shell escape does not end the argument, so the value runs on past it. + const input = String.raw`curl -H "X-API-Key: abc\"def" https://example.test`; + const output = redactCommandText(input); + expect(output).not.toContain("def"); + expect(output).toBe( + `curl -H "X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE}" https://example.test`, + ); + }); + + it("redacts a backslash inside a single-quoted header value", () => { + // A shell single quote has no escapes, so the backslash is part of the value. + const input = String.raw`curl -H 'X-API-Key: abc\def' https://example.test`; + const output = redactCommandText(input); + expect(output).not.toContain("abc"); + expect(output).toBe( + `curl -H 'X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE}' https://example.test`, + ); + }); + + it("redacts a double-quoted value that is itself an escaped quoted string", () => { + const input = String.raw`curl -H "Authorization: \"Bearer nested\"" https://example.test`; + const output = redactCommandText(input); + expect(output).not.toContain("nested"); + expect(output).toBe( + `curl -H "Authorization: ${REDACTED_COMMAND_TEXT_VALUE}" https://example.test`, + ); + }); + 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 b761716b32..e401c3b55c 100644 --- a/packages/adapter-utils/src/command-redaction.ts +++ b/packages/adapter-utils/src/command-redaction.ts @@ -45,11 +45,17 @@ const COMMAND_AUTHORIZATION_BEARER_RE = // rule agree byte for byte with the bearer rule above, so // `Authorization: Bearer ` produces the same output as before. // -// 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. +// Each branch treats the backslash the way its quoting context does. A +// double-quoted value consumes escape pairs, so an escaped quote inside the +// argument (`"X-API-Key: abc\"def"`) does not end the value early. Its opening +// quote must itself be unescaped, which keeps the branch off a serialized +// diagnostic such as `\"X-API-Key: ...\"`, where the closing `\"` must survive. +// A single-quoted value takes a backslash literally, because a shell single +// quote has no escapes. Only the unquoted branch stops at a backslash, so an +// escaped-quote opener such as `Authorization: \"Bearer ...\"` is left to the +// caller's own authorization rules. 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 @@ -89,8 +95,8 @@ const COMMAND_SECRET_HEADER_UNQUOTED_VALUE_PATTERN = "`" + String.raw`]+)`; const COMMAND_SECRET_HEADER_RE = new RegExp( - String.raw`("${COMMAND_SECRET_HEADER_PREFIX_PATTERN})[^\s"\\][^"\\\r\n]*(")` + - String.raw`|('${COMMAND_SECRET_HEADER_PREFIX_PATTERN})[^\s'\\][^'\\\r\n]*(')` + + String.raw`(? Date: Sat, 5 Sep 2026 15:41:32 +0200 Subject: [PATCH 04/21] fix(adapter-utils): consume a line continuation inside a quoted header value A backslash-newline continuation inside a double-quoted header argument ended the quoted match, so the unquoted fallback redacted only the part of the credential before the continuation. The double-quoted branch now treats the continuation as part of the value, with LF and CRLF line endings. Claude-Session: https://claude.ai/code/session_01RYigf3eMFJjey9iKRApPGE --- .../adapter-utils/src/command-redaction.test.ts | 14 ++++++++++++++ packages/adapter-utils/src/command-redaction.ts | 5 +++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/adapter-utils/src/command-redaction.test.ts b/packages/adapter-utils/src/command-redaction.test.ts index b68a65ab8c..7ddb24c823 100644 --- a/packages/adapter-utils/src/command-redaction.test.ts +++ b/packages/adapter-utils/src/command-redaction.test.ts @@ -286,6 +286,20 @@ describe("redactCommandText header secrets", () => { ); }); + it("redacts across a backslash-newline continuation inside a double-quoted value", () => { + // A shell line continuation inside double quotes is part of the argument. + const input = 'curl -H "X-API-Key: abc\\\ndef" https://example.test'; + const output = redactCommandText(input); + expect(output).not.toContain("def"); + expect(output).toBe( + `curl -H "X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE}" https://example.test`, + ); + const crlf = 'curl -H "X-API-Key: abc\\\r\ndef" https://example.test'; + expect(redactCommandText(crlf)).toBe( + `curl -H "X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE}" https://example.test`, + ); + }); + it("redacts a backslash inside a single-quoted header value", () => { // A shell single quote has no escapes, so the backslash is part of the value. const input = String.raw`curl -H 'X-API-Key: abc\def' https://example.test`; diff --git a/packages/adapter-utils/src/command-redaction.ts b/packages/adapter-utils/src/command-redaction.ts index e401c3b55c..0a2a573c6b 100644 --- a/packages/adapter-utils/src/command-redaction.ts +++ b/packages/adapter-utils/src/command-redaction.ts @@ -47,7 +47,8 @@ const COMMAND_AUTHORIZATION_BEARER_RE = // // Each branch treats the backslash the way its quoting context does. A // double-quoted value consumes escape pairs, so an escaped quote inside the -// argument (`"X-API-Key: abc\"def"`) does not end the value early. Its opening +// 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, which keeps the branch off a serialized // diagnostic such as `\"X-API-Key: ...\"`, where the closing `\"` must survive. // A single-quoted value takes a backslash literally, because a shell single @@ -95,7 +96,7 @@ const COMMAND_SECRET_HEADER_UNQUOTED_VALUE_PATTERN = "`" + String.raw`]+)`; const COMMAND_SECRET_HEADER_RE = new RegExp( - String.raw`(? Date: Sat, 5 Sep 2026 16:14:59 +0200 Subject: [PATCH 05/21] fix(adapter-utils): redact an escaped-quoted header inside a serialized command A serialized command writes a double-quoted header argument with escaped quotes. The escaped opener fell through to the unquoted branch, which stops at the first backslash, so a multi-part credential such as a Digest value kept its later fields. A fourth branch mirrors the double-quoted one over `\"` delimiters and consumes the doubled escape sequences an embedded quote or backslash becomes. Claude-Session: https://claude.ai/code/session_01RYigf3eMFJjey9iKRApPGE --- .../src/command-redaction.test.ts | 33 +++++++++++++++ .../adapter-utils/src/command-redaction.ts | 42 ++++++++++++++----- 2 files changed, 64 insertions(+), 11 deletions(-) diff --git a/packages/adapter-utils/src/command-redaction.test.ts b/packages/adapter-utils/src/command-redaction.test.ts index 7ddb24c823..b3f8f3467b 100644 --- a/packages/adapter-utils/src/command-redaction.test.ts +++ b/packages/adapter-utils/src/command-redaction.test.ts @@ -319,6 +319,39 @@ describe("redactCommandText header secrets", () => { ); }); + it("redacts an entire serialized digest credential", () => { + // The header argument is escaped inside a JSON string, so its quotes read as + // `\"` and its own embedded quotes as `\\\"`. The value must still run to the + // end of the argument. + const input = String.raw`{"command":"curl -H \"Authorization: Digest username=\\\"alice\\\", response=\\\"deadbeef\\\"\" https://x"}`; + const output = redactCommandText(input); + expect(output).not.toContain("alice"); + expect(output).not.toContain("deadbeef"); + expect(output).toBe( + String.raw`{"command":"curl -H \"Authorization: Digest ` + + REDACTED_COMMAND_TEXT_VALUE + + String.raw`\" https://x"}`, + ); + }); + + it("redacts past an embedded escaped quote in a serialized header value", () => { + const input = String.raw`{"command":"curl -H \"X-API-Key: abc\\\"def\" https://x"}`; + const output = redactCommandText(input); + expect(output).not.toContain("def"); + expect(output).toBe( + String.raw`{"command":"curl -H \"X-API-Key: ` + + REDACTED_COMMAND_TEXT_VALUE + + String.raw`\" https://x"}`, + ); + }); + + it("is idempotent over a serialized multi-part credential", () => { + const input = String.raw`{"command":"curl -H \"Authorization: Digest username=\\\"alice\\\", response=\\\"deadbeef\\\"\" https://x"}`; + 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 0a2a573c6b..acd4a199bb 100644 --- a/packages/adapter-utils/src/command-redaction.ts +++ b/packages/adapter-utils/src/command-redaction.ts @@ -48,15 +48,19 @@ const COMMAND_AUTHORIZATION_BEARER_RE = // Each branch treats the backslash the way its quoting context does. A // 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, which keeps the branch off a serialized -// diagnostic such as `\"X-API-Key: ...\"`, where the closing `\"` must survive. -// A single-quoted value takes a backslash literally, because a shell single -// quote has no escapes. Only the unquoted branch stops at a backslash, so an -// escaped-quote opener such as `Authorization: \"Bearer ...\"` is left to the -// caller's own authorization rules. 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. +// 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 single-quoted value takes a +// backslash literally, because a shell single quote has no escapes. Only the +// unquoted branch stops at a backslash, so an escaped-quote opener that does +// not follow a header name, such as `Authorization: \"Bearer ...\"`, is left to +// the caller's own authorization rules. 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 @@ -95,9 +99,18 @@ const COMMAND_SECRET_HEADER_UNQUOTED_VALUE_PATTERN = String.raw`(?:${COMMAND_SECRET_HEADER_PARAM_PATTERN}(?:[ \t]*,[ \t]*${COMMAND_SECRET_HEADER_PARAM_PATTERN})*|[^\s\\"'` + "`" + String.raw`]+)`; +// 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`\\\\\\.|\\\\[^\\]|\\[^"\\]`; const COMMAND_SECRET_HEADER_RE = new RegExp( String.raw`(? { const prefix = - doubleQuotedPrefix ?? singleQuotedPrefix ?? unquotedPrefix ?? ""; - const closingQuote = doubleQuoteClose ?? singleQuoteClose ?? ""; + doubleQuotedPrefix ?? + singleQuotedPrefix ?? + escapedQuotedPrefix ?? + unquotedPrefix ?? + ""; + const closingQuote = + doubleQuoteClose ?? singleQuoteClose ?? escapedQuoteClose ?? ""; return `${prefix}${redactedValue}${closingQuote}`; }, ) From 2c58c2c29797d9dd11b0112971f137f6abc43672 Mon Sep 17 00:00:00 2001 From: Michel Tomas Date: Sat, 5 Sep 2026 17:06:35 +0200 Subject: [PATCH 06/21] fix(adapter-utils): consume the whole shell word of a header value A header value is the rest of its shell word, which can concatenate unquoted, double-quoted, single-quoted, ANSI-C-quoted, and backslash-escaped segments. The rule now consumes every segment of that word before writing one placeholder, stops at whitespace and shell metacharacters so the next argument survives, and still redacts a run-log line truncated inside a quoted value. The recognized scheme list gains the registered `Concealed` scheme, and a quoted Digest parameter may carry HTTP quoted-pairs. Claude-Session: https://claude.ai/code/session_01RYigf3eMFJjey9iKRApPGE --- .../src/command-redaction.test.ts | 157 ++++++++++++++++++ .../adapter-utils/src/command-redaction.ts | 98 ++++++++--- 2 files changed, 231 insertions(+), 24 deletions(-) diff --git a/packages/adapter-utils/src/command-redaction.test.ts b/packages/adapter-utils/src/command-redaction.test.ts index b3f8f3467b..6f216652d3 100644 --- a/packages/adapter-utils/src/command-redaction.test.ts +++ b/packages/adapter-utils/src/command-redaction.test.ts @@ -352,6 +352,163 @@ describe("redactCommandText header secrets", () => { expect(redactDiagnosticText(once)).toBe(once); }); + 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. + 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`, + ); + }); + + it("redacts a segment adjacent to a quoted header argument", () => { + // The trailing `123` joins the same shell word, so it is part of the value. + const input = `curl -H "X-API-Key: abc"123 https://example.test`; + const output = redactCommandText(input); + expect(output).not.toContain("123"); + expect(output).toBe( + `curl -H "X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE}" https://example.test`, + ); + }); + + it("redacts across an unquoted escape pair", () => { + // `\ ` escapes the space, so the word continues past it. + const input = String.raw`curl -H X-API-Key:abc\ 123 https://example.test`; + const output = redactCommandText(input); + expect(output).not.toContain("123"); + expect(output).toBe( + `curl -H X-API-Key:${REDACTED_COMMAND_TEXT_VALUE} https://example.test`, + ); + }); + + it("redacts an ANSI-C quoted header argument", () => { + const input = String.raw`curl -H $'X-API-Key: abc\'123' https://example.test`; + const output = redactCommandText(input); + expect(output).not.toContain("abc"); + expect(output).not.toContain("123"); + expect(output).toBe( + `curl -H $'X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE}' https://example.test`, + ); + }); + + it("keeps an unterminated quote out of the value", () => { + // A lone quote does not open a segment, so the word ends before it. + const input = String.raw`X-API-Key: abc"tail`; + const output = redactCommandText(input); + expect(output).not.toContain("abc"); + expect(output).toBe( + `X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE}"tail`, + ); + }); + + it("redacts a concealed credential parameter list", () => { + // RFC 9729 writes the proof and key identifier as authentication parameters. + const input = + "Authorization: Concealed k=YmFzZW1lbnQ, a=PUBLICKEY, s=2055, v=VERIFY, p=PROOFSECRET status=401"; + const output = redactCommandText(input); + expect(output).not.toContain("PROOFSECRET"); + expect(output).not.toContain("YmFzZW1lbnQ"); + expect(output).toBe( + `Authorization: Concealed ${REDACTED_COMMAND_TEXT_VALUE} status=401`, + ); + }); + + it("redacts a quoted concealed credential to the closing quote", () => { + const input = `curl -H "Authorization: Concealed k=YmFzZW1lbnQ, p=PROOFSECRET" https://x`; + const output = redactCommandText(input); + expect(output).not.toContain("PROOFSECRET"); + expect(output).toBe( + `curl -H "Authorization: Concealed ${REDACTED_COMMAND_TEXT_VALUE}" https://x`, + ); + }); + + it("redacts a digest credential whose parameter carries a quoted-pair", () => { + // HTTP quoted-string syntax allows an escaped character inside a parameter. + const input = String.raw`Authorization: Digest username="al\"ice", nonce="n", response="abc123" status=401`; + const output = redactCommandText(input); + expect(output).not.toContain("abc123"); + expect(output).not.toContain("al"); + expect(output).toBe( + `Authorization: Digest ${REDACTED_COMMAND_TEXT_VALUE} status=401`, + ); + expect(redactDiagnosticText(input)).toBe( + `Authorization: Digest ${REDACTED_COMMAND_TEXT_VALUE} status=401`, + ); + }); + + it("redacts a quoted header argument whose closing quote never arrives", () => { + // A truncated run log ends the line mid-argument. The value runs to the end + // of the line instead of to a closing quote. + const R = REDACTED_COMMAND_TEXT_VALUE; + expect(redactCommandText(`curl -H "X-API-Key: abc`)).toBe( + `curl -H "X-API-Key: ${R}`, + ); + expect(redactCommandText(`curl -H 'X-API-Key: abc`)).toBe( + `curl -H 'X-API-Key: ${R}`, + ); + expect(redactCommandText(`curl -H $'X-API-Key: abc`)).toBe( + `curl -H $'X-API-Key: ${R}`, + ); + // A lone trailing backslash is part of the truncated value. + expect(redactCommandText('curl -H "X-API-Key: abc\\')).toBe( + `curl -H "X-API-Key: ${R}`, + ); + // The next line is a separate line, so it stays as it is. + expect(redactCommandText('curl -H "X-API-Key: abc\nsecond line')).toBe( + `curl -H "X-API-Key: ${R}\nsecond line`, + ); + }); + + it("keeps a shell separator after a quoted header argument", () => { + // A metacharacter ends the shell word, so the pipeline and the next command + // survive the redaction. + expect(redactCommandText(`curl -H 'x-api-key: abc'|head`)).toBe( + `curl -H 'x-api-key: ${REDACTED_COMMAND_TEXT_VALUE}'|head`, + ); + expect( + redactCommandText(`sh -c 'curl -H "X-API-Key: abc"; echo done'`), + ).toBe( + `sh -c 'curl -H "X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE}"; echo done'`, + ); + expect(redactCommandText(`(curl -H "X-API-Key: abc")`)).toBe( + `(curl -H "X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE}")`, + ); + }); + + it("is stable and keeps serialized commands parseable", () => { + const shellWordForms = [ + `curl -H X-API-Key:"abc123" https://example.test`, + `curl -H "X-API-Key: abc"123 https://example.test`, + String.raw`curl -H X-API-Key:abc\ 123 https://example.test`, + String.raw`curl -H $'X-API-Key: abc\'123' https://example.test`, + ]; + const pinnedForms = [ + `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`, + String.raw`prefix Authorization: \"Bearer nested\" suffix`, + 'Authorization: Digest username="alice", response="deadbeef" status=401', + "Authorization: AWS4-HMAC-SHA256 Credential=AKIAEXAMPLE, Signature=abc123 retry", + String.raw`X-API-Key: abc"tail`, + ]; + for (const input of [...shellWordForms, ...pinnedForms]) { + const once = redactCommandText(input); + expect(redactCommandText(once)).toBe(once); + expect(redactDiagnosticText(once)).toBe(once); + } + // The escaped-quoted branch keeps a serialized command valid JSON. + const serializedForms = [ + String.raw`{"command":"curl -H \"X-API-Key: abc\" https://example.test"}`, + String.raw`{"command":"curl -H \"Authorization: Digest username=\\\"alice\\\", response=\\\"deadbeef\\\"\" https://x"}`, + ]; + for (const input of serializedForms) { + const once = redactCommandText(input); + expect(() => JSON.parse(once)).not.toThrow(); + expect(redactCommandText(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 acd4a199bb..7061639c89 100644 --- a/packages/adapter-utils/src/command-redaction.ts +++ b/packages/adapter-utils/src/command-redaction.ts @@ -32,11 +32,25 @@ const COMMAND_AUTHORIZATION_BEARER_RE = // `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 `=`, +// A header value is the rest of its shell word, so a multi-part credential +// stays covered end to end. A shell word concatenates segments: an unquoted +// run, a double-quoted part, a single-quoted part, an ANSI-C `$'...'` part, and +// a backslash escape pair all join into one argument, and the rule consumes +// every segment of the word before it writes one placeholder. Whitespace and a +// shell metacharacter end the word, so the following argument survives. A +// quoted segment that opens the value may also end at a line break or at the +// end of the input, because a truncated run log writes an argument whose +// closing quote never arrives. The first segment of an unquoted value never +// opens on a backslash, which leaves an escaped-quote opener such as +// `Authorization: \"Bearer ...\"` to the caller's own rules. 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 +// parameter written as an HTTP quoted-string carries quoted-pairs and still +// rejects a raw line break. A continuation parameter must itself carry an `=`, // so a bare word after the last parameter (`... response="r" status=401`) // survives. // @@ -55,20 +69,20 @@ const COMMAND_AUTHORIZATION_BEARER_RE = // 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 -// backslash literally, because a shell single quote has no escapes. Only the -// unquoted branch stops at a backslash, so an escaped-quote opener that does -// not follow a header name, such as `Authorization: \"Bearer ...\"`, is left to -// the caller's own authorization rules. A quoted value must open with 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. // -// 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. +// The scheme list follows the IANA HTTP Authentication Scheme Registry as of +// the RFC 9729 `Concealed` addition, plus `AWS4-HMAC-SHA256`, `Hawk`, 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", + "Concealed", "Digest", "DPoP", "GNAP", @@ -92,13 +106,40 @@ const COMMAND_SECRET_HEADER_PREFIX_PATTERN = const COMMAND_SECRET_HEADER_PARAM_PATTERN = String.raw`[^\s"'` + "`" + - String.raw`\\,=]+=(?:"[^"\\\r\n]*"|'[^'\\\r\n]*'|[^\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_PARAM_LIST_PATTERN = + COMMAND_SECRET_HEADER_PARAM_PATTERN + + String.raw`(?:[ \t]*,[ \t]*${COMMAND_SECRET_HEADER_PARAM_PATTERN})*`; +// The segments a shell word concatenates. A double-quoted part keeps its escape +// pairs and line continuations, a single-quoted part takes every byte +// literally, an ANSI-C `$'...'` part has its own escapes, a lone escape pair +// carries one character, and a plain run carries the rest. +const COMMAND_SHELL_QUOTED_SEGMENT_PATTERNS = [ + String.raw`"(?:\\\r?\n|\\.|[^"\\\r\n])*"`, + String.raw`'[^'\r\n]*'`, + String.raw`\$'(?:\\.|[^'\\\r\n])*'`, +] as const; +const COMMAND_SHELL_ESCAPE_PAIR_PATTERN = String.raw`\\[^\r\n]`; +// A plain run stops at a shell metacharacter as well as at whitespace: `;`, +// `|`, `&`, `<`, `>`, and the parentheses end the word, so a redaction never +// swallows a separator, a redirection, or the next command. +const COMMAND_SHELL_PLAIN_SEGMENT_PATTERN = + String.raw`[^\s"'` + "`" + String.raw`\\;|&<>()]+`; +const COMMAND_SHELL_SEGMENT_PATTERN = `(?:${[ + ...COMMAND_SHELL_QUOTED_SEGMENT_PATTERNS, + COMMAND_SHELL_ESCAPE_PAIR_PATTERN, + COMMAND_SHELL_PLAIN_SEGMENT_PATTERN, +].join("|")})`; +// The first segment of an unquoted value never opens on a backslash, so an +// escaped-quote opener stays with the caller's own rules. +const COMMAND_SHELL_FIRST_SEGMENT_PATTERN = `(?:${[ + ...COMMAND_SHELL_QUOTED_SEGMENT_PATTERNS, + COMMAND_SHELL_PLAIN_SEGMENT_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 @@ -106,12 +147,13 @@ const COMMAND_SECRET_HEADER_UNQUOTED_VALUE_PATTERN = // argument. const COMMAND_SECRET_HEADER_JSON_ESCAPE_PATTERN = String.raw`\\\\\\.|\\\\[^\\]|\\[^"\\]`; const COMMAND_SECRET_HEADER_RE = new RegExp( - String.raw`(? { + // Exactly one branch matches, so exactly one prefix is defined. const prefix = doubleQuotedPrefix ?? singleQuotedPrefix ?? - escapedQuotedPrefix ?? + ansiCQuotedPrefix ?? + serializedPrefix ?? unquotedPrefix ?? ""; const closingQuote = - doubleQuoteClose ?? singleQuoteClose ?? escapedQuoteClose ?? ""; + doubleQuoteClose ?? + singleQuoteClose ?? + ansiCQuoteClose ?? + serializedClose ?? + ""; return `${prefix}${redactedValue}${closingQuote}`; }, ) From 03b3e4b3ff5760ca14c760dc07b68f201d9740fc Mon Sep 17 00:00:00 2001 From: Michel Tomas Date: Sat, 5 Sep 2026 17:50:05 +0200 Subject: [PATCH 07/21] fix(adapter-utils): read the first unquoted header segment as a raw token The first segment of an unquoted header value may open on an escape pair, so a value whose first byte is an escaped space is consumed, while an escaped-quote opener still falls to the caller's own rules. That first segment is bounded by whitespace only: a raw HTTP diagnostic carries an opaque credential the same way, so a shell metacharacter inside it is a credential byte. Only a continuation segment after a closing quote stops at a metacharacter, which keeps a following separator or command intact. Claude-Session: https://claude.ai/code/session_01RYigf3eMFJjey9iKRApPGE --- .../src/command-redaction.test.ts | 40 +++++++++++++++++++ .../adapter-utils/src/command-redaction.ts | 31 +++++++++----- 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/packages/adapter-utils/src/command-redaction.test.ts b/packages/adapter-utils/src/command-redaction.test.ts index 6f216652d3..ff1ddf3ffa 100644 --- a/packages/adapter-utils/src/command-redaction.test.ts +++ b/packages/adapter-utils/src/command-redaction.test.ts @@ -460,6 +460,46 @@ describe("redactCommandText header secrets", () => { ); }); + it("redacts a value that opens with an escape pair", () => { + // `X-API-Key:\ abc123` is one shell word whose first value byte is escaped. + const input = String.raw`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`, + ); + }); + + it("redacts a raw header value that contains a shell metacharacter", () => { + // A raw HTTP diagnostic carries an opaque credential, so `;` inside the + // value is a credential byte and the whole token goes. + const input = "tool: X-API-Key: abc;def status=401"; + const output = redactCommandText(input); + expect(output).not.toContain("def"); + expect(output).toBe( + `tool: X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE} status=401`, + ); + expect(redactDiagnosticText(input)).toBe( + `tool: X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE} status=401`, + ); + }); + + it("takes the whole raw token when a command shares that shape", () => { + // The same bytes read as a shell command would end the word at `;`. The + // raw-token reading wins, which over-redacts here and never under-redacts. + expect(redactCommandText("X-API-Key:abc;echo done")).toBe( + `X-API-Key:${REDACTED_COMMAND_TEXT_VALUE} done`, + ); + }); + + it("stops a continuation segment at a shell metacharacter", () => { + // After a closing quote the word really does end at `;`, so the next + // command survives. + expect(redactCommandText(`curl -H "X-API-Key: abc"123;echo done`)).toBe( + `curl -H "X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE}";echo done`, + ); + }); + it("keeps a shell separator after a quoted header argument", () => { // A metacharacter ends the shell word, so the pipeline and the next command // survive the redaction. diff --git a/packages/adapter-utils/src/command-redaction.ts b/packages/adapter-utils/src/command-redaction.ts index 7061639c89..d66262b5dd 100644 --- a/packages/adapter-utils/src/command-redaction.ts +++ b/packages/adapter-utils/src/command-redaction.ts @@ -40,9 +40,11 @@ const COMMAND_AUTHORIZATION_BEARER_RE = // shell metacharacter end the word, so the following argument survives. A // quoted segment that opens the value may also end at a line break or at the // end of the input, because a truncated run log writes an argument whose -// closing quote never arrives. The first segment of an unquoted value never -// opens on a backslash, which leaves an escaped-quote opener such as -// `Authorization: \"Bearer ...\"` to the caller's own rules. The unquoted +// 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. @@ -56,7 +58,7 @@ const COMMAND_AUTHORIZATION_BEARER_RE = // // 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 +// rule agree with the bearer rule above for a well-formed bearer header, so // `Authorization: Bearer ` produces the same output as before. // // Each branch treats the backslash the way its quoting context does. A @@ -122,9 +124,19 @@ const COMMAND_SHELL_QUOTED_SEGMENT_PATTERNS = [ String.raw`\$'(?:\\.|[^'\\\r\n])*'`, ] as const; const COMMAND_SHELL_ESCAPE_PAIR_PATTERN = String.raw`\\[^\r\n]`; -// A plain run stops at a shell metacharacter as well as at whitespace: `;`, -// `|`, `&`, `<`, `>`, and the parentheses end the word, so a redaction never -// swallows a separator, a redirection, or the next command. +// 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. +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 +// carries an opaque credential the same way, so a `;`, `|`, or `&` inside it +// is a credential byte rather than a command separator. +const COMMAND_SHELL_RAW_TOKEN_PATTERN = + String.raw`[^\s"'` + "`" + String.raw`\\]+`; +// A continuation segment follows a closing quote inside one shell word, where +// a metacharacter does end the word. Stopping there keeps a redaction from +// swallowing a separator, a redirection, or the next command. const COMMAND_SHELL_PLAIN_SEGMENT_PATTERN = String.raw`[^\s"'` + "`" + String.raw`\\;|&<>()]+`; const COMMAND_SHELL_SEGMENT_PATTERN = `(?:${[ @@ -132,11 +144,10 @@ const COMMAND_SHELL_SEGMENT_PATTERN = `(?:${[ COMMAND_SHELL_ESCAPE_PAIR_PATTERN, COMMAND_SHELL_PLAIN_SEGMENT_PATTERN, ].join("|")})`; -// The first segment of an unquoted value never opens on a backslash, so an -// escaped-quote opener stays with the caller's own rules. const COMMAND_SHELL_FIRST_SEGMENT_PATTERN = `(?:${[ ...COMMAND_SHELL_QUOTED_SEGMENT_PATTERNS, - COMMAND_SHELL_PLAIN_SEGMENT_PATTERN, + 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})`; From 5959803e5c60103076bbe950af2bc2bf999f195e Mon Sep 17 00:00:00 2001 From: Michel Tomas Date: Sat, 5 Sep 2026 18:18:02 +0200 Subject: [PATCH 08/21] 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 --- .../src/command-redaction.test.ts | 68 +++++++++++++++-- .../adapter-utils/src/command-redaction.ts | 76 ++++++++++--------- 2 files changed, 100 insertions(+), 44 deletions(-) diff --git a/packages/adapter-utils/src/command-redaction.test.ts b/packages/adapter-utils/src/command-redaction.test.ts index ff1ddf3ffa..649714f2fa 100644 --- a/packages/adapter-utils/src/command-redaction.test.ts +++ b/packages/adapter-utils/src/command-redaction.test.ts @@ -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`, diff --git a/packages/adapter-utils/src/command-redaction.ts b/packages/adapter-utils/src/command-redaction.ts index d66262b5dd..4f285cb6d8 100644 --- a/packages/adapter-utils/src/command-redaction.ts +++ b/packages/adapter-utils/src/command-redaction.ts @@ -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`|(? { - // 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, From c152eed16ac1606dd4508f5b797e0a7b58b1237e Mon Sep 17 00:00:00 2001 From: Michel Tomas Date: Sat, 5 Sep 2026 22:45:14 +0200 Subject: [PATCH 09/21] 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 --- .../src/command-redaction.test.ts | 112 ++++++++++++++ .../adapter-utils/src/command-redaction.ts | 140 +++++++++++------- 2 files changed, 200 insertions(+), 52 deletions(-) diff --git a/packages/adapter-utils/src/command-redaction.test.ts b/packages/adapter-utils/src/command-redaction.test.ts index 649714f2fa..1f80f44eb2 100644 --- a/packages/adapter-utils/src/command-redaction.test.ts +++ b/packages/adapter-utils/src/command-redaction.test.ts @@ -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); diff --git a/packages/adapter-utils/src/command-redaction.ts b/packages/adapter-utils/src/command-redaction.ts index 4f285cb6d8..abf7ab022e 100644 --- a/packages/adapter-utils/src/command-redaction.ts +++ b/packages/adapter-utils/src/command-redaction.ts @@ -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`(?")(?:\\\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, @@ -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`(?(?:\\\\)*\\)"`; +const commandSecretHeaderEscapedBody = (run: string) => + String.raw`(?:(?!(?")[^\s\r\n])(?:(?!(?")[^\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}` + + `|(?${commandSecretHeaderEscapedOpener("serRun")}${COMMAND_SECRET_HEADER_PREFIX_PATTERN})` + + `${commandSecretHeaderEscapedBody("serRun")}${commandSecretHeaderEscapedCloser("serRun", "serClose")}` + + String.raw`|(?\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`|(?\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`) From f675a08bbd4a4d29e5b2e2959341c8223a25e02b Mon Sep 17 00:00:00 2001 From: Michel Tomas Date: Sat, 5 Sep 2026 23:18:10 +0200 Subject: [PATCH 10/21] fix(adapter-utils): consume a serialized header's adjacent segment and keep a truncated string well formed A suffix segment adjacent to a serialized quoted header argument belongs to the same shell word, so the serialized branch now takes the shell continuation after its closer. A truncated serialized argument has no closer on its line; its value then stops before a bare quote, one preceded by an even run of backslashes, which can only be the enclosing serializer's delimiter, so that string stays parseable. The closer of an escaped-quoted value must itself be unescaped, so backtracking cannot read an escaped backslash as the closer. Claude-Session: https://claude.ai/code/session_01RYigf3eMFJjey9iKRApPGE --- .../src/command-redaction.test.ts | 36 +++++++++++++++++++ .../adapter-utils/src/command-redaction.ts | 19 +++++++--- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/packages/adapter-utils/src/command-redaction.test.ts b/packages/adapter-utils/src/command-redaction.test.ts index 1f80f44eb2..b58d37aad8 100644 --- a/packages/adapter-utils/src/command-redaction.test.ts +++ b/packages/adapter-utils/src/command-redaction.test.ts @@ -306,6 +306,42 @@ describe("redactCommandText header secrets", () => { ); }); + it("consumes a suffix segment adjacent to a serialized quoted header argument", () => { + // The suffix is part of the same shell word as the header, so it is part + // of the credential at every serialization depth. + let text = 'curl -H "X-API-Key: SECRET"TAILMARK;echo safe'; + for (let depth = 1; depth <= 3; depth += 1) { + text = JSON.stringify(text); + const output = redactCommandText(text); + expect(output).not.toContain("SECRET"); + expect(output).not.toContain("TAILMARK"); + expect(output).toContain(";echo safe"); + expect(() => JSON.parse(output)).not.toThrow(); + expect(redactCommandText(output)).toBe(output); + } + }); + + it("keeps a serializer's closing delimiter when the argument is truncated", () => { + // A run log can cut a serialized command inside the header argument. The + // truncated value stops before the enclosing string's own quote, even + // when the cut lands after a backslash, so the string stays well formed. + const cuts = [ + 'curl -H "X-API-Key: SECRET', + 'curl -H X-API-Key:"SECRET', + 'curl -H "X-API-Key: SECRET\\', + 'curl -H "X-API-Key: SECRET\\\\', + 'curl -H X-API-Key:"SECRET\\', + ]; + for (const cut of cuts) { + for (const text of [JSON.stringify(cut), JSON.stringify(JSON.stringify(cut))]) { + const output = redactCommandText(text); + expect(output).not.toContain("SECRET"); + expect(() => JSON.parse(output)).not.toThrow(); + expect(redactCommandText(output)).toBe(output); + } + } + }); + it("redacts a bare apikey header value", () => { // Supabase sends the key under an unhyphenated `apikey` header. expect(redactCommandText("apikey: abc")).toBe( diff --git a/packages/adapter-utils/src/command-redaction.ts b/packages/adapter-utils/src/command-redaction.ts index abf7ab022e..0223179264 100644 --- a/packages/adapter-utils/src/command-redaction.ts +++ b/packages/adapter-utils/src/command-redaction.ts @@ -185,16 +185,27 @@ const commandSecretHeaderEscapedOpener = (run: string) => String.raw`(?(?:\\\\)*\\)"`; const commandSecretHeaderEscapedBody = (run: string) => String.raw`(?:(?!(?")[^\s\r\n])(?:(?!(?")[^\r\n])*`; -const commandSecretHeaderEscapedCloser = (run: string, close: string) => - String.raw`(?:(?<${close}>\k<${run}>")|(?=[\r\n]|$))`; +// A truncated argument has no closer on its line. Its body then also stops +// before a bare quote, one preceded by an even run of backslashes, which can +// only be an enclosing serializer's delimiter; that delimiter survives and the +// enclosing string stays well formed. The closer itself is unescaped, so +// backtracking never reads an escaped backslash as the closer. +const COMMAND_SECRET_HEADER_BARE_QUOTE_PATTERN = String.raw`(?<=(? + String.raw`(?:(?!(?")(?!${COMMAND_SECRET_HEADER_BARE_QUOTE_PATTERN})[^\s\r\n])` + + String.raw`(?:(?!(?")(?!${COMMAND_SECRET_HEADER_BARE_QUOTE_PATTERN})[^\r\n])*`; +const commandSecretHeaderEscapedValue = (run: string, close: string) => + `(?:${commandSecretHeaderEscapedBody(run)}(?\\k<${run}>")` + + `|${commandSecretHeaderEscapedTruncatedBody(run)}` + + String.raw`(?=[\r\n]|$|${COMMAND_SECRET_HEADER_BARE_QUOTE_PATTERN}))`; 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}` + `|(?${commandSecretHeaderEscapedOpener("serRun")}${COMMAND_SECRET_HEADER_PREFIX_PATTERN})` + - `${commandSecretHeaderEscapedBody("serRun")}${commandSecretHeaderEscapedCloser("serRun", "serClose")}` + + `${commandSecretHeaderEscapedValue("serRun", "serClose")}${COMMAND_SECRET_HEADER_CONTINUATION_PATTERN}` + String.raw`|(?\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}` + + `${commandSecretHeaderEscapedValue("evRun", "evClose")}${COMMAND_SECRET_HEADER_CONTINUATION_PATTERN}` + String.raw`|(?\b${COMMAND_SECRET_HEADER_PREFIX_PATTERN})${COMMAND_SECRET_HEADER_UNQUOTED_VALUE_PATTERN}${COMMAND_SECRET_HEADER_CONTINUATION_PATTERN}`, "gi", ); From 205c82591be7e6d04c34658a9a95d5530d6ce621 Mon Sep 17 00:00:00 2001 From: Michel Tomas Date: Sat, 5 Sep 2026 23:20:04 +0200 Subject: [PATCH 11/21] fix(adapter-utils): read a serialized escape pair as one backslash run A shell escape pair doubles its backslash with every serialization layer, so the continuation and opening escape-pair segments now consume the whole backslash run with the character it escapes. An escaped-space segment adjacent to a header value inside a serialized command is therefore part of the value at any depth. Claude-Session: https://claude.ai/code/session_01RYigf3eMFJjey9iKRApPGE --- .../src/command-redaction.test.ts | 21 +++++++++++++++++++ .../adapter-utils/src/command-redaction.ts | 12 +++++------ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/packages/adapter-utils/src/command-redaction.test.ts b/packages/adapter-utils/src/command-redaction.test.ts index b58d37aad8..7be371ff22 100644 --- a/packages/adapter-utils/src/command-redaction.test.ts +++ b/packages/adapter-utils/src/command-redaction.test.ts @@ -342,6 +342,27 @@ describe("redactCommandText header secrets", () => { } }); + it("consumes an escaped-space continuation at every serialization depth", () => { + // A shell escape pair doubles its backslash with each serialization + // layer; the continuation reads the whole run as one pair. + const bases = [ + 'curl -H X-API-Key:"SECRET"\\ TAIL https://example.test', + 'curl -H X-API-Key:\\ SECRET https://example.test', + 'curl -H "X-API-Key: SECRET"\\ TAIL;echo safe', + ]; + for (const base of bases) { + let text = base; + for (let depth = 0; depth <= 2; depth += 1) { + if (depth > 0) text = JSON.stringify(text); + const output = redactCommandText(text); + expect(output).not.toContain("SECRET"); + expect(output).not.toContain("TAIL"); + if (depth > 0) expect(() => JSON.parse(output)).not.toThrow(); + expect(redactCommandText(output)).toBe(output); + } + } + }); + it("redacts a bare apikey header value", () => { // Supabase sends the key under an unhyphenated `apikey` header. expect(redactCommandText("apikey: abc")).toBe( diff --git a/packages/adapter-utils/src/command-redaction.ts b/packages/adapter-utils/src/command-redaction.ts index 0223179264..3a534525e0 100644 --- a/packages/adapter-utils/src/command-redaction.ts +++ b/packages/adapter-utils/src/command-redaction.ts @@ -135,13 +135,13 @@ const COMMAND_SHELL_QUOTED_SEGMENT_PATTERNS = [ String.raw`'[^'\r\n]*'`, String.raw`\$'(?:\\.|[^'\\\r\n])*'`, ] as const; -const COMMAND_SHELL_ESCAPE_PAIR_PATTERN = String.raw`\\[^\r\n]`; +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 `\"` 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]`; +// `X-API-Key:\ abc`. The backslash run may be longer inside a serialized +// command, where each layer doubles it. A run followed by a quote is excluded, +// so a `\"` opener at any depth falls to the escaped branches, which precede +// the unquoted one in the alternation. +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 // carries an opaque credential the same way, so a `;`, `|`, or `&` inside it From bf30a05e6451ec232cf1c72b5e8057016a275893 Mon Sep 17 00:00:00 2001 From: Michel Tomas Date: Sat, 5 Sep 2026 23:45:06 +0200 Subject: [PATCH 12/21] fix(adapter-utils): read an even backslash run before a quote as a bare quote An escape-pair segment is either an even backslash run followed by a character other than a quote, the form a serialized escaped space takes, or an odd run followed by any character, a true shell escape. An even run before a quote is an escaped backslash and the quote opens a further segment of the same word, which the value consumes. A truncated escaped-quoted value runs to the end of its line again: a bare quote there may be a further segment of the word, so it is redacted rather than kept as an enclosing delimiter. Claude-Session: https://claude.ai/code/session_01RYigf3eMFJjey9iKRApPGE --- .../src/command-redaction.test.ts | 31 ++++++++++++++++--- .../adapter-utils/src/command-redaction.ts | 21 +++++-------- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/packages/adapter-utils/src/command-redaction.test.ts b/packages/adapter-utils/src/command-redaction.test.ts index 7be371ff22..2293c6eaf4 100644 --- a/packages/adapter-utils/src/command-redaction.test.ts +++ b/packages/adapter-utils/src/command-redaction.test.ts @@ -321,10 +321,11 @@ describe("redactCommandText header secrets", () => { } }); - it("keeps a serializer's closing delimiter when the argument is truncated", () => { - // A run log can cut a serialized command inside the header argument. The - // truncated value stops before the enclosing string's own quote, even - // when the cut lands after a backslash, so the string stays well formed. + it("redacts a truncated serialized argument to the end of its line", () => { + // A run log can cut a serialized command inside the header argument. With + // no closer on the line, the value runs to the end of the line: a bare + // quote there may be a further segment of the same shell word, so the rule + // redacts it rather than keeping it as the enclosing string's delimiter. const cuts = [ 'curl -H "X-API-Key: SECRET', 'curl -H X-API-Key:"SECRET', @@ -336,12 +337,32 @@ describe("redactCommandText header secrets", () => { for (const text of [JSON.stringify(cut), JSON.stringify(JSON.stringify(cut))]) { const output = redactCommandText(text); expect(output).not.toContain("SECRET"); - expect(() => JSON.parse(output)).not.toThrow(); expect(redactCommandText(output)).toBe(output); } } }); + it("keeps an even backslash run before a quote out of the escape pair", () => { + // Two backslashes are an escaped backslash; the quote after them opens a + // further segment of the same word, which is consumed with the value. + const input = 'curl -H X-API-Key:SECRET\\\\"TAILMARK"MORE ;echo safe'; + const output = redactCommandText(input); + expect(output).not.toContain("SECRET"); + expect(output).not.toContain("TAILMARK"); + expect(output).not.toContain("MORE"); + expect(output).toBe(`curl -H X-API-Key:${REDACTED_COMMAND_TEXT_VALUE} ;echo safe`); + expect(redactCommandText(output)).toBe(output); + }); + + it("redacts a truncated quoted tail after an escaped-quoted value", () => { + const input = 'curl -H X-API-Key:\\"SECRET"TAILMARK'; + const output = redactCommandText(input); + expect(output).not.toContain("SECRET"); + expect(output).not.toContain("TAILMARK"); + expect(output).toBe(`curl -H X-API-Key:\\"${REDACTED_COMMAND_TEXT_VALUE}`); + expect(redactCommandText(output)).toBe(output); + }); + it("consumes an escaped-space continuation at every serialization depth", () => { // A shell escape pair doubles its backslash with each serialization // layer; the continuation reads the whole run as one pair. diff --git a/packages/adapter-utils/src/command-redaction.ts b/packages/adapter-utils/src/command-redaction.ts index 3a534525e0..9d4d6f6c54 100644 --- a/packages/adapter-utils/src/command-redaction.ts +++ b/packages/adapter-utils/src/command-redaction.ts @@ -135,13 +135,13 @@ const COMMAND_SHELL_QUOTED_SEGMENT_PATTERNS = [ String.raw`'[^'\r\n]*'`, String.raw`\$'(?:\\.|[^'\\\r\n])*'`, ] as const; -const COMMAND_SHELL_ESCAPE_PAIR_PATTERN = String.raw`\\+[^\r\n]`; +const COMMAND_SHELL_ESCAPE_PAIR_PATTERN = String.raw`(?:(?:\\\\)+[^"\r\n]|(?:\\\\)*\\[^\r\n])`; // An opening escape pair carries the first byte of an unquoted value, as in // `X-API-Key:\ abc`. The backslash run may be longer inside a serialized // command, where each layer doubles it. A run followed by a quote is excluded, // so a `\"` opener at any depth falls to the escaped branches, which precede // the unquoted one in the alternation. -const COMMAND_SHELL_OPENING_ESCAPE_PAIR_PATTERN = String.raw`\\+[^"\r\n]`; +const COMMAND_SHELL_OPENING_ESCAPE_PAIR_PATTERN = String.raw`(?:(?:\\\\)+[^"\r\n]|(?:\\\\)*\\[^"\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 // carries an opaque credential the same way, so a `;`, `|`, or `&` inside it @@ -185,19 +185,14 @@ const commandSecretHeaderEscapedOpener = (run: string) => String.raw`(?(?:\\\\)*\\)"`; const commandSecretHeaderEscapedBody = (run: string) => String.raw`(?:(?!(?")[^\s\r\n])(?:(?!(?")[^\r\n])*`; -// A truncated argument has no closer on its line. Its body then also stops -// before a bare quote, one preceded by an even run of backslashes, which can -// only be an enclosing serializer's delimiter; that delimiter survives and the -// enclosing string stays well formed. The closer itself is unescaped, so -// backtracking never reads an escaped backslash as the closer. -const COMMAND_SECRET_HEADER_BARE_QUOTE_PATTERN = String.raw`(?<=(? - String.raw`(?:(?!(?")(?!${COMMAND_SECRET_HEADER_BARE_QUOTE_PATTERN})[^\s\r\n])` + - String.raw`(?:(?!(?")(?!${COMMAND_SECRET_HEADER_BARE_QUOTE_PATTERN})[^\r\n])*`; +// The closer itself is unescaped, so backtracking never reads an escaped +// backslash as the closer. A truncated argument has no closer on its line and +// the value then runs to the end of the line: a bare quote there may be a +// further segment of the same shell word, so it is redacted rather than kept. const commandSecretHeaderEscapedValue = (run: string, close: string) => `(?:${commandSecretHeaderEscapedBody(run)}(?\\k<${run}>")` + - `|${commandSecretHeaderEscapedTruncatedBody(run)}` + - String.raw`(?=[\r\n]|$|${COMMAND_SECRET_HEADER_BARE_QUOTE_PATTERN}))`; + `|${commandSecretHeaderEscapedBody(run)}` + + String.raw`(?=[\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}` + From 808854d9af7e2aa7dbf757c13dfe580616369331 Mon Sep 17 00:00:00 2001 From: Michel Tomas Date: Sun, 6 Sep 2026 00:13:16 +0200 Subject: [PATCH 13/21] fix(adapter-utils): consume an even escape run whole and a truncated trailing segment The even-run escape pair now requires a character that is neither a quote nor a backslash after the run, so a run of any even length is consumed by the odd alternative as pairs plus an escaped backslash and the following quote opens a segment of the same word. The continuation may end with one unterminated quoted segment that runs to the end of the line, so a log cut inside the last segment of a header word still redacts it. Claude-Session: https://claude.ai/code/session_01RYigf3eMFJjey9iKRApPGE --- .../src/command-redaction.test.ts | 46 ++++++++++++++----- .../adapter-utils/src/command-redaction.ts | 16 +++++-- 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/packages/adapter-utils/src/command-redaction.test.ts b/packages/adapter-utils/src/command-redaction.test.ts index 2293c6eaf4..6dbb6f8b53 100644 --- a/packages/adapter-utils/src/command-redaction.test.ts +++ b/packages/adapter-utils/src/command-redaction.test.ts @@ -296,14 +296,37 @@ describe("redactCommandText header secrets", () => { }); 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 + // `\\\\"` 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', - ); + expect(output).toBe(String.raw`foo\\"X-API-Key: ` + REDACTED_COMMAND_TEXT_VALUE); + }); + + it("consumes an even backslash run of any length before a segment quote", () => { + for (const run of ["\\\\", "\\\\\\\\", "\\\\\\\\\\\\"]) { + const input = `curl -H X-API-Key:SECRET${run}"TAILMARK"MORE ;echo safe`; + const output = redactCommandText(input); + expect(output).not.toContain("SECRET"); + expect(output).not.toContain("TAILMARK"); + expect(output).not.toContain("MORE"); + expect(output).toBe(`curl -H X-API-Key:${REDACTED_COMMAND_TEXT_VALUE} ;echo safe`); + expect(redactCommandText(output)).toBe(output); + } + }); + + it("redacts a truncated quoted tail after a closed escaped value", () => { + for (const tail of ["'TAILMARK", '"TAILMARK', "$'TAILMARK"]) { + let text = `curl -H X-API-Key:\\"SECRET\\"${tail}`; + for (let depth = 0; depth <= 2; depth += 1) { + if (depth > 0) text = JSON.stringify(text); + const output = redactCommandText(text); + expect(output).not.toContain("SECRET"); + expect(output).not.toContain("TAILMARK"); + expect(redactCommandText(output)).toBe(output); + } + } }); it("consumes a suffix segment adjacent to a serialized quoted header argument", () => { @@ -540,13 +563,14 @@ describe("redactCommandText header secrets", () => { ); }); - it("keeps an unterminated quote out of the value", () => { - // A lone quote does not open a segment, so the word ends before it. - const input = String.raw`X-API-Key: abc"tail`; - const output = redactCommandText(input); - expect(output).not.toContain("abc"); - expect(output).toBe( - `X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE}"tail`, + it("redacts an unterminated quoted tail as a truncated segment", () => { + // A quote with no closer on the line is read as a segment of the same + // word cut by the log, so its text is redacted rather than kept. + expect(redactCommandText('X-API-Key: abc"tail')).toBe( + `X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE}`, + ); + expect(redactCommandText('curl -H "X-API-Key: abc" "other')).toBe( + `curl -H "X-API-Key: ${REDACTED_COMMAND_TEXT_VALUE}" "other`, ); }); diff --git a/packages/adapter-utils/src/command-redaction.ts b/packages/adapter-utils/src/command-redaction.ts index 9d4d6f6c54..ccba563c93 100644 --- a/packages/adapter-utils/src/command-redaction.ts +++ b/packages/adapter-utils/src/command-redaction.ts @@ -135,13 +135,13 @@ const COMMAND_SHELL_QUOTED_SEGMENT_PATTERNS = [ String.raw`'[^'\r\n]*'`, String.raw`\$'(?:\\.|[^'\\\r\n])*'`, ] as const; -const COMMAND_SHELL_ESCAPE_PAIR_PATTERN = String.raw`(?:(?:\\\\)+[^"\r\n]|(?:\\\\)*\\[^\r\n])`; +const COMMAND_SHELL_ESCAPE_PAIR_PATTERN = String.raw`(?:(?:\\\\)+[^"\\\r\n]|(?:\\\\)*\\[^\r\n])`; // An opening escape pair carries the first byte of an unquoted value, as in // `X-API-Key:\ abc`. The backslash run may be longer inside a serialized // command, where each layer doubles it. A run followed by a quote is excluded, // so a `\"` opener at any depth falls to the escaped branches, which precede // the unquoted one in the alternation. -const COMMAND_SHELL_OPENING_ESCAPE_PAIR_PATTERN = String.raw`(?:(?:\\\\)+[^"\r\n]|(?:\\\\)*\\[^"\r\n])`; +const COMMAND_SHELL_OPENING_ESCAPE_PAIR_PATTERN = String.raw`(?:(?:\\\\)+[^"\\\r\n]|(?:\\\\)*\\[^"\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 // carries an opaque credential the same way, so a `;`, `|`, or `&` inside it @@ -158,7 +158,17 @@ const COMMAND_SHELL_SEGMENT_PATTERN = `(?:${[ COMMAND_SHELL_ESCAPE_PAIR_PATTERN, COMMAND_SHELL_PLAIN_SEGMENT_PATTERN, ].join("|")})`; -const COMMAND_SECRET_HEADER_CONTINUATION_PATTERN = `${COMMAND_SHELL_SEGMENT_PATTERN}*`; +// A run log can cut a line inside the last segment of the word. A quoted +// segment with no closer on the line is still part of the value, so the +// continuation may end with one unterminated quoted segment that runs to the +// end of the line. +const COMMAND_SHELL_TRUNCATED_SEGMENT_PATTERN = + String.raw`(?:"(?:\.|[^"\ +])*|\$'(?:\.|[^'\ +])*|'[^' +]*)(?=[ +]|$)`; +const COMMAND_SECRET_HEADER_CONTINUATION_PATTERN = `${COMMAND_SHELL_SEGMENT_PATTERN}*(?:${COMMAND_SHELL_TRUNCATED_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. From 85831e6a0bf6dbb85715f51a919106c798d249c0 Mon Sep 17 00:00:00 2001 From: Michel Tomas Date: Sun, 6 Sep 2026 02:01:57 +0200 Subject: [PATCH 14/21] fix(adapter-utils): scan the header value instead of matching it The header-secret rule was one composite regular expression with six branches. Command text arrives at an unknown serialization depth, and the same bytes mean opposite things at depth 0 and inside a JSON string, so every generalisation made for shell text broke serialized text or the reverse. Replace the whole thing with a bounded forward scanner. The scanner keeps the candidate detector as a regular expression: a header name, its colon, and an optional auth scheme are all a pattern can decide on its own. Everything after that is scanned in code, in one pass, linear in the length of the line. The contract is union over readings, never a guess. For each candidate the scanner enumerates the plausible readings of the surrounding text, scans the header's shell word once per reading, and redacts the longest span. A reading that disagrees can only lengthen the redaction, so disagreement over-redacts and never leaks. A reading is one number: the backslash run that spells a quote at that layer, with depth 0 as the run of length zero, which collapses the shell and serialized cases into one code path. Two leaks close. A serialized adjacent double-quoted segment whose first byte is a space is now consumed with the word it belongs to, at every depth. A truncated double-quoted tail carrying an escaped quote after a closed escaped value no longer leaves its remainder in the clear; bash reads those bytes as part of the credential-bearing word. One pass settles. The placeholder that replaces a value carries no quote, no backslash and no separator, so a second pass reads the output in a state the first pass never reached. The scanner consumes now whatever such a pass would consume, which keeps the caller's chain stable. Claude-Session: https://claude.ai/code/session_01RYigf3eMFJjey9iKRApPGE --- .../adapter-utils/src/command-redaction.ts | 883 ++++++++++++++---- 1 file changed, 692 insertions(+), 191 deletions(-) diff --git a/packages/adapter-utils/src/command-redaction.ts b/packages/adapter-utils/src/command-redaction.ts index ccba563c93..0ac15a43eb 100644 --- a/packages/adapter-utils/src/command-redaction.ts +++ b/packages/adapter-utils/src/command-redaction.ts @@ -32,58 +32,6 @@ const COMMAND_AUTHORIZATION_BEARER_RE = // `proxy-authenticate` are excluded: they are response headers whose // `error="invalid_token"` parameters are diagnostics worth keeping. // -// A header value is the rest of its shell word, so a multi-part credential -// stays covered end to end. A shell word concatenates segments: an unquoted -// run, a double-quoted part, a single-quoted part, an ANSI-C `$'...'` part, and -// a backslash escape pair all join into one argument, and the rule consumes -// every segment of the word before it writes one placeholder. Whitespace and a -// shell metacharacter end the word, so the following argument survives. A -// quoted segment that opens the value may also end at a line break or at the -// 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 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 -// parameter written as an HTTP quoted-string carries quoted-pairs and still -// rejects a raw line break. 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 with the bearer rule above for a well-formed bearer header, so -// `Authorization: Bearer ` produces the same output as before. -// -// Each branch treats the backslash the way its quoting context does. A -// 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 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 // `Token`, which are widely used but unregistered. A longer alternative @@ -113,131 +61,696 @@ const COMMAND_SECRET_HEADER_NAME_PATTERN = 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 + - COMMAND_SECRET_HEADER_COLON_PATTERN + - COMMAND_SECRET_HEADER_SCHEME_PATTERN; -const COMMAND_SECRET_HEADER_PARAM_PATTERN = - String.raw`[^\s"'` + - "`" + - String.raw`\\,=]+=(?:"(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*'|[^\s"'` + - "`" + - String.raw`\\,]*)`; -const COMMAND_SECRET_HEADER_PARAM_LIST_PATTERN = - COMMAND_SECRET_HEADER_PARAM_PATTERN + - String.raw`(?:[ \t]*,[ \t]*${COMMAND_SECRET_HEADER_PARAM_PATTERN})*`; -// The segments a shell word concatenates. A double-quoted part keeps its escape -// pairs and line continuations, a single-quoted part takes every byte -// literally, an ANSI-C `$'...'` part has its own escapes, a lone escape pair -// carries one character, and a plain run carries the rest. -const COMMAND_SHELL_QUOTED_SEGMENT_PATTERNS = [ - String.raw`"(?:\\\r?\n|\\.|[^"\\\r\n])*"`, - String.raw`'[^'\r\n]*'`, - String.raw`\$'(?:\\.|[^'\\\r\n])*'`, -] as const; -const COMMAND_SHELL_ESCAPE_PAIR_PATTERN = String.raw`(?:(?:\\\\)+[^"\\\r\n]|(?:\\\\)*\\[^\r\n])`; -// An opening escape pair carries the first byte of an unquoted value, as in -// `X-API-Key:\ abc`. The backslash run may be longer inside a serialized -// command, where each layer doubles it. A run followed by a quote is excluded, -// so a `\"` opener at any depth falls to the escaped branches, which precede -// the unquoted one in the alternation. -const COMMAND_SHELL_OPENING_ESCAPE_PAIR_PATTERN = String.raw`(?:(?:\\\\)+[^"\\\r\n]|(?:\\\\)*\\[^"\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 -// carries an opaque credential the same way, so a `;`, `|`, or `&` inside it -// is a credential byte rather than a command separator. -const COMMAND_SHELL_RAW_TOKEN_PATTERN = - String.raw`[^\s"'` + "`" + String.raw`\\]+`; -// A continuation segment follows a closing quote inside one shell word, where -// a metacharacter does end the word. Stopping there keeps a redaction from -// swallowing a separator, a redirection, or the next command. -const COMMAND_SHELL_PLAIN_SEGMENT_PATTERN = - String.raw`[^\s"'` + "`" + String.raw`\\;|&<>()]+`; -const COMMAND_SHELL_SEGMENT_PATTERN = `(?:${[ - ...COMMAND_SHELL_QUOTED_SEGMENT_PATTERNS, - COMMAND_SHELL_ESCAPE_PAIR_PATTERN, - COMMAND_SHELL_PLAIN_SEGMENT_PATTERN, -].join("|")})`; -// A run log can cut a line inside the last segment of the word. A quoted -// segment with no closer on the line is still part of the value, so the -// continuation may end with one unterminated quoted segment that runs to the -// end of the line. -const COMMAND_SHELL_TRUNCATED_SEGMENT_PATTERN = - String.raw`(?:"(?:\.|[^"\ -])*|\$'(?:\.|[^'\ -])*|'[^' -]*)(?=[ -]|$)`; -const COMMAND_SECRET_HEADER_CONTINUATION_PATTERN = `${COMMAND_SHELL_SEGMENT_PATTERN}*(?:${COMMAND_SHELL_TRUNCATED_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("|")})`; -// 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`(?(?:\\\\)*\\)"`; -const commandSecretHeaderEscapedBody = (run: string) => - String.raw`(?:(?!(?")[^\s\r\n])(?:(?!(?")[^\r\n])*`; -// The closer itself is unescaped, so backtracking never reads an escaped -// backslash as the closer. A truncated argument has no closer on its line and -// the value then runs to the end of the line: a bare quote there may be a -// further segment of the same shell word, so it is redacted rather than kept. -const commandSecretHeaderEscapedValue = (run: string, close: string) => - `(?:${commandSecretHeaderEscapedBody(run)}(?\\k<${run}>")` + - `|${commandSecretHeaderEscapedBody(run)}` + - String.raw`(?=[\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}` + - `|(?${commandSecretHeaderEscapedOpener("serRun")}${COMMAND_SECRET_HEADER_PREFIX_PATTERN})` + - `${commandSecretHeaderEscapedValue("serRun", "serClose")}${COMMAND_SECRET_HEADER_CONTINUATION_PATTERN}` + - String.raw`|(?\b${COMMAND_SECRET_HEADER_NAME_PATTERN}${COMMAND_SECRET_HEADER_COLON_PATTERN}${COMMAND_SECRET_HEADER_SCHEME_PATTERN}${commandSecretHeaderEscapedOpener("evRun")}${COMMAND_SECRET_HEADER_SCHEME_PATTERN})` + - `${commandSecretHeaderEscapedValue("evRun", "evClose")}${COMMAND_SECRET_HEADER_CONTINUATION_PATTERN}` + - String.raw`|(?\b${COMMAND_SECRET_HEADER_PREFIX_PATTERN})${COMMAND_SECRET_HEADER_UNQUOTED_VALUE_PATTERN}${COMMAND_SECRET_HEADER_CONTINUATION_PATTERN}`, +// The candidate detector. Everything after it is scanned in code: the header +// name, its colon, and an optional auth scheme are the only part of the rule a +// regular expression can decide on its own. +const COMMAND_SECRET_HEADER_CANDIDATE_RE = new RegExp( + String.raw`(? 0` it is the enclosing serializer's own delimiter and ends that +// reading there. +// +// The value is the rest of the header's shell word, so a multi-part credential +// stays covered end to end. A shell word concatenates segments: an unquoted +// run, a double-quoted part, a single-quoted part, an ANSI-C `$'...'` part, and +// a backslash escape pair all join into one argument. Whitespace and a shell +// metacharacter end the word once a segment has closed, so the following +// argument survives. A quoted segment with no closer on its line runs to the +// end of the line, because a truncated run log writes an argument whose closing +// quote never arrives. +// +// The first segment of an unquoted value is read in raw mode instead: a +// comma-separated `key=value` parameter list, the shape a `Digest`, +// `Concealed`, or `AWS4-HMAC-SHA256` credential takes, or else a token bounded +// only by whitespace, a quote, a backtick, or a backslash. A raw HTTP +// diagnostic carries an opaque credential, so a `;`, `|`, or `&` inside that +// token is a credential byte rather than a command separator. Only a +// continuation segment stops at one. +// +// Each candidate emits one placeholder: the header prefix byte for byte, the +// value's own opening delimiter when the value was quoted after the colon, the +// placeholder, the value's own closing delimiter when the winning reading found +// one, and the header argument's closing delimiter when the winning reading +// found one. Everything else the scan consumed is dropped. Every output fed +// back through the rule is byte-identical, which the caller's chain relies on. +const COMMAND_SHELL_METACHARACTERS = new Set([ + ";", + "|", + "&", + "<", + ">", + "(", + ")", + "`", +]); +// The serialization layers a reading may assume for a value whose delimiter +// does not name one: depth 0 through depth 4. +const COMMAND_SECRET_HEADER_READING_RUNS = [0, 1, 3, 7, 15] as const; + +type CommandDelimiterKind = "double" | "single" | "ansi" | "escaped"; + +interface CommandDelimiter { + kind: CommandDelimiterKind; + /** Backslash run that spells this delimiter's quote; 0 for a bare quote. */ + run: number; + /** Index just past the delimiter. */ + end: number; +} + +type CommandTokenKind = + | "char" + | "quote" + | "ansiOpen" + | "lineContinuation" + | "space" + | "newline" + | "metacharacter" + | "serializerEnd" + | "end"; + +interface CommandToken { + kind: CommandTokenKind; + /** The quote character for a `quote` token. */ + quote?: string; + start: number; + next: number; +} + +/** + * Decode one shell-level token at `index` under the reading `run`. + * + * The decoder collapses a whole backslash run at once, which is what makes the + * depth-0 and serialized readings share a code path and what keeps the scan + * linear: every token advances the index past the run it consumed. + */ +function readCommandToken( + text: string, + index: number, + run: number, +): CommandToken { + if (index >= text.length) return { kind: "end", start: index, next: index }; + const character = text[index]!; + if (character === "\\") { + let cursor = index; + while (cursor < text.length && text[cursor] === "\\") cursor += 1; + const runLength = cursor - index; + const unit = run + 1; + if (text[cursor] === '"') { + const offset = runLength - run; + const quotes = + offset >= 0 && offset % unit === 0 ? offset / unit : Number.NaN; + // An even count of decoded backslashes leaves the quote unescaped, so it + // delimits. An odd count, or a run this layer cannot have written, + // escapes it and the value runs on past it. + if (Number.isInteger(quotes) && quotes % 2 === 0) { + return { kind: "quote", quote: '"', start: index, next: cursor + 1 }; + } + return { kind: "char", start: index, next: cursor + 1 }; + } + const decoded = Math.floor(runLength / unit); + if (decoded % 2 === 1) { + if (cursor >= text.length) { + return { kind: "char", start: index, next: cursor }; + } + const escaped = text[cursor]!; + if (escaped === "\n") { + return { kind: "lineContinuation", start: index, next: cursor + 1 }; + } + if (escaped === "\r") { + return { + kind: "lineContinuation", + start: index, + next: text[cursor + 1] === "\n" ? cursor + 2 : cursor + 1, + }; + } + return { kind: "char", start: index, next: cursor + 1 }; + } + return { kind: "char", start: index, next: cursor }; + } + if (character === '"') { + if (run > 0) return { kind: "serializerEnd", start: index, next: index + 1 }; + return { kind: "quote", quote: '"', start: index, next: index + 1 }; + } + if (character === "'") { + return { kind: "quote", quote: "'", start: index, next: index + 1 }; + } + if (character === "$" && text[index + 1] === "'") { + return { kind: "ansiOpen", start: index, next: index + 2 }; + } + if (character === "\n" || character === "\r") { + return { kind: "newline", start: index, next: index }; + } + if (character === " " || character === "\t") { + return { kind: "space", start: index, next: index + 1 }; + } + if (COMMAND_SHELL_METACHARACTERS.has(character)) { + return { kind: "metacharacter", start: index, next: index + 1 }; + } + return { kind: "char", start: index, next: index + 1 }; +} + +interface CommandBodyScan { + /** Index just past the closer, or the index the truncated body stopped at. */ + end: number; + closed: boolean; +} + +/** + * Scan the body of a double-quoted part, whether it is a bare `"..."` at depth + * 0 or a `\"...\"` argument at any serialization depth. Escape pairs and + * backslash-newline continuations stay inside; a line break or the end of the + * input truncates the body. + */ +function scanCommandDoubleQuotedBody( + text: string, + index: number, + run: number, +): CommandBodyScan { + let cursor = index; + for (;;) { + const token = readCommandToken(text, cursor, run); + if (token.kind === "quote" && token.quote === '"') { + return { end: token.next, closed: true }; + } + if ( + token.kind === "end" || + token.kind === "newline" || + token.kind === "serializerEnd" + ) { + return { end: token.start, closed: false }; + } + cursor = token.next; + } +} + +/** Scan the body of an ANSI-C `$'...'` part, which has escapes of its own. */ +function scanCommandAnsiQuotedBody( + text: string, + index: number, + run: number, +): CommandBodyScan { + let cursor = index; + for (;;) { + const token = readCommandToken(text, cursor, run); + if (token.kind === "quote" && token.quote === "'") { + return { end: token.next, closed: true }; + } + if ( + token.kind === "end" || + token.kind === "newline" || + token.kind === "serializerEnd" + ) { + return { end: token.start, closed: false }; + } + cursor = token.next; + } +} + +/** + * Scan the body of a single-quoted part. A shell single quote has no escapes, + * so the backslash is an ordinary byte here and only the closing quote, a line + * break, or the enclosing serializer's own delimiter ends the body. + */ +function scanCommandSingleQuotedBody( + text: string, + index: number, + run: number, +): CommandBodyScan { + let cursor = index; + while (cursor < text.length) { + const character = text[cursor]!; + if (character === "'") return { end: cursor + 1, closed: true }; + if (character === "\n" || character === "\r") { + return { end: cursor, closed: false }; + } + if (character === '"' && run > 0) { + let back = cursor - 1; + let runLength = 0; + while (back >= 0 && text[back] === "\\") { + runLength += 1; + back -= 1; + } + const offset = runLength - run; + if (offset < 0 || offset % (run + 1) !== 0) { + return { end: cursor, closed: false }; + } + } + cursor += 1; + } + return { end: cursor, closed: false }; +} + +function scanCommandQuotedBody( + text: string, + index: number, + run: number, + kind: CommandDelimiterKind, +): CommandBodyScan { + if (kind === "single") return scanCommandSingleQuotedBody(text, index, run); + if (kind === "ansi") return scanCommandAnsiQuotedBody(text, index, run); + return scanCommandDoubleQuotedBody(text, index, run); +} + +/** + * Continue a shell word after its first segment. Segments concatenate, so a + * quoted part joins the same argument; whitespace, a metacharacter, a line + * break, or the serializer's own delimiter ends it. A quoted part with no + * closer on the line is a truncated log line and ends the word at the line end. + */ +function scanCommandWordTail(text: string, index: number, run: number): number { + let cursor = index; + for (;;) { + const token = readCommandToken(text, cursor, run); + if ( + token.kind === "end" || + token.kind === "newline" || + token.kind === "space" || + token.kind === "metacharacter" || + token.kind === "serializerEnd" || + token.kind === "lineContinuation" + ) { + return token.start; + } + if (token.kind === "quote" || token.kind === "ansiOpen") { + const kind: CommandDelimiterKind = + token.kind === "ansiOpen" + ? "ansi" + : token.quote === "'" + ? "single" + : "double"; + const body = scanCommandQuotedBody(text, token.next, run, kind); + // A segment with no closer on its line is a cut log line and its bytes + // belong to the word. A cut that leaves the segment empty carries no + // bytes at all, so the word ends before the quote and the quote stays: + // redacting it would only make the rule's own output move again. + if (!body.closed) { + return body.end === token.next ? token.start : body.end; + } + cursor = body.end; + continue; + } + cursor = token.next; + } +} + +// A raw first segment is bounded only by whitespace, a quote, a backtick, or a +// backslash. A metacharacter inside it is a credential byte. The word tail +// takes over at the boundary, which is what lets an unquoted value open on an +// escape pair such as `X-API-Key:\ abc`. +function scanCommandRawSegment(text: string, index: number): number { + let cursor = index; + while (cursor < text.length) { + const character = text[cursor]!; + if ( + character === "\n" || + character === "\r" || + character === " " || + character === "\t" || + character === "\\" || + character === '"' || + character === "'" || + character === "`" + ) { + return cursor; + } + if (character === "$" && text[cursor + 1] === "'") return cursor; + cursor += 1; + } + return cursor; +} + +const COMMAND_SECRET_HEADER_PARAM_NAME_RE = /[^\s"'`\\,=]+/y; +const COMMAND_SECRET_HEADER_PARAM_DOUBLE_RE = /"(?:\\.|[^"\\\r\n])*"/y; +const COMMAND_SECRET_HEADER_PARAM_SINGLE_RE = /'(?:\\.|[^'\\\r\n])*'/y; +const COMMAND_SECRET_HEADER_PARAM_BARE_RE = /[^\s"'`\\,]*/y; +const COMMAND_SECRET_HEADER_PARAM_SEPARATOR_RE = /[ \t]*,[ \t]*/y; + +/** + * Scan one `key=value` authentication parameter. A parameter written as an HTTP + * quoted-string carries quoted-pairs and still rejects a raw line break. A + * continuation parameter must itself carry an `=`, so a bare word after the + * last parameter (`... response="r" status=401`) survives. + */ +function scanCommandAuthParameter(text: string, index: number): number | null { + COMMAND_SECRET_HEADER_PARAM_NAME_RE.lastIndex = index; + if (!COMMAND_SECRET_HEADER_PARAM_NAME_RE.exec(text)) return null; + let cursor = COMMAND_SECRET_HEADER_PARAM_NAME_RE.lastIndex; + if (text[cursor] !== "=") return null; + cursor += 1; + for (const pattern of [ + COMMAND_SECRET_HEADER_PARAM_DOUBLE_RE, + COMMAND_SECRET_HEADER_PARAM_SINGLE_RE, + ]) { + pattern.lastIndex = cursor; + if (pattern.exec(text)) return pattern.lastIndex; + } + COMMAND_SECRET_HEADER_PARAM_BARE_RE.lastIndex = cursor; + COMMAND_SECRET_HEADER_PARAM_BARE_RE.exec(text); + return COMMAND_SECRET_HEADER_PARAM_BARE_RE.lastIndex; +} + +function scanCommandAuthParameterList( + text: string, + index: number, +): number | null { + let end = scanCommandAuthParameter(text, index); + if (end === null) return null; + for (;;) { + COMMAND_SECRET_HEADER_PARAM_SEPARATOR_RE.lastIndex = end; + if (!COMMAND_SECRET_HEADER_PARAM_SEPARATOR_RE.exec(text)) return end; + const next = scanCommandAuthParameter( + text, + COMMAND_SECRET_HEADER_PARAM_SEPARATOR_RE.lastIndex, + ); + if (next === null) return end; + end = next; + } +} + +/** Scan an unquoted value: a raw first segment, then the rest of the word. */ +function scanCommandUnquotedValue( + text: string, + index: number, + run: number, +): number { + let end = scanCommandWordTail(text, scanCommandRawSegment(text, index), run); + if (run === 0) { + const parameters = scanCommandAuthParameterList(text, index); + if (parameters !== null) { + const withTail = scanCommandWordTail(text, parameters, run); + if (withTail > end) end = withTail; + } + } + return end; +} + +/** + * Read the delimiter immediately before the header name: the opener of the + * argument the header sits in, if it has one. + */ +function readCommandHeaderOpener( + text: string, + nameStart: number, +): CommandDelimiter | null { + const previous = text[nameStart - 1]; + if (previous === '"') { + let back = nameStart - 2; + let runLength = 0; + while (back >= 0 && text[back] === "\\") { + runLength += 1; + back -= 1; + } + // An odd run is an escaped quote written by a serializer; an even run is + // escaped backslashes before a quote that is bare at depth 0. + if (runLength % 2 === 1) { + return { kind: "escaped", run: runLength, end: nameStart }; + } + return { kind: "double", run: 0, end: nameStart }; + } + if (previous === "'") { + if (text[nameStart - 2] === "$") { + return { kind: "ansi", run: 0, end: nameStart }; + } + return { kind: "single", run: 0, end: nameStart }; + } + return null; +} + +/** Read the value's own delimiter, the one that follows the colon. */ +function readCommandValueDelimiter( + text: string, + index: number, +): CommandDelimiter | null { + const character = text[index]; + if (character === '"') return { kind: "double", run: 0, end: index + 1 }; + if (character === "'") return { kind: "single", run: 0, end: index + 1 }; + if (character === "$" && text[index + 1] === "'") { + return { kind: "ansi", run: 0, end: index + 2 }; + } + if (character === "\\") { + let cursor = index; + while (text[cursor] === "\\") cursor += 1; + const runLength = cursor - index; + if (text[cursor] === '"' && runLength % 2 === 1) { + return { kind: "escaped", run: runLength, end: cursor + 1 }; + } + } + return null; +} + +function commandDelimiterCloser(delimiter: CommandDelimiter): string { + if (delimiter.kind === "single" || delimiter.kind === "ansi") return "'"; + if (delimiter.kind === "escaped") return `${"\\".repeat(delimiter.run)}"`; + return '"'; +} + +/** + * Continue a quoted body that a second pass over this rule's own output would + * have opened inside the placeholder, and then finish the word. An empty body + * carries no bytes, so it does not extend the word. + */ +function continueCommandQuotedBody( + text: string, + index: number, + run: number, + kind: CommandDelimiterKind, +): number { + const body = scanCommandQuotedBody(text, index, run, kind); + if (!body.closed) return body.end === index ? index : body.end; + return scanCommandWordTail(text, body.end, run); +} + +interface CommandHeaderReading { + end: number; + closer: string; +} + +/** + * Redact the value of every secret-bearing header in `command`. + * + * One placeholder per candidate; the span is the union of the plausible + * readings, so the rule over-redacts where the readings disagree and never + * leaves a credential byte behind. + */ +function redactCommandSecretHeaders( + command: string, + redactedValue: string, +): string { + COMMAND_SECRET_HEADER_CANDIDATE_RE.lastIndex = 0; + let output = ""; + let copied = 0; + let match: RegExpExecArray | null; + while ((match = COMMAND_SECRET_HEADER_CANDIDATE_RE.exec(command)) !== null) { + const nameStart = match.index; + const valueStart = nameStart + match[0].length; + if (nameStart < copied) continue; + const opener = readCommandHeaderOpener(command, nameStart); + // The unquoted reading is declined when the name sits immediately after an + // unescaped quote: there the name is inside a quoted argument the quoted + // reading already owns. + const previous = command[nameStart - 1]; + const allowsUnquoted = + !(previous === '"' || previous === "'") || + command[nameStart - 2] === "\\"; + const first = readCommandToken(command, valueStart, opener?.run ?? 0); + // A value must open with a non-blank character, and an argument's own + // closing delimiter there means the argument is empty. Either way there is + // nothing to hide and the text stays byte for byte. + if ( + first.kind === "end" || + first.kind === "newline" || + first.kind === "space" + ) { + continue; + } + if ( + opener !== null && + first.kind === "quote" && + first.quote === commandDelimiterCloser(opener).slice(-1) + ) { + continue; + } + const delimiter = + opener === null ? readCommandValueDelimiter(command, valueStart) : null; + let bodyStart = delimiter?.end ?? valueStart; + if (delimiter?.kind === "escaped") { + COMMAND_SECRET_HEADER_SCHEME_AT_RE.lastIndex = bodyStart; + if (COMMAND_SECRET_HEADER_SCHEME_AT_RE.exec(command)) { + bodyStart = COMMAND_SECRET_HEADER_SCHEME_AT_RE.lastIndex; + } + } + const readings: CommandHeaderReading[] = []; + const bounded = opener ?? delimiter; + let closerStart: number | null = null; + if (bounded !== null) { + const body = scanCommandQuotedBody( + command, + bodyStart, + bounded.run, + bounded.kind, + ); + if (body.closed) closerStart = body.end - (bounded.run + 1); + readings.push({ + end: body.closed + ? scanCommandWordTail(command, body.end, bounded.run) + : body.end, + closer: body.closed ? commandDelimiterCloser(bounded) : "", + }); + } + // The unquoted readings. With no delimiter to name a layer, every layer + // from depth 0 to depth 4 is plausible and each is scanned. With an escaped + // delimiter, the depth-0 shell reading is always plausible too, because the + // same bytes are an escape pair carrying a literal quote there. A bare + // quote cannot appear in serialized text, so a bare delimiter fixes the + // reading at depth 0 and admits no unquoted alternative of its own. + const unquotedRuns = + bounded === null + ? COMMAND_SECRET_HEADER_READING_RUNS + : (opener === null ? bounded.kind === "escaped" : allowsUnquoted) + ? ([0] as const) + : []; + for (const run of unquotedRuns) { + readings.push({ + end: scanCommandUnquotedValue(command, valueStart, run), + closer: "", + }); + } + // The reopened reading: the depth-0 shell text where the closer this rule + // just found is not a closer at all but an ordinary byte, or the opening + // quote of a further segment of the same word. It is the reading the rule's + // own output presents on a second pass, because the placeholder that + // replaces the value carries no quote, no backslash, and no separator to + // stop an unquoted scan before the closer. Admitting it here is what makes + // one pass a fixpoint: whatever a second pass would consume, the first pass + // has already consumed. + if (closerStart !== null && unquotedRuns.length > 0) { + readings.push({ + end: scanCommandWordTail(command, closerStart, 0), + closer: "", + }); + } + let best: CommandHeaderReading | null = null; + for (const reading of readings) { + if ( + best === null || + reading.end > best.end || + (reading.end === best.end && reading.closer.length > best.closer.length) + ) { + best = reading; + } + } + if (best === null || best.end <= valueStart) continue; + // The fixpoint. A second pass reads this rule's own output, where the + // placeholder has replaced the value: it carries no quote, no backslash and + // no separator, so a scan that crosses it arrives at the text after the + // span in a state the first pass never reached. Consuming now whatever such + // a scan would consume makes one pass settle, which the caller's chain + // requires. Each round starts where the last stopped and the rounds cover + // disjoint text, so the loop stays linear. + for (;;) { + let grown: number = best.end; + const consider = (candidate: number) => { + if (candidate > grown) grown = candidate; + }; + if (bounded !== null) { + if (best.closer === "") { + // With no closer written, a second pass hunts the delimiter's closer + // from inside the placeholder and its body scan spills past the span. + consider( + continueCommandQuotedBody( + command, + best.end, + bounded.run, + bounded.kind, + ), + ); + } else { + consider(scanCommandWordTail(command, best.end, bounded.run)); + } + } + if (unquotedRuns.length > 0) { + if (best.closer === '"' || best.closer === "'") { + consider( + continueCommandQuotedBody( + command, + best.end, + 0, + best.closer === "'" ? "single" : "double", + ), + ); + } else { + consider(scanCommandWordTail(command, best.end, 0)); + } + for (const run of unquotedRuns) { + consider(scanCommandWordTail(command, best.end, run)); + } + } + if (grown <= best.end) break; + best = { end: grown, closer: best.closer }; + } + output += + command.slice(copied, nameStart) + + command.slice(nameStart, bodyStart) + + redactedValue + + best.closer; + copied = best.end; + COMMAND_SECRET_HEADER_CANDIDATE_RE.lastIndex = best.end; + } + return output + command.slice(copied); +} 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 = @@ -276,22 +789,10 @@ export function redactCommandText( redactedValue = REDACTED_COMMAND_TEXT_VALUE, ): string { if (!maybeContainsSecretText(command)) return command; - return command - .replace(COMMAND_AUTHORIZATION_BEARER_RE, `$1${redactedValue}`) - .replace(COMMAND_SECRET_HEADER_RE, (...matchArgs: unknown[]) => { - // 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}`; - }) + return redactCommandSecretHeaders( + command.replace(COMMAND_AUTHORIZATION_BEARER_RE, `$1${redactedValue}`), + redactedValue, + ) .replace(COMMAND_CLI_SECRET_OPTION_RE, `$1${redactedValue}$3`) .replace( COMMAND_ENV_SECRET_ASSIGNMENT_RE, From 5ad32ccc875cae52f85b71961cbd5bd67d5e4ecd Mon Sep 17 00:00:00 2001 From: Michel Tomas Date: Sun, 6 Sep 2026 02:02:05 +0200 Subject: [PATCH 15/21] test(adapter-utils): pin the header scanner's matrices as tables Promote the round-eight review matrices into repository tables so the scanner's contract is checked here rather than in a harness under /tmp. Opener kind by value kind by suffix kind by serialization depth 0 to 3, including the adjacent double-quoted segment whose first byte is a space that leaked at depths 2 and 3. Truncated tails of each quote kind after each root kind, including the escaped-quote tail whose remainder leaked. Even backslash runs of 2, 4, 6 and 8 before a quote, a plain character, a space and a line end. Serialized rows assert the marker is gone, the output is stable, and the output still parses as the JSON string it arrived as and decodes to the redacted shell text. Truncated rows assert removal and stability only: a serialized string cut inside the header argument loses its outer delimiter, which is the contract's known over-redaction and never keeps a credential. Claude-Session: https://claude.ai/code/session_01RYigf3eMFJjey9iKRApPGE --- .../src/command-redaction.test.ts | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/packages/adapter-utils/src/command-redaction.test.ts b/packages/adapter-utils/src/command-redaction.test.ts index 6dbb6f8b53..0fbfac3d0a 100644 --- a/packages/adapter-utils/src/command-redaction.test.ts +++ b/packages/adapter-utils/src/command-redaction.test.ts @@ -825,3 +825,163 @@ describe("redactCommandText header secrets", () => { expect(output).toContain("command failed:"); }); }); + +describe("redactCommandText header scanner matrices", () => { + const R = REDACTED_COMMAND_TEXT_VALUE; + const serialize = (value: string, depth: number) => { + let encoded = value; + for (let index = 0; index < depth; index += 1) encoded = JSON.stringify(encoded); + return encoded; + }; + const parseDepth = (value: string, depth: number) => { + let parsed: unknown = value; + for (let index = 0; index < depth; index += 1) parsed = JSON.parse(parsed as string); + return parsed as string; + }; + + // Opener kind x value kind x suffix kind x serialization depth. Each row is + // one shell word, so every suffix is credential material and the whole word + // collapses to one placeholder. `double-leading-space` is the F1 reproduction + // that leaked at depths 2 and 3 before the scanner. + const roots = [ + ["full-header", (value: string) => `curl -H "X-API-Key: ${value}"`, `curl -H "X-API-Key: ${R}"`], + ["value-only", (value: string) => `curl -H X-API-Key:"${value}"`, `curl -H X-API-Key:"${R}"`], + ] as const; + const suffixes = [ + ["plain", "TAILMARK"], + ["single-quoted", "'TAILMARK'"], + ["double-quoted", '"TAILMARK"'], + ["ansi-c", "$'TAILMARK'"], + ["escape-pair", "\\TAILMARK"], + ["escaped-space", "\\ TAILMARK"], + ["double-leading-space", '" TAILMARK"'], + ] as const; + + it.each(roots)("redacts every %s suffix kind at depths 0-3", (_name, build, redacted) => { + for (const [, suffix] of suffixes) { + const base = `${build("SECRET")}${suffix};echo safe`; + const expectedBase = `${redacted};echo safe`; + for (let depth = 0; depth <= 3; depth += 1) { + const input = serialize(base, depth); + const output = redactCommandText(input); + expect(output).not.toContain("SECRET"); + expect(output).not.toContain("TAILMARK"); + // The serializer's own layers survive the redaction, so the output is + // still the same JSON string it arrived as. + expect(output).toBe(serialize(expectedBase, depth)); + if (depth > 0) expect(parseDepth(output, depth)).toBe(expectedBase); + expect(redactCommandText(output)).toBe(output); + } + } + }); + + // A tail cut by a run log after a closed value. The N6 reproduction is the + // double-quoted tail that carries an escaped quote: the bytes after it belong + // to the same shell word, so the whole tail goes. These rows lose the + // enclosing serializer's delimiter under N4, so they assert removal and + // stability rather than a round trip. + const truncatedRoots = [ + ["closed-escaped", String.raw`curl -H X-API-Key:\"SECRET\"`], + ["closed-double", `curl -H "X-API-Key: SECRET"`], + ["closed-single", `curl -H 'X-API-Key: SECRET'`], + ["closed-ansi", `curl -H $'X-API-Key: SECRET'`], + ["unquoted", `curl -H X-API-Key:SECRET`], + ] as const; + const truncatedTails = [ + ["double", '"TAILMARK'], + ["single", "'TAILMARK",], + ["ansi-c", "$'TAILMARK"], + ["double-escaped-quote", String.raw`"TAIL\"LEAK`], + ] as const; + + it.each(truncatedRoots)("redacts a truncated tail after a %s root at depths 0-2", (_name, root) => { + for (const [, tail] of truncatedTails) { + for (let depth = 0; depth <= 2; depth += 1) { + const output = redactCommandText(serialize(root + tail, depth)); + expect(output).not.toContain("SECRET"); + expect(output).not.toContain("TAILMARK"); + expect(output).not.toContain("LEAK"); + expect(redactCommandText(output)).toBe(output); + } + } + }); + + it("loses a truncated escaped-quote tail after a closed escaped value", () => { + // The N6 reproduction, spelled out. Bash reads the completed line as the + // single word `X-API-Key:"SECRET"TAIL"LEAK`, so `LEAK` is credential text. + const input = String.raw`curl -H X-API-Key:\"SECRET\""TAIL\"LEAK`; + const output = redactCommandText(input); + expect(output).not.toContain("SECRET"); + expect(output).not.toContain("LEAK"); + expect(output).toBe(String.raw`curl -H X-API-Key:\"` + R); + expect(redactCommandText(output)).toBe(output); + }); + + // Even backslash runs are escaped backslashes, so the byte after them is bare. + const evenRunFollowers = [ + ["quote", (run: string) => `curl -H X-API-Key:SECRET${run}"TAILMARK"MORE ;echo safe`, `curl -H X-API-Key:${R} ;echo safe`], + ["plain", (run: string) => `curl -H X-API-Key:SECRET${run}TAILMARK next`, `curl -H X-API-Key:${R} next`], + ] as const; + + it.each(evenRunFollowers)("consumes an even backslash run before a %s at depths 0-2", (_name, build, expectedBase) => { + for (const runLength of [2, 4, 6, 8]) { + const base = build("\\".repeat(runLength)); + for (let depth = 0; depth <= 2; depth += 1) { + const output = redactCommandText(serialize(base, depth)); + expect(output).not.toContain("SECRET"); + expect(output).not.toContain("TAILMARK"); + expect(output).toBe(serialize(expectedBase, depth)); + expect(redactCommandText(output)).toBe(output); + } + } + }); + + it("redacts an even backslash run before a space or a line end at depths 0-2", () => { + // These two followers over-redact: the readings disagree about whether the + // run escapes what comes next, so the union takes the longer span. + for (const runLength of [2, 4, 6, 8]) { + const run = "\\".repeat(runLength); + for (const base of [`curl -H X-API-Key:SECRET${run} next safe`, `curl -H X-API-Key:SECRET${run}`]) { + for (let depth = 0; depth <= 2; depth += 1) { + const output = redactCommandText(serialize(base, depth)); + expect(output).not.toContain("SECRET"); + expect(redactCommandText(output)).toBe(output); + } + } + } + }); + + it("keeps one pass a fixpoint over a serialized argument with a trailing delimiter run", () => { + // A second pass reads the placeholder, which carries no quote and no + // separator to stop an unquoted scan before the closer. The first pass + // consumes whatever that scan would, so the output never moves again. + const inputs = [ + String.raw`"curl -H \"X-API-Key: LEAK\"TAILMARK\" --next \"safe\""`, + String.raw`"curl -H \"X-API-Key: LEAK \"TAILMARK\" --next \"safe\""`, + String.raw`"curl -H X-API-Key:\"LEAK\"TAILMARK\" --next \"safe\""`, + String.raw`foo\\"X-API-Key: a b" bar`, + ]; + for (const input of inputs) { + const once = redactCommandText(input); + expect(once).not.toContain("LEAK"); + expect(redactCommandText(once)).toBe(once); + expect(redactDiagnosticText(once)).toBe(once); + } + }); + + it("keeps an empty truncated segment out of the redaction", () => { + // A cut that leaves a segment with no bytes hides nothing, so the word ends + // before the quote and the quote survives. + expect(redactCommandText('X-API-Key: abc"')).toBe(`X-API-Key: ${R}"`); + expect(redactCommandText(`X-API-Key: abc'`)).toBe(`X-API-Key: ${R}'`); + }); + + it("redacts a value that opens with a blank inside an escaped delimiter", () => { + // The escaped value delimiter owns its body, so a leading space no longer + // leaves the credential in the clear. + const output = redactCommandText(String.raw`X-API-Key:\" abc\" tail`); + expect(output).not.toContain("abc"); + expect(output).toBe(String.raw`X-API-Key:\"` + R + String.raw`\" tail`); + expect(redactCommandText(output)).toBe(output); + }); +}); From 5a4844dd3427a57115bd7f43e213a29361fba9a3 Mon Sep 17 00:00:00 2001 From: Michel Tomas Date: Sun, 6 Sep 2026 02:08:25 +0200 Subject: [PATCH 16/21] docs(adapter-utils): state what the header scanner's fixpoint rests on The growth loop settles because the placeholder carries no byte that stops a scan: no quote, no backslash, no whitespace, no backtick, no metacharacter. Every caller passes `***REDACTED***`, so the assumption holds today, but it is an assumption about a parameter and it belongs next to the loop that depends on it. Claude-Session: https://claude.ai/code/session_01RYigf3eMFJjey9iKRApPGE --- packages/adapter-utils/src/command-redaction.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/adapter-utils/src/command-redaction.ts b/packages/adapter-utils/src/command-redaction.ts index 0ac15a43eb..f89850d5f7 100644 --- a/packages/adapter-utils/src/command-redaction.ts +++ b/packages/adapter-utils/src/command-redaction.ts @@ -700,6 +700,12 @@ function redactCommandSecretHeaders( // a scan would consume makes one pass settle, which the caller's chain // requires. Each round starts where the last stopped and the rounds cover // disjoint text, so the loop stays linear. + // + // This rests on `redactedValue` carrying no quote, backslash, whitespace, + // backtick or shell metacharacter, which is what makes a scan cross it + // rather than stop inside it. Every caller passes `***REDACTED***`. A + // placeholder holding any of those bytes would stop one reading and not + // another, and the rule's output could move on a second pass. for (;;) { let grown: number = best.end; const consider = (candidate: number) => { From e03a30dfacec5a1ce4ea162cdee2ce1fc0dbe954 Mon Sep 17 00:00:00 2001 From: Michel Tomas Date: Sun, 6 Sep 2026 02:45:31 +0200 Subject: [PATCH 17/21] fix(adapter-utils): close two leaks the scanner's reading set missed A single quote and an ANSI-C `$'` are what JSON serialization leaves alone, so unlike a double quote they name no layer. The reading set gave them depth 0 alone, so a serialized double-quoted segment adjacent to the closing quote was read as an escaped quote followed by a word-ending space, and its bytes stayed in the clear at every depth from 1 up. Bash joins them to the header word. Seed the tail after such an argument at every layer the unquoted reading set already carries and take the longest, which is what the union policy asks for. The body stays at depth 0 on purpose: these quotes delimit the same bytes at every layer, and reading the body deeper would take an ANSI-C escape for a plain backslash and close the value early. A backslash-newline is a line continuation. The token reader already named it, but the word scan returned at it as though it were a boundary, so the bytes on the next physical line stayed in the clear after an unquoted value and after a closed quoted argument. The shell removes the pair and joins the lines into one word, so the scan follows it now. Inside a quoted part the handling is unchanged. Claude-Session: https://claude.ai/code/session_01RYigf3eMFJjey9iKRApPGE --- .../adapter-utils/src/command-redaction.ts | 42 +++++++++++++++---- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/packages/adapter-utils/src/command-redaction.ts b/packages/adapter-utils/src/command-redaction.ts index f89850d5f7..714d9fb9f1 100644 --- a/packages/adapter-utils/src/command-redaction.ts +++ b/packages/adapter-utils/src/command-redaction.ts @@ -366,9 +366,16 @@ function scanCommandQuotedBody( /** * Continue a shell word after its first segment. Segments concatenate, so a - * quoted part joins the same argument; whitespace, a metacharacter, a line + * quoted part joins the same argument; whitespace, a metacharacter, a bare line * break, or the serializer's own delimiter ends it. A quoted part with no * closer on the line is a truncated log line and ends the word at the line end. + * + * A backslash-newline is a line continuation, not a boundary: the shell removes + * it and joins the next physical line to the same word, so the scan follows it. + * A serializer writes that continuation either as a run of backslashes and a + * two-byte `\n` escape, which the token reader already carries as an ordinary + * escaped character, or as a run and a raw line break, which arrives here as a + * continuation token at that layer. */ function scanCommandWordTail(text: string, index: number, run: number): number { let cursor = index; @@ -379,8 +386,7 @@ function scanCommandWordTail(text: string, index: number, run: number): number { token.kind === "newline" || token.kind === "space" || token.kind === "metacharacter" || - token.kind === "serializerEnd" || - token.kind === "lineContinuation" + token.kind === "serializerEnd" ) { return token.start; } @@ -643,12 +649,30 @@ function redactCommandSecretHeaders( bounded.kind, ); if (body.closed) closerStart = body.end - (bounded.run + 1); - readings.push({ - end: body.closed - ? scanCommandWordTail(command, body.end, bounded.run) - : body.end, - closer: body.closed ? commandDelimiterCloser(bounded) : "", - }); + const closer = body.closed ? commandDelimiterCloser(bounded) : ""; + if (!body.closed) { + readings.push({ end: body.end, closer }); + } else { + // A bare double quote cannot appear inside serialized text and an + // escaped one carries its layer in its backslash run, so either one + // fixes the reading for the word that follows. A single quote and an + // ANSI-C `$'` are what a serializer leaves alone, so they fix nothing: + // the text after such an argument is shell text or any depth, and every + // one of those readings is scanned. Only the tail is seeded this way. + // The body is read at depth 0 because these quotes delimit the same + // bytes at every layer, and reading the body deeper would take an + // ANSI-C escape for a plain backslash and close the value early. + const tailRuns = + bounded.kind === "single" || bounded.kind === "ansi" + ? COMMAND_SECRET_HEADER_READING_RUNS + : [bounded.run]; + for (const run of tailRuns) { + readings.push({ + end: scanCommandWordTail(command, body.end, run), + closer, + }); + } + } } // The unquoted readings. With no delimiter to name a layer, every layer // from depth 0 to depth 4 is plausible and each is scanned. With an escaped From 831ff2036b1c91f41b57e0be5f1b304e1e15f098 Mon Sep 17 00:00:00 2001 From: Michel Tomas Date: Sun, 6 Sep 2026 02:45:31 +0200 Subject: [PATCH 18/21] test(adapter-utils): exact oracles for the scanner's tables Add the two matrices whose absence hid the leaks: single-quoted and ANSI-C roots, full-header and value-only, with an adjacent serialized double-quoted segment at depths 0 to 3; and line continuations with LF and CRLF after an unquoted value, after each closed quoted argument, inside a double-quoted value, and on the value's first byte. Turn the permissive tables into transformation oracles. The truncated-tail, even-run space and line-end, and fixpoint tables now assert exact output where it is determinate. Rows stay on removal and stability only where the union makes the exact output a policy artifact rather than a fact about the credential: every serialized truncated row, which loses its outer delimiter, and the adjacent segment carrying an escaped quote, where scanning the tail at every layer runs a deeper reading to the line end. Each of those carries the reason inline. Claude-Session: https://claude.ai/code/session_01RYigf3eMFJjey9iKRApPGE --- .../src/command-redaction.test.ts | 163 ++++++++++++++++-- 1 file changed, 150 insertions(+), 13 deletions(-) diff --git a/packages/adapter-utils/src/command-redaction.test.ts b/packages/adapter-utils/src/command-redaction.test.ts index 0fbfac3d0a..6c4a806944 100644 --- a/packages/adapter-utils/src/command-redaction.test.ts +++ b/packages/adapter-utils/src/command-redaction.test.ts @@ -889,13 +889,31 @@ describe("redactCommandText header scanner matrices", () => { ] as const; const truncatedTails = [ ["double", '"TAILMARK'], - ["single", "'TAILMARK",], + ["single", "'TAILMARK"], ["ansi-c", "$'TAILMARK"], ["double-escaped-quote", String.raw`"TAIL\"LEAK`], ] as const; + // Depth-0 output is determinate, so it is pinned exactly, in tail order. A + // tail opening on a bare double quote reopens the closer under the depth-0 + // reading, which wins and drops the closer; a single or ANSI-C tail is + // consumed by the delimiter's own reading, which keeps it. + const truncatedExpectations: Record = { + "closed-escaped": [ + String.raw`curl -H X-API-Key:\"` + R, + String.raw`curl -H X-API-Key:\"` + R + String.raw`\"`, + String.raw`curl -H X-API-Key:\"` + R + String.raw`\"`, + String.raw`curl -H X-API-Key:\"` + R, + ], + "closed-double": Array(4).fill(`curl -H "X-API-Key: ${R}"`), + "closed-single": Array(4).fill(`curl -H 'X-API-Key: ${R}'`), + "closed-ansi": Array(4).fill(`curl -H $'X-API-Key: ${R}'`), + unquoted: Array(4).fill(`curl -H X-API-Key:${R}`), + }; - it.each(truncatedRoots)("redacts a truncated tail after a %s root at depths 0-2", (_name, root) => { - for (const [, tail] of truncatedTails) { + it.each(truncatedRoots)("redacts a truncated tail after a %s root at depths 0-2", (name, root) => { + truncatedTails.forEach(([, tail], index) => { + // Depth 0: exact. + expect(redactCommandText(root + tail)).toBe(truncatedExpectations[name]![index]); for (let depth = 0; depth <= 2; depth += 1) { const output = redactCommandText(serialize(root + tail, depth)); expect(output).not.toContain("SECRET"); @@ -903,7 +921,11 @@ describe("redactCommandText header scanner matrices", () => { expect(output).not.toContain("LEAK"); expect(redactCommandText(output)).toBe(output); } - } + // Depths 1 and 2 stay marker-and-stability only: a serialized string cut + // inside the header argument loses its outer delimiter under N4, so the + // exact output there is a policy artifact of the union, not a fact about + // the credential. + }); }); it("loses a truncated escaped-quote tail after a closed escaped value", () => { @@ -938,10 +960,17 @@ describe("redactCommandText header scanner matrices", () => { it("redacts an even backslash run before a space or a line end at depths 0-2", () => { // These two followers over-redact: the readings disagree about whether the - // run escapes what comes next, so the union takes the longer span. + // run escapes what comes next, so the union takes the longer span. Depth 0 + // is determinate and pinned exactly; the serialized rows drop the outer + // delimiter under N4, so the exact output there is a union artifact. for (const runLength of [2, 4, 6, 8]) { const run = "\\".repeat(runLength); - for (const base of [`curl -H X-API-Key:SECRET${run} next safe`, `curl -H X-API-Key:SECRET${run}`]) { + const followers = [ + [`curl -H X-API-Key:SECRET${run} next safe`, `curl -H X-API-Key:${R} safe`], + [`curl -H X-API-Key:SECRET${run}`, `curl -H X-API-Key:${R}`], + ] as const; + for (const [base, expected] of followers) { + expect(redactCommandText(base)).toBe(expected); for (let depth = 0; depth <= 2; depth += 1) { const output = redactCommandText(serialize(base, depth)); expect(output).not.toContain("SECRET"); @@ -955,20 +984,128 @@ describe("redactCommandText header scanner matrices", () => { // A second pass reads the placeholder, which carries no quote and no // separator to stop an unquoted scan before the closer. The first pass // consumes whatever that scan would, so the output never moves again. - const inputs = [ - String.raw`"curl -H \"X-API-Key: LEAK\"TAILMARK\" --next \"safe\""`, - String.raw`"curl -H \"X-API-Key: LEAK \"TAILMARK\" --next \"safe\""`, - String.raw`"curl -H X-API-Key:\"LEAK\"TAILMARK\" --next \"safe\""`, - String.raw`foo\\"X-API-Key: a b" bar`, - ]; - for (const input of inputs) { + const rows = [ + [ + String.raw`"curl -H \"X-API-Key: LEAK\"TAILMARK\" --next \"safe\""`, + String.raw`"curl -H \"X-API-Key: ` + R + String.raw`\""`, + ], + [ + String.raw`"curl -H \"X-API-Key: LEAK \"TAILMARK\" --next \"safe\""`, + String.raw`"curl -H \"X-API-Key: ` + R + String.raw`\""`, + ], + [ + String.raw`"curl -H X-API-Key:\"LEAK\"TAILMARK\" --next \"safe\""`, + String.raw`"curl -H X-API-Key:\"` + R + String.raw`\""`, + ], + [String.raw`foo\\"X-API-Key: a b" bar`, String.raw`foo\\"X-API-Key: ` + R], + ] as const; + for (const [input, expected] of rows) { const once = redactCommandText(input); expect(once).not.toContain("LEAK"); + expect(once).toBe(expected); expect(redactCommandText(once)).toBe(once); expect(redactDiagnosticText(once)).toBe(once); } }); + // A single quote and an ANSI-C `$'` are what JSON serialization leaves alone, + // so they name no layer: an adjacent double-quoted segment after such an + // argument is a further segment of the same shell word at every depth. Bash + // reads each decoded row as one word, `X-API-Key: SECRET TAILMARK`. + const layerInvariantRoots = [ + ["single-quoted full-header", `curl -H 'X-API-Key: SECRET'`, `curl -H 'X-API-Key: ${R}'`], + ["single-quoted value-only", `curl -H X-API-Key:'SECRET'`, `curl -H X-API-Key:'${R}'`], + ["ansi-c full-header", `curl -H $'X-API-Key: SECRET'`, `curl -H $'X-API-Key: ${R}'`], + ["ansi-c value-only", `curl -H X-API-Key:$'SECRET'`, `curl -H X-API-Key:$'${R}'`], + ] as const; + const adjacentDoubleQuoted = ['"TAILMARK"', '" TAILMARK"'] as const; + + it.each(layerInvariantRoots)( + "consumes a serialized adjacent segment after a %s argument at depths 0-3", + (_name, root, redacted) => { + for (const suffix of adjacentDoubleQuoted) { + const base = `${root}${suffix} --next safe`; + const expectedBase = `${redacted} --next safe`; + for (let depth = 0; depth <= 3; depth += 1) { + const output = redactCommandText(serialize(base, depth)); + expect(output).not.toContain("SECRET"); + expect(output).not.toContain("TAILMARK"); + expect(output).toBe(serialize(expectedBase, depth)); + if (depth > 0) expect(parseDepth(output, depth)).toBe(expectedBase); + expect(redactCommandText(output)).toBe(output); + } + } + }, + ); + + it.each(layerInvariantRoots)( + "consumes an adjacent segment carrying an escaped quote after a %s argument", + (_name, root, redacted) => { + const base = `${root}${String.raw`"TAIL\"MARK"`} --next safe`; + // Depth 0 is determinate. At depth 1 and deeper the tail is scanned at + // every layer, because a single quote names none, and a deeper layer + // reads this shape as an argument that never closes, so the union runs to + // the line end. That is a union artifact, not a fact about the + // credential, so these rows assert removal and stability only. Seeding + // fewer layers is not available: a tail that opens on plain bytes before + // its serialized quote leaks under a depth-0-only tail, which + // `808854d9a` demonstrates on + // `"curl -H 'X-API-Key: SECRET'TAIL\" MORE\" --next safe"`. + expect(redactCommandText(base)).toBe(`${redacted} --next safe`); + for (let depth = 0; depth <= 3; depth += 1) { + const output = redactCommandText(serialize(base, depth)); + expect(output).not.toContain("SECRET"); + expect(output).not.toContain("TAILMARK"); + expect(output).not.toContain("MARK"); + expect(redactCommandText(output)).toBe(output); + } + }, + ); + + it("consumes a serialized adjacent segment that opens after plain bytes", () => { + // The tail's serialized quote is not its first byte, so the layer cannot be + // read off the tail. Scanning every layer is what covers it. + const input = JSON.stringify( + `curl -H 'X-API-Key: SECRET'TAIL" MORE" --next safe`, + ); + const output = redactCommandText(input); + expect(output).not.toContain("SECRET"); + expect(output).not.toContain("MORE"); + expect(output).toBe(JSON.stringify(`curl -H 'X-API-Key: ${R}' --next safe`)); + expect(redactCommandText(output)).toBe(output); + }); + + // A backslash-newline is a line continuation: the shell removes it and joins + // the next physical line to the same word, so the credential runs on past it. + const lineContinuations = [ + ["an unquoted value", (nl: string) => `curl -H X-API-Key:SECRET\\${nl}TAILMARK --next safe`, `curl -H X-API-Key:${R} --next safe`], + ["a closed single-quoted argument", (nl: string) => `curl -H 'X-API-Key: SECRET'\\${nl}TAILMARK --next safe`, `curl -H 'X-API-Key: ${R}' --next safe`], + ["a closed double-quoted argument", (nl: string) => `curl -H "X-API-Key: SECRET"\\${nl}TAILMARK --next safe`, `curl -H "X-API-Key: ${R}" --next safe`], + ["a closed ANSI-C argument", (nl: string) => `curl -H $'X-API-Key: SECRET'\\${nl}TAILMARK --next safe`, `curl -H $'X-API-Key: ${R}' --next safe`], + ["a double-quoted value", (nl: string) => `curl -H "X-API-Key: SECRET\\${nl}TAILMARK" --next safe`, `curl -H "X-API-Key: ${R}" --next safe`], + ["the value's first byte", (nl: string) => `curl -H X-API-Key:\\${nl}SECRET --next safe`, `curl -H X-API-Key:${R} --next safe`], + ] as const; + + it.each(lineContinuations)( + "follows a line continuation after %s", + (_name, build, expected) => { + for (const newline of ["\n", "\r\n"]) { + expect(redactCommandText(build(newline))).toBe(expected); + expect(redactCommandText(expected)).toBe(expected); + // A serializer writes the continuation as a backslash run and a + // two-byte `\n` escape, which the reader carries as an escaped + // character, so the word still runs on. The outer delimiter is lost + // under N4, so these rows assert removal and stability only. + for (let depth = 1; depth <= 2; depth += 1) { + const output = redactCommandText(serialize(build(newline), depth)); + expect(output).not.toContain("SECRET"); + expect(output).not.toContain("TAILMARK"); + expect(redactCommandText(output)).toBe(output); + } + } + }, + ); + it("keeps an empty truncated segment out of the redaction", () => { // A cut that leaves a segment with no bytes hides nothing, so the word ends // before the quote and the quote survives. From cd727dd8d514240b405cc3a4941d2bd1dc885908 Mon Sep 17 00:00:00 2001 From: Michel Tomas Date: Sun, 6 Sep 2026 03:13:31 +0200 Subject: [PATCH 19/21] fix(adapter-utils): let a quoted part keep a line break its closer spans A raw line break inside a quoted part is an ordinary byte the shell keeps in the same word, but every body scanner returned before one, so a header value whose closing quote arrived on a later physical line left the rest of the credential in the clear. Single quotes were the strongest case, since the backslash is an ordinary byte there and the very next quote closes the part wherever it falls, but the double-quoted and ANSI-C bodies had it too. A body may now cross a line break, and only when its closer really arrives. Each scanner remembers the first break it crossed and falls back to it when no closer is found, so an argument cut mid-line by a run log still ends where its line does, and nothing outside quotes changes: a bare line break still ends the word. A line break carrying a backslash run in front of it is a continuation at whatever layer wrote that run. The reader already names the depth-0 spelling; a deeper layer spells the same pair as a run this reading may divide evenly, and a text that lost a layer of escaping on the break alone spells it as a run this reading cannot place at all. The shell joins the lines in every one of them, so the word scan does too. This widens redaction where a stray quote in a value finds a closing quote on a later line: the span can now reach that quote rather than stopping at the line. With no such quote the behaviour is unchanged. Claude-Session: https://claude.ai/code/session_01RYigf3eMFJjey9iKRApPGE --- .../adapter-utils/src/command-redaction.ts | 103 +++++++++++------- 1 file changed, 63 insertions(+), 40 deletions(-) diff --git a/packages/adapter-utils/src/command-redaction.ts b/packages/adapter-utils/src/command-redaction.ts index 714d9fb9f1..2201e071ef 100644 --- a/packages/adapter-utils/src/command-redaction.ts +++ b/packages/adapter-utils/src/command-redaction.ts @@ -268,61 +268,70 @@ interface CommandBodyScan { closed: boolean; } +// A quote opens a part, and a raw line break inside it is an ordinary byte the +// shell keeps in the same word. So a body may cross a line break, but only when +// its closer really arrives: an argument whose closing quote never comes is a +// run log cut mid-line, and that body still ends where its line does. Both +// scanners below therefore remember the first line break they crossed and fall +// back to it when no closer is found. Outside quotes nothing changes: a raw +// line break still ends the word. +function advancePastNewline(text: string, index: number): number { + return text[index] === "\r" && text[index + 1] === "\n" ? index + 2 : index + 1; +} + /** * Scan the body of a double-quoted part, whether it is a bare `"..."` at depth - * 0 or a `\"...\"` argument at any serialization depth. Escape pairs and - * backslash-newline continuations stay inside; a line break or the end of the - * input truncates the body. + * 0 or a `\"...\"` argument at any serialization depth, or of an ANSI-C + * `$'...'` part, which has escapes of its own. Escape pairs and + * backslash-newline continuations stay inside, and a raw line break stays + * inside when the closer arrives later. */ +function scanCommandEscapingQuotedBody( + text: string, + index: number, + run: number, + closer: string, +): CommandBodyScan { + let cursor = index; + let firstNewline: number | null = null; + for (;;) { + const token = readCommandToken(text, cursor, run); + if (token.kind === "quote" && token.quote === closer) { + return { end: token.next, closed: true }; + } + if (token.kind === "end" || token.kind === "serializerEnd") { + return { end: firstNewline ?? token.start, closed: false }; + } + if (token.kind === "newline") { + if (firstNewline === null) firstNewline = token.start; + cursor = advancePastNewline(text, token.start); + continue; + } + cursor = token.next; + } +} + function scanCommandDoubleQuotedBody( text: string, index: number, run: number, ): CommandBodyScan { - let cursor = index; - for (;;) { - const token = readCommandToken(text, cursor, run); - if (token.kind === "quote" && token.quote === '"') { - return { end: token.next, closed: true }; - } - if ( - token.kind === "end" || - token.kind === "newline" || - token.kind === "serializerEnd" - ) { - return { end: token.start, closed: false }; - } - cursor = token.next; - } + return scanCommandEscapingQuotedBody(text, index, run, '"'); } -/** Scan the body of an ANSI-C `$'...'` part, which has escapes of its own. */ function scanCommandAnsiQuotedBody( text: string, index: number, run: number, ): CommandBodyScan { - let cursor = index; - for (;;) { - const token = readCommandToken(text, cursor, run); - if (token.kind === "quote" && token.quote === "'") { - return { end: token.next, closed: true }; - } - if ( - token.kind === "end" || - token.kind === "newline" || - token.kind === "serializerEnd" - ) { - return { end: token.start, closed: false }; - } - cursor = token.next; - } + return scanCommandEscapingQuotedBody(text, index, run, "'"); } /** * Scan the body of a single-quoted part. A shell single quote has no escapes, - * so the backslash is an ordinary byte here and only the closing quote, a line - * break, or the enclosing serializer's own delimiter ends the body. + * so a backslash is an ordinary byte here and the very next quote closes the + * part, wherever it falls. Only the enclosing serializer's own delimiter, the + * end of the input, or a missing closer ends the body another way. */ function scanCommandSingleQuotedBody( text: string, @@ -330,11 +339,14 @@ function scanCommandSingleQuotedBody( run: number, ): CommandBodyScan { let cursor = index; + let firstNewline: number | null = null; while (cursor < text.length) { const character = text[cursor]!; if (character === "'") return { end: cursor + 1, closed: true }; if (character === "\n" || character === "\r") { - return { end: cursor, closed: false }; + if (firstNewline === null) firstNewline = cursor; + cursor = advancePastNewline(text, cursor); + continue; } if (character === '"' && run > 0) { let back = cursor - 1; @@ -345,12 +357,12 @@ function scanCommandSingleQuotedBody( } const offset = runLength - run; if (offset < 0 || offset % (run + 1) !== 0) { - return { end: cursor, closed: false }; + return { end: firstNewline ?? cursor, closed: false }; } } cursor += 1; } - return { end: cursor, closed: false }; + return { end: firstNewline ?? cursor, closed: false }; } function scanCommandQuotedBody( @@ -381,9 +393,20 @@ function scanCommandWordTail(text: string, index: number, run: number): number { let cursor = index; for (;;) { const token = readCommandToken(text, cursor, run); + if (token.kind === "newline") { + // A line break carrying a backslash in front of it is a continuation at + // whatever layer wrote that run, so the word runs on. The reader already + // names the depth-0 spelling, one backslash, a continuation token; a + // deeper layer spells the same pair as a run whose length this reading + // may divide evenly, and a text that lost a layer of escaping on the + // break alone spells it as a run this reading cannot place at all. Both + // arrive here, and the shell joins the lines in every one of them. + if (text[token.start - 1] !== "\\") return token.start; + cursor = advancePastNewline(text, token.start); + continue; + } if ( token.kind === "end" || - token.kind === "newline" || token.kind === "space" || token.kind === "metacharacter" || token.kind === "serializerEnd" From 6610e7282550a5dde9510d8ca134ba4f7623bad3 Mon Sep 17 00:00:00 2001 From: Michel Tomas Date: Sun, 6 Sep 2026 03:13:31 +0200 Subject: [PATCH 20/21] test(adapter-utils): pin multi-line quoted bodies and their bound Exact output for a single-quoted and an ANSI-C body carrying a continuation and carrying a raw line break, a value-only single-quoted body, and a double-quoted body closed two lines later, with LF and CRLF at depths 0 to 3, each checked to still parse back through its serialization layers. The bound is pinned beside them: a quoted body whose closer never arrives still ends at its own line and the next line survives. One more row covers a line break whose backslash run belongs to a layer the reading cannot place. Claude-Session: https://claude.ai/code/session_01RYigf3eMFJjey9iKRApPGE --- .../src/command-redaction.test.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/packages/adapter-utils/src/command-redaction.test.ts b/packages/adapter-utils/src/command-redaction.test.ts index 6c4a806944..ca2273ecc9 100644 --- a/packages/adapter-utils/src/command-redaction.test.ts +++ b/packages/adapter-utils/src/command-redaction.test.ts @@ -1106,6 +1106,59 @@ describe("redactCommandText header scanner matrices", () => { }, ); + // A quoted part keeps a raw line break in the same shell word when its + // closer arrives on a later line. Bash reads each of these as one argument. + const multilineBodies = [ + ["a single-quoted body with a continuation", (nl: string) => `curl -H 'X-API-Key: SECRET\\${nl}TAILMARK' --next safe`, `curl -H 'X-API-Key: ${R}' --next safe`], + ["a single-quoted body with a raw line break", (nl: string) => `curl -H 'X-API-Key: SECRET${nl}TAILMARK' --next safe`, `curl -H 'X-API-Key: ${R}' --next safe`], + ["an ANSI-C body with a continuation", (nl: string) => `curl -H $'X-API-Key: SECRET\\${nl}TAILMARK' --next safe`, `curl -H $'X-API-Key: ${R}' --next safe`], + ["an ANSI-C body with a raw line break", (nl: string) => `curl -H $'X-API-Key: SECRET${nl}TAILMARK' --next safe`, `curl -H $'X-API-Key: ${R}' --next safe`], + ["a value-only single-quoted body", (nl: string) => `curl -H X-API-Key:'SECRET${nl}TAILMARK' --next safe`, `curl -H X-API-Key:'${R}' --next safe`], + ["a double-quoted body closed two lines later", (nl: string) => `curl -H "X-API-Key: SECRET${nl}MID${nl}TAILMARK" --next safe`, `curl -H "X-API-Key: ${R}" --next safe`], + ] as const; + + it.each(multilineBodies)("crosses a line break inside %s", (_name, build, expected) => { + for (const newline of ["\n", "\r\n"]) { + for (let depth = 0; depth <= 3; depth += 1) { + const output = redactCommandText(serialize(build(newline), depth)); + expect(output).not.toContain("SECRET"); + expect(output).not.toContain("TAILMARK"); + expect(output).not.toContain("MID"); + expect(output).toBe(serialize(expected, depth)); + if (depth > 0) expect(parseDepth(output, depth)).toBe(expected); + expect(redactCommandText(output)).toBe(output); + } + } + }); + + it("still ends an unterminated quoted body at its own line", () => { + // A closing quote that never arrives is a run log cut mid-line, so the + // value stops where the line does and the next line survives. This is the + // bound on the crossing above: without a closer, nothing changes. + expect(redactCommandText(`curl -H 'X-API-Key: SECRET\nsecond line`)).toBe( + `curl -H 'X-API-Key: ${R}\nsecond line`, + ); + expect(redactCommandText(`curl -H "X-API-Key: SECRET\nsecond line`)).toBe( + `curl -H "X-API-Key: ${R}\nsecond line`, + ); + expect(redactCommandText(`curl -H $'X-API-Key: SECRET\nsecond line`)).toBe( + `curl -H $'X-API-Key: ${R}\nsecond line`, + ); + }); + + it("follows a line break carrying a backslash run from a deeper layer", () => { + // A text that lost one layer of escaping on the break alone spells the + // continuation as a run this reading cannot place. The shell still joins + // the lines, so the word runs on. + const slash = (count: number) => "\\".repeat(count); + const input = + `curl -H X-API-Key:${slash(7)}"SECRET${slash(7)}"${slash(6)}\nTAILMARK --next safe`; + const output = redactCommandText(input); + expect(output).not.toContain("SECRET"); + expect(output).not.toContain("TAILMARK"); + expect(redactCommandText(output)).toBe(output); + }); + it("keeps an empty truncated segment out of the redaction", () => { // A cut that leaves a segment with no bytes hides nothing, so the word ends // before the quote and the quote survives. From 1aae093b51c9ace27298634166edac0995e9e8a3 Mon Sep 17 00:00:00 2001 From: Michel Tomas Date: Sun, 6 Sep 2026 20:06:14 +0200 Subject: [PATCH 21/21] 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 --- .../src/command-redaction.test.ts | 53 ++++++++++++++++++- .../adapter-utils/src/command-redaction.ts | 18 ++++--- 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/packages/adapter-utils/src/command-redaction.test.ts b/packages/adapter-utils/src/command-redaction.test.ts index ca2273ecc9..763344256c 100644 --- a/packages/adapter-utils/src/command-redaction.test.ts +++ b/packages/adapter-utils/src/command-redaction.test.ts @@ -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`, diff --git a/packages/adapter-utils/src/command-redaction.ts b/packages/adapter-utils/src/command-redaction.ts index 2201e071ef..b071068b41 100644 --- a/packages/adapter-utils/src/command-redaction.ts +++ b/packages/adapter-utils/src/command-redaction.ts @@ -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 ` 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,