diff --git a/src/mitm/maskSecrets.ts b/src/mitm/maskSecrets.ts index f0e9f90865..e1695d36dd 100644 --- a/src/mitm/maskSecrets.ts +++ b/src/mitm/maskSecrets.ts @@ -6,14 +6,20 @@ * Pattern sources: plano 11 §4.8 (origin: llm-interceptor proxy.py:310) */ -// Pre-compiled regex patterns — ORDER IS SIGNIFICANT (BEARER must run first) -const BEARER = /(authorization:\s*Bearer\s+)[A-Za-z0-9._-]+/gi; +// Pre-compiled regex patterns — ORDER IS SIGNIFICANT (BEARER must run first). +// BEARER matches the token after a standalone "Bearer " — NOT only after a +// literal "authorization:" prefix. sanitizeHeaders() masks header *values* +// ("Bearer ") with the key already stripped, so a prefix-anchored regex +// never fired there and short/opaque-but-<40 tokens leaked into the inspector +// (found by the AgentBridge live capture). The char class is bounded + linear +// (no nested quantifiers) to stay ReDoS-safe. +const BEARER = /(\bBearer\s+)[A-Za-z0-9._~+/-]+=*/gi; const SK_KEY = /\b(sk|ak|pk)-[A-Za-z0-9_-]{16,}\b/g; const LONG_TOKEN = /\b[A-Za-z0-9_-]{40,}\b/g; /** * Mask secrets in a string value. - * - Bearer tokens: replaces token after "Bearer " with "***" + * - Bearer tokens: replaces the token after any "Bearer " with "***" * - sk-/ak-/pk- keys: keeps first 6 chars + last 2 chars * - Long opaque tokens (≥40 chars): keeps first 4 chars + last 2 chars */ diff --git a/tests/unit/mitm-masksecrets.test.ts b/tests/unit/mitm-masksecrets.test.ts index 4a5c57ac36..f863c0d3bb 100644 --- a/tests/unit/mitm-masksecrets.test.ts +++ b/tests/unit/mitm-masksecrets.test.ts @@ -61,3 +61,28 @@ test("maskSecret — short sk- key below 16 chars is NOT masked", () => { const shortKey = "sk-shortkey"; assert.equal(maskSecret(shortKey), shortKey); }); + +// Regression: sanitizeHeaders() masks header *values* ("Bearer " with the +// "authorization:" key already stripped). The previous prefix-anchored BEARER +// regex never fired there, so short/opaque-<40 Bearer tokens leaked into the +// Traffic Inspector (found by the AgentBridge live capture). +test("maskSecret — Bearer token in a bare header value (no authorization: prefix) is masked", () => { + const result = maskSecret("Bearer sk-secret-TESTE"); + assert.equal(result, "Bearer ***"); +}); + +test("maskSecret — a short opaque Bearer token is still masked", () => { + assert.equal(maskSecret("Bearer abc123"), "Bearer ***"); +}); + +test("maskSecret — a realistic Google OAuth Bearer value is masked whole", () => { + const result = maskSecret("Bearer ya29.a0AfH6SMxLONGTOKEN_1234567890-abcdefghij"); + assert.equal(result, "Bearer ***"); + assert.ok(!result.includes("LONGTOKEN"), "no part of the token should survive"); +}); + +test("maskSecret — 'authorization: Bearer ' still masks (no regression)", () => { + const result = maskSecret("authorization: Bearer sk-proj-abcdefghijklmnop"); + assert.ok(result.startsWith("authorization: Bearer ***"), `got: ${result}`); + assert.ok(!result.includes("abcdefghijklmnop")); +});