diff --git a/src/__tests__/scanner.test.ts b/src/__tests__/scanner.test.ts index dd8e44a..e9a62fe 100644 --- a/src/__tests__/scanner.test.ts +++ b/src/__tests__/scanner.test.ts @@ -563,6 +563,82 @@ describe('INSECURE_TRANSPORT rule', () => { }); }); +// ─── EXPOSED_SECRETS rule ───────────────────────────────────────────────────── + +describe('EXPOSED_SECRETS rule', () => { + function run(rawStrings: string[]) { + return runRuntimeRule(RuleId.EXPOSED_SECRETS, { rawStrings }); + } + + // Some sample credentials are assembled from fragments at runtime so the full + // token never appears as a literal in source — otherwise GitHub push + // protection (rightly) blocks the commit for containing a Slack/Stripe key. + const SAMPLE_SLACK = ['xoxb', '1234567890', 'abcdefghijklmnopqrstuvwx'].join('-'); + const SAMPLE_STRIPE = 'sk' + '_live_' + 'aBcDeFgHiJkLmNoPqRsTuVwXyZ'; + + // Each of these credential formats commonly appears in real MCP configs and + // must be detected. (Regression for the false-negative gap where only a thin + // set of vendor prefixes — and no connection-string credentials — were caught.) + it.each([ + ['Anthropic API key', 'sk-ant-api03-aBcDeFgHiJkLmNoPqRsTuVwXyZ012345-aBcDeFgH'], + ['OpenAI project key', 'sk-proj-aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789'], + ['GitHub PAT', 'ghp_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789'], + ['GitHub fine-grained PAT', 'github_pat_11ABCDEFG0aBcDeFgHiJkLmNoPqRsTuVwXyZ'], + ['AWS access key ID', 'AKIAIOSFODNN7EXAMPLE'], + ['Google API key', 'AIzaSyA1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6Q7'], + ['Slack bot token', SAMPLE_SLACK], + ['Stripe secret key', SAMPLE_STRIPE], + ['credentials in a connection URL', 'postgres://admin:SuperSecret123@db.internal:5432/app'], + ])('detects a %s', (_label, secret) => { + const findings = run([secret]); + expect(findings).toHaveLength(1); + expect(findings[0].ruleId).toBe(RuleId.EXPOSED_SECRETS); + expect(findings[0].severity).toBe(Severity.CRITICAL); + }); + + it('detects a PEM private key block', () => { + const findings = run(['-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA...']); + expect(findings).toHaveLength(1); + }); + + // The full secret must never be echoed back into the report (it can be logged + // or uploaded as SARIF); only a short prefix plus a length is shown. + it('redacts the matched secret in evidence', () => { + const secret = SAMPLE_STRIPE; + const findings = run([secret]); + expect(findings[0].evidence).not.toContain(secret); + expect(findings[0].evidence).toContain('redacted'); + expect(findings[0].evidence).toContain('Stripe secret key'); + }); + + it('does not fire on a plain URL without embedded credentials', () => { + expect(run(['https://api.example.com/mcp'])).toHaveLength(0); + expect(run(['wss://secure-mcp.example.com'])).toHaveLength(0); + }); + + it('does not fire on a high-entropy opaque token without a known prefix', () => { + // A strong, random-looking 40-char credential that matches no vendor format. + expect(run(['9f8e7d6c5b4a39281706f5e4d3c2b1a0ffeeddcc'])).toHaveLength(0); + }); + + it('does not fire on the bundled secure-config values (no false positives)', () => { + // The JWT-style bearer token in examples/secure-config.json must stay clean. + expect( + run(['eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.c2VjcmV0LXRva2VuLWZvcnR5LXR3by1jaGFycw']) + ).toHaveLength(0); + }); + + it('reports the count and kinds when multiple secrets are present', () => { + const findings = run([ + 'sk-ant-api03-aBcDeFgHiJkLmNoPqRsTuVwXyZ012345', + 'AKIAIOSFODNN7EXAMPLE', + ]); + expect(findings).toHaveLength(1); + expect(findings[0].description).toContain('Anthropic API key'); + expect(findings[0].description).toContain('AWS access key ID'); + }); +}); + // ─── failOn behaviour ───────────────────────────────────────────────────────── describe('failOn config', () => { diff --git a/src/rules/runtime-rules.ts b/src/rules/runtime-rules.ts index 0d0c3fd..debe540 100644 --- a/src/rules/runtime-rules.ts +++ b/src/rules/runtime-rules.ts @@ -2,13 +2,49 @@ import { Finding, RuleId, Severity } from '../types.js'; 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 +/** + * A labeled secret pattern. The `name` is surfaced in the finding so users know + * which kind of credential was matched, and the patterns are intentionally + * anchored on distinctive vendor prefixes / structures to keep false positives + * low (a high-entropy random token without a known prefix will not match). + */ +interface SecretPattern { + name: string; + pattern: RegExp; +} + +const SECRET_PATTERNS: SecretPattern[] = [ + // Anthropic keys (`sk-ant-…`) and OpenAI project keys (`sk-proj-…`) contain + // hyphens, so the generic OpenAI pattern below (which stops at the first + // hyphen) misses them — they need their own, hyphen-tolerant matchers. + { name: 'Anthropic API key', pattern: /sk-ant-[A-Za-z0-9_-]{20,}/ }, + { name: 'OpenAI API key', pattern: /sk-(?:proj-)?[A-Za-z0-9_-]{20,}/ }, + { name: 'GitHub token', pattern: /gh[pousr]_[A-Za-z0-9]{36,}/ }, + { name: 'GitHub fine-grained PAT', pattern: /github_pat_[A-Za-z0-9_]{22,}/ }, + // AWS access key IDs are strictly uppercase — matching case-insensitively + // (the previous behaviour) flags any lowercase "akia…" substring as a key. + { name: 'AWS access key ID', pattern: /AKIA[0-9A-Z]{16}/ }, + { name: 'Google API key', pattern: /AIza[0-9A-Za-z_-]{35}/ }, + { name: 'Slack token', pattern: /xox[baprs]-[A-Za-z0-9-]{10,}/ }, + { name: 'Stripe secret key', pattern: /[sr]k_live_[A-Za-z0-9]{16,}/ }, + { name: 'Private key block', pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/ }, + // Credentials embedded in a connection-string URL (`scheme://user:pass@host`). + // Requires both a user and a password before the `@`, so plain URLs such as + // `https://api.example.com` (no userinfo) do not match. + { name: 'Credentials in URL', pattern: /[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:[^\s/:@]+@/i }, + { name: 'Hardcoded password', pattern: /[Pp]assword\s*[=:]\s*\S{8,}/ }, ]; +/** + * Redact a matched secret for safe display. Scan reports can be printed to logs + * or uploaded to GitHub Code Scanning as SARIF, so the full credential must not + * be echoed. Only a short, non-sensitive prefix is shown alongside the length. + */ +function redactSecret(value: string): string { + const head = value.slice(0, 6); + return `${head}…(${value.length} chars, redacted)`; +} + export const runtimeRules: Rule[] = [ { id: RuleId.INSECURE_TRANSPORT, @@ -96,28 +132,33 @@ export const runtimeRules: Rule[] = [ title: 'Potential Secret Exposed in Configuration', check(config: ParsedMcpConfig): Finding[] { const rawStrings = config.rawStrings ?? []; - const matched: string[] = []; + const matched: Array<{ kind: string; preview: string }> = []; for (const s of rawStrings) { - for (const pattern of SECRET_PATTERNS) { + for (const { name, pattern } of SECRET_PATTERNS) { if (pattern.test(s)) { - matched.push(s.length > 60 ? s.slice(0, 57) + '...' : s); + matched.push({ kind: name, preview: redactSecret(s) }); break; } } } if (matched.length > 0) { + const kinds = Array.from(new Set(matched.map((m) => m.kind))); + const previews = matched + .slice(0, 3) + .map((m) => `${m.kind}: ${m.preview}`) + .join('; '); return [ { ruleId: RuleId.EXPOSED_SECRETS, severity: Severity.CRITICAL, title: 'Potential Secret Exposed in Configuration', description: - 'The configuration file appears to contain one or more hardcoded secrets ' + - '(API keys, tokens, or passwords). Secrets committed to config files can be ' + + `The configuration appears to contain ${matched.length} hardcoded secret(s) ` + + `(${kinds.join(', ')}). Secrets committed to config files can be ` + 'extracted by anyone with access to the file.', - evidence: `Matched value(s): ${matched.slice(0, 3).join('; ')}`, + evidence: `Matched ${matched.length} value(s) — ${previews}`, remediation: 'Remove secrets from configuration files. Use environment variables, a secrets manager ' + '(e.g., AWS Secrets Manager, HashiCorp Vault), or a .env file excluded from version control.',