diff --git a/src/__tests__/scanner.test.ts b/src/__tests__/scanner.test.ts index ce0baf3..4ab5669 100644 --- a/src/__tests__/scanner.test.ts +++ b/src/__tests__/scanner.test.ts @@ -435,6 +435,59 @@ describe('INSECURE_TRANSPORT rule', () => { }); }); +// ─── EXPOSED_SECRETS rule ───────────────────────────────────────────────────── + +describe('EXPOSED_SECRETS rule', () => { + // Synthetic fixtures: each value is assembled from its prefix plus a filler + // body so that no contiguous, real-looking credential literal ever appears in + // this source file (which would otherwise trip secret-scanning push + // protection). The filler still satisfies each pattern's character class and + // length requirement. + const body = (n: number) => 'a'.repeat(n); + const secrets: Array<[string, string]> = [ + ['legacy OpenAI key', 'sk-' + body(40)], + ['OpenAI project key', 'sk-' + 'proj-' + body(40)], + ['OpenAI service-account key', 'sk-' + 'svcacct-' + body(40)], + ['Anthropic key', 'sk-' + 'ant-' + body(40)], + ['GitHub classic PAT', 'ghp' + '_' + body(36)], + ['GitHub OAuth token', 'gho' + '_' + body(36)], + ['GitHub fine-grained PAT', 'github' + '_pat_' + body(82)], + ['AWS access key id', 'AKIA' + 'A'.repeat(16)], + ['Google API key', 'AIza' + body(35)], + ['Slack bot token', 'xoxb' + '-1234567890-' + body(16)], + ['Stripe live key', 'sk' + '_live_' + body(24)], + ['password assignment', 'password=' + body(14)], + ]; + + it.each(secrets)('fires for a %s', (_label, value) => { + const config: ParsedMcpConfig = { rawStrings: [value] }; + const findings = runRuntimeRule(RuleId.EXPOSED_SECRETS, config); + expect(findings).toHaveLength(1); + expect(findings[0].ruleId).toBe(RuleId.EXPOSED_SECRETS); + expect(findings[0].severity).toBe(Severity.CRITICAL); + }); + + it('does not fire for benign config strings', () => { + const config: ParsedMcpConfig = { + rawStrings: [ + 'https://secure-mcp.example.com', + 'bearer', + 'filesystem:read', + // JWT-style bearer token (assembled from parts) — not a known secret + // format, must not trigger a false positive. + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' + '.' + 'c2VjcmV0LXRva2VuLWZvcnR5LXR3by1jaGFycw', + ], + }; + const findings = runRuntimeRule(RuleId.EXPOSED_SECRETS, config); + expect(findings).toHaveLength(0); + }); + + it('does not fire when rawStrings is absent', () => { + const findings = runRuntimeRule(RuleId.EXPOSED_SECRETS, {}); + expect(findings).toHaveLength(0); + }); +}); + // ─── failOn behaviour ───────────────────────────────────────────────────────── describe('failOn config', () => { diff --git a/src/rules/runtime-rules.ts b/src/rules/runtime-rules.ts index 0d0c3fd..7c732f9 100644 --- a/src/rules/runtime-rules.ts +++ b/src/rules/runtime-rules.ts @@ -3,10 +3,27 @@ import { ParsedMcpConfig } from '../parser.js'; import { Rule } from './index.js'; const SECRET_PATTERNS: RegExp[] = [ - /sk-[a-zA-Z0-9]{20,}/i, // OpenAI API key - /ghp_[a-zA-Z0-9]{36}/i, // GitHub PAT - /AKIA[0-9A-Z]{16}/i, // AWS access key - /[Pp]assword\s*[=:]\s*\S{8,}/, // Password assignment + // OpenAI / Anthropic keys. Modern keys carry a prefix and may contain + // hyphens/underscores in the body: project (sk-proj-), service account + // (sk-svcacct-), admin (sk-admin-), and Anthropic (sk-ant-) keys. + /sk-(?:proj|svcacct|admin|ant)-[A-Za-z0-9_-]{20,}/i, + // Legacy unprefixed OpenAI keys (sk- followed by a long alphanumeric run). + /sk-[A-Za-z0-9]{32,}/i, + // GitHub tokens: PAT (ghp_), OAuth (gho_), user-to-server (ghu_), + // server-to-server (ghs_), and refresh (ghr_) tokens. + /gh[pousr]_[A-Za-z0-9]{36,}/, + // GitHub fine-grained personal access tokens. + /github_pat_[A-Za-z0-9_]{22,}/, + // AWS access key ID. + /AKIA[0-9A-Z]{16}/, + // Google API key. + /AIza[0-9A-Za-z_-]{35}/, + // Slack tokens (bot/user/app/refresh). + /xox[baprs]-[0-9A-Za-z-]{10,}/, + // Stripe live secret / restricted keys. + /(?:sk|rk)_live_[0-9A-Za-z]{16,}/, + // Password assignment. + /[Pp]assword\s*[=:]\s*\S{8,}/, ]; export const runtimeRules: Rule[] = [