Files
OmniRoute/open-sse/utils/credentialPatterns.ts
Diego Rodrigues de Sa e Souza e498c349e3 fix(security): add Groq, xAI and OpenAI-compatible key shapes to the credential catalog (#13744)
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.
2026-09-15 13:24:30 -03:00

105 lines
4.8 KiB
TypeScript

/** Pure credential signatures shared by guardrails and public error sanitization. */
export interface CredentialPattern {
name: string;
regex: RegExp;
replacement: string;
}
export const CREDENTIAL_PATTERNS: CredentialPattern[] = [
{ name: "openai_proj", regex: /sk-proj-[A-Za-z0-9_-]{20,}/g, replacement: "[REDACTED:openai]" },
{ name: "openai", regex: /\bsk-[A-Za-z0-9]{48}\b/g, replacement: "[REDACTED:openai]" },
{
name: "anthropic",
regex: /sk-ant-api[0-9]?-[A-Za-z0-9_-]{20,}/g,
replacement: "[REDACTED:anthropic]",
},
{
name: "anthropic_alt",
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
// therefore passing straight through into error bodies. In an error message
// over-redacting a string that merely starts with AIza costs nothing; under-redacting
// one leaks a credential, so the loose bound is the correct side to err on.
{ name: "google", regex: /AIza[0-9A-Za-z_-]{20,}/g, replacement: "[REDACTED:google]" },
{ name: "huggingface", regex: /hf_[A-Za-z0-9]{34}/g, replacement: "[REDACTED:hf]" },
{ name: "replicate", regex: /r8_[A-Za-z0-9]{37}/g, replacement: "[REDACTED:replicate]" },
{ name: "github", regex: /gh[pousr]_[A-Za-z0-9]{36,}/g, replacement: "[REDACTED:github]" },
{ name: "slack", regex: /xox[bpoa]-[A-Za-z0-9-]{10,}/g, replacement: "[REDACTED:slack]" },
{ name: "linear", regex: /lin_api_[A-Za-z0-9]{40}/g, replacement: "[REDACTED:linear]" },
{ name: "notion", regex: /secret_[A-Za-z0-9]{43}/g, replacement: "[REDACTED:notion]" },
{ name: "npm", regex: /npm_[A-Za-z0-9]{36}/g, replacement: "[REDACTED:npm]" },
{
name: "postman",
regex: /PMAK-[a-f0-9]{8}-[a-f0-9]{32}/g,
replacement: "[REDACTED:postman]",
},
{
name: "discord",
regex: /\b[MN][A-Za-z0-9]{23}\.[A-Za-z0-9]{6}\.[A-Za-z0-9]{27}\b/g,
replacement: "[REDACTED:discord]",
},
{
name: "stripe",
regex: /(?:sk|rk)_(?:live|test)_[0-9a-zA-Z]{24,}/g,
replacement: "[REDACTED:stripe]",
},
{
name: "square",
regex: /sq0(?:atp-[0-9A-Za-z_-]{22}|csp-[0-9A-Za-z_-]{43})/g,
replacement: "[REDACTED:square]",
},
{ name: "aws_access_key", regex: /AKIA[0-9A-Z]{16}/g, replacement: "[REDACTED:aws]" },
{ name: "twilio", regex: /\bSK[0-9a-fA-F]{32}\b/g, replacement: "[REDACTED:twilio]" },
{
name: "sendgrid",
regex: /SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/g,
replacement: "[REDACTED:sendgrid]",
},
{ name: "mailgun", regex: /key-[a-f0-9]{32}/g, replacement: "[REDACTED:mailgun]" },
{
name: "private_key",
regex:
/-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/g,
replacement: "[REDACTED:private_key]",
},
{
name: "jwt",
regex: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
replacement: "[REDACTED:jwt]",
},
{
name: "connection_string",
regex: /(?:mongodb(?:\+srv)?|postgres(?:ql)?|mysql|redis|amqp):\/\/[^:/@\s"']+:[^:/@\s"']+@/g,
replacement: "[REDACTED:connection_string]",
},
{
name: "auth_header",
regex:
/((?:["\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]",
},
];