From 49469bfd3446fd886852e958d57fcf1f511f8de4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 21:09:10 +0000 Subject: [PATCH] feat(EXPOSED_SECRETS): broaden secret detection and stop leaking matched values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand the EXPOSED_SECRETS rule from 4 to 9 high-confidence credential patterns and stop echoing the matched secret into the finding. New patterns: PEM private keys (RSA/EC/OPENSSH/DSA), Slack tokens, Google API keys, Stripe live secret keys, scoped GitHub tokens (gho_/ghu_/ghs_/ ghr_) and fine-grained PATs (github_pat_), and AWS temporary access keys (ASIA) alongside the existing AKIA. Patterns remain prefix-anchored to keep the false-positive rate low — a JWT-shaped auth.token is not flagged. The finding evidence previously truncated and printed the matched secret, re-exposing it in CI logs and uploaded SARIF. It now names the secret *types* detected instead (e.g. "Detected 2 likely secret type(s): OpenAI API key, PEM private key"). Adds the first test coverage for EXPOSED_SECRETS: per-pattern positive cases, the no-leak evidence guarantee, type de-duplication, and false-positive guards (JWT token, ordinary config strings, absent input). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NYU6nnRkj9cM4yobBvUKwG --- CHANGELOG.md | 17 ++++++++ src/__tests__/scanner.test.ts | 73 +++++++++++++++++++++++++++++++++++ src/rules/runtime-rules.ts | 47 ++++++++++++++++------ 3 files changed, 126 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34c673f..753c395 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `EXPOSED_SECRETS`: broadened detection from 4 to 9 high-confidence credential + formats. New patterns cover PEM-encoded **private keys** (RSA/EC/OPENSSH/DSA), + **Slack** tokens (`xox[baprs]-`), **Google** API keys (`AIza…`), **Stripe** + live secret keys (`[sr]k_live_…`), scoped **GitHub** tokens (`gho_`/`ghu_`/ + `ghs_`/`ghr_`) and fine-grained PATs (`github_pat_…`), and AWS **temporary** + access keys (`ASIA…`) alongside the existing long-term `AKIA…`. Patterns stay + prefix-anchored to keep the false-positive rate low (a JWT-shaped `auth.token` + is not flagged). First test coverage for this rule (previously untested). + +### Security +- `EXPOSED_SECRETS`: the finding no longer echoes the matched secret value into + its evidence (and therefore into CI logs and uploaded SARIF). It now reports + the *types* of secret detected (e.g. "Detected 2 likely secret type(s): + OpenAI API key, PEM private key") instead of a truncated copy of the + credential, so running the scanner can no longer re-expose the secret. + ### Fixed - URL/endpoint mode no longer produces false findings (#27). Scanning a URL previously ran every config rule against an effectively empty config, so any diff --git a/src/__tests__/scanner.test.ts b/src/__tests__/scanner.test.ts index dd8e44a..b3a0000 100644 --- a/src/__tests__/scanner.test.ts +++ b/src/__tests__/scanner.test.ts @@ -563,6 +563,79 @@ describe('INSECURE_TRANSPORT rule', () => { }); }); +// ─── EXPOSED_SECRETS rule ───────────────────────────────────────────────────── + +describe('EXPOSED_SECRETS rule', () => { + function runSecretScan(rawStrings: string[]) { + return runRuntimeRule(RuleId.EXPOSED_SECRETS, { rawStrings }); + } + + // Each high-confidence pattern should match a representative sample. + const positives: Array<[string, string]> = [ + ['OpenAI API key', 'sk-abcdefghijklmnopqrstuvwxyz0123'], + ['classic GitHub PAT', 'ghp_' + 'a'.repeat(36)], + ['scoped GitHub token (gho_)', 'gho_' + 'b'.repeat(36)], + ['GitHub fine-grained PAT', 'github_pat_' + 'c'.repeat(30)], + ['AWS long-term access key', 'AKIAIOSFODNN7EXAMPLE'], + ['AWS temporary access key', 'ASIAIOSFODNN7EXAMPLE'], + ['Google API key', 'AIzaSyA1234567890abcdefghijklmnopqrstuv'], + ['Slack bot token', 'xoxb-EXAMPLE-PLACEHOLDER-NOT-A-REAL-TOKEN'], + ['Stripe live secret key', 'sk_live_0123456789abcdefABCDEF'], + ['PEM private key', '-----BEGIN RSA PRIVATE KEY-----'], + ['OpenSSH private key', '-----BEGIN OPENSSH PRIVATE KEY-----'], + ['hardcoded password', 'password: hunter2hunter2'], + ]; + + it.each(positives)('flags a %s', (_label, value) => { + const findings = runSecretScan([value]); + expect(findings).toHaveLength(1); + expect(findings[0].ruleId).toBe(RuleId.EXPOSED_SECRETS); + expect(findings[0].severity).toBe(Severity.CRITICAL); + }); + + it('does not echo the raw secret value in the evidence', () => { + const secret = 'sk-abcdefghijklmnopqrstuvwxyz0123'; + const findings = runSecretScan([secret]); + expect(findings[0].evidence).not.toContain(secret); + // Evidence should name the *type* of secret instead. + expect(findings[0].evidence).toContain('OpenAI API key'); + }); + + it('reports each distinct secret type once, without duplicates', () => { + const findings = runSecretScan([ + 'sk-abcdefghijklmnopqrstuvwxyz0123', + 'sk-zyxwvutsrqponmlkjihgfedcba9876', // second OpenAI key — same type + 'AKIAIOSFODNN7EXAMPLE', + ]); + expect(findings).toHaveLength(1); + expect(findings[0].evidence).toContain('OpenAI API key'); + expect(findings[0].evidence).toContain('AWS access key ID'); + // "OpenAI API key" must appear exactly once despite two matching values. + expect(findings[0].evidence.match(/OpenAI API key/g)).toHaveLength(1); + }); + + it('does not fire on a JWT-shaped auth token (no false positive)', () => { + const findings = runSecretScan([ + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.c2VjcmV0LXRva2VuLWZvcnR5LXR3by1jaGFycw', + ]); + expect(findings).toHaveLength(0); + }); + + it('does not fire on ordinary config strings', () => { + const findings = runSecretScan([ + 'https://secure-mcp.example.com', + 'read-document', + 'filesystem:read', + ]); + 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..78705c2 100644 --- a/src/rules/runtime-rules.ts +++ b/src/rules/runtime-rules.ts @@ -2,11 +2,33 @@ 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 labelled secret-detection pattern. The label names the secret *type* so a + * finding can say what was detected without echoing the secret value itself. */ +interface SecretPattern { + label: string; + pattern: RegExp; +} + +/** + * High-confidence patterns for credentials that should never be hardcoded in a + * config file. Each is anchored on a distinctive prefix or structure to keep the + * false-positive rate low — we deliberately avoid generic "long random-looking + * string" heuristics, which would flag legitimate opaque tokens (e.g. a JWT in + * `transport.auth.token`, which the auth rules treat as valid authentication). + */ +const SECRET_PATTERNS: SecretPattern[] = [ + { label: 'OpenAI API key', pattern: /sk-[a-zA-Z0-9]{20,}/i }, + // Classic and scoped GitHub tokens: ghp_/gho_/ghu_/ghs_/ghr_. + { label: 'GitHub token', pattern: /gh[opusr]_[A-Za-z0-9]{36,}/ }, + { label: 'GitHub fine-grained PAT', pattern: /github_pat_[A-Za-z0-9_]{22,}/ }, + // AWS long-term (AKIA) and temporary (ASIA) access key IDs are uppercase. + { label: 'AWS access key ID', pattern: /(?:AKIA|ASIA)[0-9A-Z]{16}/ }, + { label: 'Google API key', pattern: /AIza[0-9A-Za-z_-]{35}/ }, + { label: 'Slack token', pattern: /xox[baprs]-[A-Za-z0-9-]{10,}/ }, + { label: 'Stripe secret key', pattern: /[sr]k_live_[0-9a-zA-Z]{16,}/ }, + // PEM-encoded private key of any flavour (RSA, EC, OPENSSH, DSA, PGP, …). + { label: 'PEM private key', pattern: /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----/ }, + { label: 'hardcoded password', pattern: /[Pp]assword\s*[=:]\s*\S{8,}/ }, ]; export const runtimeRules: Rule[] = [ @@ -96,18 +118,21 @@ export const runtimeRules: Rule[] = [ title: 'Potential Secret Exposed in Configuration', check(config: ParsedMcpConfig): Finding[] { const rawStrings = config.rawStrings ?? []; - const matched: string[] = []; + // Collect the *types* of secret detected, not the secret values. Echoing a + // matched credential into the finding (and from there into CI logs / SARIF + // uploads) would re-expose the very secret this rule is meant to protect. + const detected = new Set(); for (const s of rawStrings) { - for (const pattern of SECRET_PATTERNS) { + for (const { label, pattern } of SECRET_PATTERNS) { if (pattern.test(s)) { - matched.push(s.length > 60 ? s.slice(0, 57) + '...' : s); - break; + detected.add(label); } } } - if (matched.length > 0) { + if (detected.size > 0) { + const labels = Array.from(detected); return [ { ruleId: RuleId.EXPOSED_SECRETS, @@ -117,7 +142,7 @@ export const runtimeRules: Rule[] = [ 'The configuration file appears to contain one or more hardcoded secrets ' + '(API keys, tokens, or passwords). 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: `Detected ${labels.length} likely secret type(s): ${labels.join(', ')}`, 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.',