mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-16 20:02:45 +03:00
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:
@@ -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)
|
||||
@@ -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]",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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, /\.\*|\.\+|\)\*|\)\+/);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user