Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions src/__tests__/scanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
47 changes: 36 additions & 11 deletions src/rules/runtime-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down Expand Up @@ -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<string>();

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,
Expand All @@ -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.',
Expand Down
Loading