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
This commit is contained in:
parent
e8aaf25d2c
commit
3399fc784f
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 <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.
|
||||
// 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 <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_-]*`;
|
||||
// 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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue