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:
parent
06c0e883fa
commit
e8aaf25d2c
|
|
@ -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:");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue