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,