fix(security): add Groq, xAI and OpenAI-compatible key shapes to the credential catalog

GHSA-r4q7-7f24-m29p. `CREDENTIAL_PATTERNS` (open-sse/utils/credentialPatterns.ts)
is the single catalog iterated in order by both the opt-in credential-masker
guardrail and the public error sanitizer. It had no entry for Groq (`gsk_`) or
xAI (`xai-`), and only knew the exact 48-char OpenAI `sk-` form.

Measured on the release tip before this change:

| Shape                        | public sanitizer | guardrail |
|------------------------------|------------------|-----------|
| Groq  gsk_ + 52              | LEAK             | LEAK      |
| xAI   xai- + 80              | LEAK             | LEAK      |
| DeepSeek sk- + 32 hex        | redacted         | LEAK      |
| sk- + 20/36/40/51 (not 48)   | redacted         | LEAK      |

The public path already caught every `sk-` shape through STRONG_CREDENTIAL_TOKEN,
so the advisory's "both layers" framing only holds for gsk_/xai-; for the sk-
family the exposure was the guardrail.

Adds `groq` and `xai` after `anthropic_alt`, and a generic `openai_compatible`
`sk-` fallback as the LAST entry. Ordering matters: both consumers replace as
they iterate, so `openai_proj`, `openai` and `anthropic*` stamp their specific
label first and the fallback only sees shapes nothing else claimed. The
lookbehind mirrors STRONG_CREDENTIAL_TOKEN so `risk-…`-style words do not match.
All three regexes are a fixed prefix plus one bounded character class — linear,
no nested quantifiers.

Tests are red-first: the new guardrail cases (bare / sentence / JSON-body
contexts per shape, plus label-ordering and negative cases) and the catalog
coverage array in error-sensitive-redaction both failed on the tip.

Follow-ups deliberately left out of scope: `tskey-auth-` (Tailscale) was never in
the catalog, and the guardrail does not decode `\uXXXX` escapes the way the
public path does.
This commit is contained in:
diegosouzapw
2026-09-15 12:17:51 -03:00
parent 58f88a83e4
commit 1073fcc189
4 changed files with 196 additions and 0 deletions

View File

@@ -0,0 +1 @@
- **fix(security):** redact Groq (`gsk_…`), xAI (`xai-…`) and every OpenAI-compatible `sk-…` key shape (DeepSeek 32-hex, Moonshot/Kimi, Together, …) in error bodies and the opt-in credential-masker guardrail — the catalog only knew the exact 48-char OpenAI form, so those keys passed through the guardrail verbatim and `gsk_`/`xai-` also reached public error responses (GHSA-r4q7-7f24-m29p)

View File

@@ -18,6 +18,12 @@ export const CREDENTIAL_PATTERNS: CredentialPattern[] = [
regex: /sk-ant-[A-Za-z0-9_-]{20,}/g,
replacement: "[REDACTED:anthropic]",
},
// GHSA-r4q7-7f24-m29p: Groq (`gsk_` + 52) and xAI (`xai-` + 80) had no entry, so both
// the opt-in guardrail and the public error sanitizer echoed them verbatim. Lower bound
// only, for the same reason as `google` below — an error body that over-redacts a
// look-alike costs nothing; one that under-redacts leaks a credential.
{ name: "groq", regex: /\bgsk_[A-Za-z0-9]{20,}/g, replacement: "[REDACTED:groq]" },
{ name: "xai", regex: /\bxai-[A-Za-z0-9]{20,}/g, replacement: "[REDACTED:xai]" },
// {20,} rather than the exact {35} of a standard 39-char Google API key. #12506 added
// this pattern with the exact length; #12620 landed the anti-drift test that asserts
// /\bAIza[A-Za-z0-9_-]{20,}/ must not survive. Anything shorter or longer than 39 was
@@ -82,4 +88,17 @@ export const CREDENTIAL_PATTERNS: CredentialPattern[] = [
/((?:["\x27]?(?:Authorization|x-api-key|api-key|apikey)["\x27]?\s*[:=]\s*["\x27]?)(?:(?:Bearer|Basic|Token)\s+)?)[A-Za-z0-9._~+/=-]{10,}/gi,
replacement: "$1[REDACTED:auth_header]",
},
// GHSA-r4q7-7f24-m29p: generic `sk-` fallback for every OpenAI-compatible provider whose
// key is not exactly 48 chars (DeepSeek 32-hex, Moonshot/Kimi 47-49, Together, …). The
// guardrail is catalog-only, so all of those passed through it untouched. MUST stay the
// LAST entry: both consumers iterate in order and replace as they go, so `openai_proj`,
// `openai` and `anthropic*` have already stamped their specific label before this one
// runs — it only ever sees the `sk-` shapes nothing else claimed. The lookbehind
// (mirroring STRONG_CREDENTIAL_TOKEN in errorSanitization.ts) keeps `risk-…`-style words
// from matching.
{
name: "openai_compatible",
regex: /(?<![A-Za-z0-9])sk-[A-Za-z0-9._~+/=-]{20,}/g,
replacement: "[REDACTED:openai_compatible]",
},
];

View File

@@ -1,7 +1,9 @@
import test from "node:test";
import assert from "node:assert/strict";
import safeRegex from "safe-regex";
import {
CREDENTIAL_PATTERNS,
CredentialMaskerGuardrail,
redactCredentials,
} from "../../src/lib/guardrails/credentialMasker.ts";
@@ -91,3 +93,173 @@ test("does not re-redact an already-redacted structured header", async () => {
assert.equal(response.headers.Authorization, "Bearer [REDACTED:auth_header]");
});
});
// ---------------------------------------------------------------------------
// GHSA-r4q7-7f24-m29p — Groq (`gsk_`), xAI (`xai-`) and OpenAI-compatible
// (`sk-` of any non-48 length: DeepSeek 32-hex, Moonshot/Kimi, Together, …)
// keys had no catalog entry. The runtime guardrail is catalog-only, so every
// one of those shapes passed through `redactCredentials()` untouched; the
// public sanitizer only caught the `sk-` family by coincidence through its
// STRONG_CREDENTIAL_TOKEN fallback and leaked `gsk_`/`xai-` outright.
//
// Key shapes below are deterministic fakes (shape-accurate, never real keys),
// generated the same way as the verifier probe so the regression guard and the
// empirical leak table agree byte-for-byte on what "a key" looks like.
// ---------------------------------------------------------------------------
const ALNUM = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const HEX = "0123456789abcdef";
function fill(n: number, charset: string, seed = 7): string {
let out = "";
for (let i = 0; i < n; i++) out += charset[(i * 31 + seed * 17 + i * i) % charset.length];
return out;
}
// `type` is both the detection name and the `[REDACTED:<type>]` label.
type LeakShape = { label: string; key: string; type: string };
const LEAK_SHAPES: LeakShape[] = [
{ label: "groq gsk_ + 52 alnum", key: "gsk_" + fill(52, ALNUM), type: "groq" },
{ label: "xai xai- + 80 alnum", key: "xai-" + fill(80, ALNUM, 3), type: "xai" },
{ label: "deepseek sk- + 32 hex", key: "sk-" + fill(32, HEX), type: "openai_compatible" },
{
label: "openai-compatible sk- + 40 alnum",
key: "sk-" + fill(40, ALNUM, 9),
type: "openai_compatible",
},
{
label: "openai-compatible sk- + 51 alnum",
key: "sk-" + fill(51, ALNUM, 11),
type: "openai_compatible",
},
{
label: "openai-compatible sk- + 20 alnum (minimum bound)",
key: "sk-" + fill(20, ALNUM, 13),
type: "openai_compatible",
},
{
label: "openai-compatible sk- + 36 mixed [A-Za-z0-9_-]",
key: "sk-" + fill(36, ALNUM + "_-", 2),
type: "openai_compatible",
},
];
const CONTEXTS: Array<[string, (key: string) => string]> = [
["bare", (key) => key],
["sentence", (key) => `upstream error: Invalid API Key ${key} for model foo`],
["json-msg", (key) => `{"error":{"message":"Incorrect API key provided: ${key}. Check docs."}}`],
];
for (const shape of LEAK_SHAPES) {
for (const [contextName, wrap] of CONTEXTS) {
test(`GHSA-r4q7: redacts ${shape.label} in ${contextName} context`, () => {
const input = wrap(shape.key);
const result = redactCredentials(input);
assert.equal(result.modified, true, `not modified: ${input}`);
assert.equal(result.text.includes(shape.key), false, `key survived: ${result.text}`);
assert.ok(
result.text.includes(`[REDACTED:${shape.type}]`),
`expected [REDACTED:${shape.type}] in: ${result.text}`
);
assert.deepEqual(
result.detections.map((d) => d.type),
[shape.type],
`unexpected detection set for ${shape.label}`
);
});
}
}
test("GHSA-r4q7: leaves short prose tokens and sub-bound prefixes untouched", () => {
const benign = [
"gsk_abc",
"xai-1",
"sk-short",
"task sk failed",
"gsk_" + fill(19, ALNUM),
"xai-" + fill(19, ALNUM),
"sk-" + fill(19, ALNUM),
// `sk-` preceded by an alphanumeric is part of a larger word, not a key prefix.
"risk-based-access-control-policy-evaluation-failed",
"Model gpt-5 is not available on this plan",
];
for (const input of benign) {
const result = redactCredentials(input);
assert.equal(result.modified, false, `over-redacted: ${input} -> ${result.text}`);
assert.equal(result.text, input);
assert.deepEqual(result.detections, []);
}
});
test("GHSA-r4q7: specific sk- labels still win over the openai_compatible fallback", () => {
const specific: Array<[string, string, string]> = [
["sk-proj-" + fill(60, ALNUM + "_-", 4), "openai_proj", "[REDACTED:openai]"],
["sk-" + fill(48, ALNUM, 21), "openai", "[REDACTED:openai]"],
// `anthropic` only allows one digit after `api`, so the real `api03` shape is
// caught by `anthropic_alt` — same label, pre-existing, out of scope here.
["sk-ant-api03-" + fill(60, ALNUM + "_-", 6), "anthropic_alt", "[REDACTED:anthropic]"],
["sk-ant-api3-" + fill(60, ALNUM + "_-", 6), "anthropic", "[REDACTED:anthropic]"],
["sk-ant-" + fill(40, ALNUM + "_-", 8), "anthropic_alt", "[REDACTED:anthropic]"],
["sk_live_" + fill(24, ALNUM, 10), "stripe", "[REDACTED:stripe]"],
];
for (const [key, expectedType, expectedLabel] of specific) {
const result = redactCredentials(`upstream error: Invalid API Key ${key} for model foo`);
assert.equal(result.text.includes(key), false, `key survived: ${result.text}`);
assert.ok(result.text.includes(expectedLabel), `expected ${expectedLabel} in ${result.text}`);
assert.equal(result.text.includes("[REDACTED:openai_compatible]"), false, result.text);
assert.deepEqual(
result.detections.map((d) => d.type),
[expectedType],
`fallback must not fire when a specific pattern already matched: ${key}`
);
}
});
test("GHSA-r4q7: catalog ordering keeps the generic sk- fallback last", () => {
const names = CREDENTIAL_PATTERNS.map((p) => p.name);
assert.equal(names.at(-1), "openai_compatible", "openai_compatible must be the LAST entry");
assert.equal(new Set(names).size, names.length, "duplicate catalog names");
// Every other pattern that can match a string starting with `sk-` must run
// before the fallback, or it would never get to apply its specific label.
const fallbackIndex = names.indexOf("openai_compatible");
for (const [index, pattern] of CREDENTIAL_PATTERNS.entries()) {
if (pattern.name === "openai_compatible") continue;
if (/^\\?b?sk-/.test(pattern.regex.source)) {
assert.ok(index < fallbackIndex, `${pattern.name} is ordered after openai_compatible`);
}
}
// The provider-specific entries sit with their siblings, before the loose
// `google` bound and after the last `sk-ant` label.
assert.ok(names.indexOf("groq") > names.indexOf("anthropic_alt"));
assert.ok(names.indexOf("xai") > names.indexOf("anthropic_alt"));
assert.ok(names.indexOf("groq") < names.indexOf("google"));
assert.ok(names.indexOf("xai") < names.indexOf("google"));
});
test("GHSA-r4q7: catalog regexes are ReDoS-safe and globally flagged", () => {
for (const pattern of CREDENTIAL_PATTERNS) {
assert.ok(pattern.regex.global, `${pattern.name} must carry the g flag`);
// `auth_header` predates this guard and trips safe-regex's star-height
// heuristic through `\s*` nested inside optional groups; its token class is
// bounded by `{10,}` so it is linear in practice. Everything else, including
// every future addition, must pass.
if (pattern.name === "auth_header") continue;
assert.ok(safeRegex(pattern.regex), `${pattern.name} failed safe-regex: ${pattern.regex}`);
}
for (const name of ["groq", "xai", "openai_compatible"]) {
const pattern = CREDENTIAL_PATTERNS.find((p) => p.name === name);
assert.ok(pattern, `${name} missing from catalog`);
assert.ok(safeRegex(pattern.regex), `${name} failed safe-regex`);
// Bounded, non-nested charset with a lower length bound only — no `.*`,
// no alternation of overlapping classes.
assert.doesNotMatch(pattern.regex.source, /\.\*|\.\+|\)\*|\)\+/);
}
});

View File

@@ -103,6 +103,10 @@ test("sanitizeErrorMessage covers the canonical credential pattern catalog", ()
`key-${"a".repeat(32)}`,
`M${"A".repeat(23)}.${"B".repeat(6)}.${"C".repeat(27)}`,
"postgresql://db-user:db-password@db.internal.example/app",
// GHSA-r4q7-7f24-m29p — Groq and xAI keys had no catalog entry and no
// STRONG_CREDENTIAL_TOKEN fallback, so they reached error bodies verbatim.
`gsk_${"A".repeat(52)}`,
`xai-${"A".repeat(80)}`,
];
for (const credential of credentials) {