fix(adapter-utils): redact header-style secrets in command text

The command redaction covered `Authorization: Bearer <value>`, 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: <token>"`
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 <value>` produces the same text as
before. `Authorization: Basic <value>` 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
This commit is contained in:
Michel Tomas 2026-09-03 20:14:56 +02:00
parent 06c0e883fa
commit e8aaf25d2c
No known key found for this signature in database
GPG Key ID: 0878846631FFD1E0
2 changed files with 119 additions and 0 deletions

View File

@ -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:");
});
});

View File

@ -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 <value>` only, so a `curl -H "X-API-Key:
// <value>"` command kept the credential in clear. This rule redacts the value
// of any header whose name contains an api-key, token, secret, or auth hint.
//
// 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 <value>` produces the same output as before.
//
// The header name and the colon match on one line only, and the value ends at
// the first quote, backslash, or whitespace. The rule therefore stops at the
// end of one header argument and never runs past it. Excluding the backslash
// also keeps the rule off an escaped-quote opener such as
// `Authorization: \"Bearer ...\"` in a serialized diagnostic, which the
// caller's own authorization rules already redact.
const COMMAND_SECRET_HEADER_NAME_PATTERN = String.raw`[A-Za-z0-9_-]*(?:api[-_]?key|token|secret|auth)[A-Za-z0-9_-]*`;
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,