From 651a4dfcb7aedc2054a3e7203da39ad851ca4f97 Mon Sep 17 00:00:00 2001 From: connabyss Date: Mon, 2 Nov 2026 19:45:58 +0000 Subject: [PATCH] fix(debug): redact vendor credential headers and fields Debug network evidence masked a header only when its whole name was in the fixed secret list, so common vendor credential headers kept their values: GitLab `PRIVATE-TOKEN`, Google `x-goog-api-key`, AWS `x-amz-security-token`, Azure `Ocp-Apim-Subscription-Key`, Vault `x-vault-token`, `x-access-token` and `x-client-secret`. JSON body fields such as `private_token` were kept the same way. A name whose last part is token, api key, secret, subscription key or password is now treated as secret too. Assisted-by: Claude Code --- .../src/debug/__tests__/redact.test.ts | 34 ++++++++++++++++++- apps/extension/src/debug/redact.ts | 18 ++++++++-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/apps/extension/src/debug/__tests__/redact.test.ts b/apps/extension/src/debug/__tests__/redact.test.ts index c2668661e..6a1c4d353 100644 --- a/apps/extension/src/debug/__tests__/redact.test.ts +++ b/apps/extension/src/debug/__tests__/redact.test.ts @@ -2,9 +2,41 @@ import { describe, expect, it } from "vitest"; import { jsonPointer, parseJsonSource } from "../json-source"; -import { BODY_CHARS, redactBody, redactRequestUrl } from "../redact"; +import { BODY_CHARS, redactBody, redactHeaders, redactRequestUrl } from "../redact"; describe("source-preserving evidence redaction", () => { + it.each([ + "PRIVATE-TOKEN", + "x-goog-api-key", + "x-amz-security-token", + "x-access-token", + "X-Vault-Token", + "Ocp-Apim-Subscription-Key", + "x-client-secret", + ])("redacts the vendor credential header %s", (name) => { + expect(redactHeaders({ [name]: "BSK_SYNTHETIC_SECRET_7264" })).toEqual({ + [name.toLowerCase()]: "[redacted]", + }); + }); + + it("redacts vendor credential fields in JSON bodies", () => { + const text = + '{"private_token":"BSK_SYNTHETIC_SECRET_7264","x_goog_api_key":"BSK_SYNTHETIC_SECRET_7264","page":2}'; + expect(redactBody(text, "application/json")).toMatchObject({ + text: '{"private_token":"[redacted]","x_goog_api_key":"[redacted]","page":2}', + redacted: true, + }); + }); + + it.each([ + "content-type", + "x-request-id", + "idempotency-key", + "x-tokenizer", + ])("keeps the ordinary header %s", (name) => { + expect(redactHeaders({ [name]: "value-7264" })).toEqual({ [name]: "value-7264" }); + }); + it.each([ 'type="password"', 'type="password"', diff --git a/apps/extension/src/debug/redact.ts b/apps/extension/src/debug/redact.ts index 98f0382b7..960d030e1 100644 --- a/apps/extension/src/debug/redact.ts +++ b/apps/extension/src/debug/redact.ts @@ -7,11 +7,25 @@ const MASK = "[redacted]"; export const BODY_CHARS = 64 * 1024; export const URL_CHARS = 2048; +/** + * Vendor credential names that name their secret in their last part: GitLab `private-token` (and the + * `private_token` query parameter), Google `x-goog-api-key`, AWS `x-amz-security-token`, Azure + * `ocp-apim-subscription-key`, Vault `x-vault-token`. + */ +const SECRET_HEADER_SUFFIX = + /(?:^|[_-])(?:token|api[_-]?key|apikey|secret|subscription[_-]key|password|passwd)$/i; + +function secretHeader(name: string): boolean { + return SECRET.test(name) || SECRET_HEADER_SUFFIX.test(name); +} + function secretField(key: string): boolean { return key .replace(/([a-z0-9])([A-Z])/g, "$1_$2") .split(/[.[\]]+/) - .some((part) => SECRET.test(part) || /(?:^|[_-])(?:password|passwd|pwd)(?:[_-]|$)/i.test(part)); + .some( + (part) => secretHeader(part) || /(?:^|[_-])(?:password|passwd|pwd)(?:[_-]|$)/i.test(part), + ); } const HTML_FIELD_SEPARATORS: Record = { @@ -97,7 +111,7 @@ export function redactHeaderEvidence(value: unknown): { let remaining = 4 * 1024; for (const [key, raw] of entries.slice(0, 80)) { const name = key.slice(0, 128).toLowerCase(); - const secret = SECRET.test(name); + const secret = secretHeader(name); const original = String(raw); const redacted = secret ? MASK : redactText(original, 2048); const limit = Math.max(0, Math.min(2048, remaining - name.length));