diff --git a/scanners/mcp-config-validator.mjs b/scanners/mcp-config-validator.mjs index 06474a0..20a84e0 100644 --- a/scanners/mcp-config-validator.mjs +++ b/scanners/mcp-config-validator.mjs @@ -18,7 +18,13 @@ const VALID_SERVER_FIELDS = new Set([ 'type', 'command', 'args', 'env', 'url', 'headers', 'timeout', 'trust', ]); -const ENV_VAR_PATTERN = /\$\{([^}]+)\}/g; +// Match only bare ${IDENTIFIER} references. POSIX expansions like ${VAR%pattern} +// or ${VAR:-default} contain operators and are skipped — Claude Code resolves +// them at launch (CC 2.1.142), so they are not config-defined env references. +const ENV_VAR_PATTERN = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g; + +// Auto-injected by Claude Code at runtime — never require an env block (CC 2.1.139). +const AUTO_INJECTED_ENV_VARS = new Set(['CLAUDE_PROJECT_DIR']); /** * Scan all .mcp.json files discovered. @@ -116,6 +122,7 @@ export async function scan(targetPath, discovery) { ENV_VAR_PATTERN.lastIndex = 0; while ((match = ENV_VAR_PATTERN.exec(arg)) !== null) { const varName = match[1]; + if (AUTO_INJECTED_ENV_VARS.has(varName)) continue; const hasEnvBlock = config.env && typeof config.env === 'object' && varName in config.env; if (!hasEnvBlock) { findings.push(finding({ diff --git a/tests/scanners/mcp-config-validator.test.mjs b/tests/scanners/mcp-config-validator.test.mjs index 81b12b7..58ce5d8 100644 --- a/tests/scanners/mcp-config-validator.test.mjs +++ b/tests/scanners/mcp-config-validator.test.mjs @@ -1,7 +1,9 @@ -import { describe, it, beforeEach } from 'node:test'; +import { describe, it, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; -import { resolve } from 'node:path'; +import { resolve, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import { resetCounter } from '../../scanners/lib/output.mjs'; import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs'; import { scan } from '../../scanners/mcp-config-validator.mjs'; @@ -101,6 +103,58 @@ describe('MCP scanner — broken project', () => { }); }); +describe('MCP scanner — env-var false positives (CC 2.1.139/2.1.142, Batch 1)', () => { + let tmpRoot; + let envFindings; + + beforeEach(async () => { + resetCounter(); + tmpRoot = await mkdtemp(join(tmpdir(), 'ca-mcp-env-')); + const mcp = { + mcpServers: { + proj: { + type: 'stdio', + command: 'node', + // CLAUDE_PROJECT_DIR is auto-injected (CC 2.1.139); ${VAR%…}/${VAR:-…} + // are POSIX expansions CC resolves (2.1.142) — none need an env block. + args: ['${CLAUDE_PROJECT_DIR}/server.mjs', '--root', '${HOME%/}', '--cfg', '${CONFIG_DIR:-/etc}'], + trust: 'workspace', + }, + legacy: { + type: 'stdio', + command: 'node', + args: ['${REAL_MISSING}'], + trust: 'workspace', + }, + }, + }; + await writeFile(join(tmpRoot, '.mcp.json'), JSON.stringify(mcp, null, 2) + '\n', 'utf8'); + const discovery = await discoverConfigFiles(tmpRoot); + const result = await scan(tmpRoot, discovery); + envFindings = result.findings.filter(f => f.title === 'Unreferenced env var in args'); + }); + + afterEach(async () => { + if (tmpRoot) await rm(tmpRoot, { recursive: true, force: true }); + }); + + it('does NOT flag ${CLAUDE_PROJECT_DIR} (auto-injected runtime var)', () => { + const f = envFindings.find(x => /CLAUDE_PROJECT_DIR/.test(x.description || '')); + assert.equal(f, undefined, 'CLAUDE_PROJECT_DIR must be allowlisted'); + }); + + it('does NOT flag POSIX expansions ${VAR%…} / ${VAR:-…}', () => { + const f = envFindings.find(x => /HOME|CONFIG_DIR/.test(x.description || '')); + assert.equal(f, undefined, 'POSIX operator expansions are not env-var references'); + }); + + it('STILL flags a genuine bare unreferenced var', () => { + assert.equal(envFindings.length, 1, + `expected exactly REAL_MISSING; got: ${envFindings.map(f => f.description).join(' | ')}`); + assert.match(envFindings[0].description || '', /REAL_MISSING/); + }); +}); + describe('MCP scanner — empty project', () => { let result; beforeEach(async () => {