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
34 changes: 34 additions & 0 deletions src/__tests__/scanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,40 @@ describe('INSECURE_TRANSPORT rule', () => {
});
});

// ─── EXPOSED_SECRETS rule ─────────────────────────────────────────────────────

describe('EXPOSED_SECRETS rule', () => {
const OPENAI_KEY = 'sk-abcdefghijklmnopqrstuvwxyz1234567890ABCD';

it('fires when a secret-shaped value is present', () => {
const config: ParsedMcpConfig = { rawStrings: [OPENAI_KEY] };
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 echo the raw secret into the finding evidence', () => {
const config: ParsedMcpConfig = { rawStrings: [OPENAI_KEY] };
const findings = runRuntimeRule(RuleId.EXPOSED_SECRETS, config);
const evidence = findings[0].evidence ?? '';
// The whole point of the rule is to get the secret rotated; reports are
// routinely persisted to CI logs / SARIF, so the raw value must not appear.
expect(evidence).not.toContain(OPENAI_KEY);
expect(evidence).toContain('redacted');
// A short, non-sensitive prefix and the length are still surfaced so the
// user can locate the offending value.
expect(evidence).toContain('OpenAI API key');
expect(evidence).toContain(`${OPENAI_KEY.length} chars`);
});

it('does not fire on a clean config', () => {
const config: ParsedMcpConfig = { rawStrings: ['bearer', 'https://app.example.com'] };
const findings = runRuntimeRule(RuleId.EXPOSED_SECRETS, config);
expect(findings).toHaveLength(0);
});
});

// ─── failOn behaviour ─────────────────────────────────────────────────────────

describe('failOn config', () => {
Expand Down
32 changes: 24 additions & 8 deletions src/rules/runtime-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,28 @@ 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
const SECRET_PATTERNS: Array<{ label: string; pattern: RegExp }> = [
{ label: 'OpenAI API key', pattern: /sk-[a-zA-Z0-9]{20,}/i },
{ label: 'GitHub personal access token', pattern: /ghp_[a-zA-Z0-9]{36}/i },
{ label: 'AWS access key ID', pattern: /AKIA[0-9A-Z]{16}/i },
{ label: 'hardcoded password', pattern: /[Pp]assword\s*[=:]\s*\S{8,}/ },
];

/**
* Produce a non-reversible preview of a matched secret for use in finding
* evidence. Scan reports are routinely written to CI logs, SARIF artifacts, and
* GitHub Code Scanning — surfaces that are often more visible and longer-lived
* than the config file itself. Echoing the raw secret there would re-expose the
* very credential the rule is asking the user to rotate. We reveal only the
* leading characters (which for every pattern above are a structural prefix such
* as `sk-`, `ghp_`, `AKIA`, or the word `password`, not entropy) plus the total
* length, which is enough to locate the value without reproducing it.
*/
function maskSecret(value: string): string {
const visible = value.slice(0, Math.min(4, value.length));
return `${visible}…[redacted, ${value.length} chars]`;
}

export const runtimeRules: Rule[] = [
{
id: RuleId.INSECURE_TRANSPORT,
Expand Down Expand Up @@ -99,9 +114,10 @@ export const runtimeRules: Rule[] = [
const matched: 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);
// Never echo the raw secret into the report — only a masked preview.
matched.push(`${label} (${maskSecret(s)})`);
break;
}
}
Expand All @@ -117,7 +133,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 ${matched.length} likely secret(s): ${matched.slice(0, 3).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