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
76 changes: 76 additions & 0 deletions src/__tests__/scanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
63 changes: 52 additions & 11 deletions src/rules/runtime-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.',
Expand Down
Loading