@mayhemx87/mcpscan 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bob Kolosky
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,181 @@
1
+ # mcpscan
2
+
3
+ [![CI](https://github.com/mayhemx87/mcpscan/actions/workflows/ci.yml/badge.svg)](https://github.com/mayhemx87/mcpscan/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
5
+ ![Node >= 20](https://img.shields.io/badge/node-%3E%3D20-brightgreen)
6
+
7
+ **Static scanner for MCP config files committed in repos — catch malicious
8
+ configs before your AI editor auto-executes them.**
9
+
10
+ When you open a repo in Claude Code, Cursor, VS Code, Windsurf, Amazon Q, Roo,
11
+ Kiro, or Gemini CLI, MCP config files in that repo can declare servers that the
12
+ editor launches automatically — as *your* user, inheriting *your* environment and
13
+ credentials (see CVE-2026-12957). A cloned repo is arbitrary-code-execution
14
+ waiting for an editor to open it.
15
+
16
+ `mcpscan` scans a checkout **statically** — no network calls, no subprocess
17
+ execution of anything in the scanned repo — and flags:
18
+
19
+ - commands that fetch and execute remote code (`curl … | sh`)
20
+ - unpinned `npx`/`uvx`/`pipx` packages (supply-chain risk)
21
+ - credential env inheritance (`AWS_*`, `GITHUB_TOKEN`, …) and **hardcoded secrets**
22
+ - remote (`url`-based) MCP endpoints, escalating over insecure `http://`
23
+ - shell metacharacter chaining, paths escaping the repo, untracked config files
24
+
25
+ ## How it differs from other MCP security tools
26
+
27
+ Most MCP scanners ([mcp-scan](https://github.com/invariantlabs-ai/mcp-scan),
28
+ [MCP-Shield](https://github.com/riseandignite/mcp-shield),
29
+ [Snyk agent-scan](https://github.com/snyk/agent-scan)) analyze the MCP servers
30
+ **you have installed** — connecting to them at runtime to detect tool poisoning
31
+ and prompt injection. That protects you from servers you chose to run.
32
+
33
+ `mcpscan` covers the step *before* that: the repo you just cloned, in CI, or at
34
+ `git commit` time. It never starts a server, never needs an API key, and is safe
35
+ to point at hostile checkouts. Use both — they solve different halves of the
36
+ problem.
37
+
38
+ ## Install
39
+
40
+ ```sh
41
+ npm install -g @mayhemx87/mcpscan
42
+
43
+ # or straight from the repo
44
+ npm install -g github:mayhemx87/mcpscan
45
+ ```
46
+
47
+ Either way the installed command is `mcpscan`. (The npm package is scoped
48
+ because the registry reserves the bare name as too similar to `mcp-scan` —
49
+ a different, runtime-focused tool.)
50
+
51
+ ## Usage
52
+
53
+ ```sh
54
+ mcpscan . # scan current directory
55
+ mcpscan path/to/checkout # scan any checkout
56
+ mcpscan . --json # machine-readable findings
57
+ mcpscan . --sarif # SARIF 2.1.0 for GitHub code scanning
58
+ mcpscan . --fail-on critical # only exit non-zero on critical
59
+ mcpscan . --quiet # exit code only
60
+ mcpscan install-hook # install the git pre-commit hook
61
+ mcpscan rules # list detection rules
62
+ ```
63
+
64
+ ### GitHub Actions
65
+
66
+ ```yaml
67
+ jobs:
68
+ mcpscan:
69
+ runs-on: ubuntu-latest
70
+ permissions:
71
+ contents: read
72
+ security-events: write # only needed for SARIF upload
73
+ steps:
74
+ - uses: actions/checkout@v4
75
+ - uses: mayhemx87/mcpscan@main
76
+ with:
77
+ fail-on: high
78
+ sarif-file: mcpscan.sarif
79
+ - uses: github/codeql-action/upload-sarif@v3
80
+ if: always()
81
+ with:
82
+ sarif_file: mcpscan.sarif
83
+ ```
84
+
85
+ Findings then appear in the repo's **Security → Code scanning** tab, annotated on
86
+ the exact line of the offending server definition.
87
+
88
+ ### pre-commit framework
89
+
90
+ ```yaml
91
+ # .pre-commit-config.yaml
92
+ repos:
93
+ - repo: https://github.com/mayhemx87/mcpscan
94
+ rev: v0.3.1
95
+ hooks:
96
+ - id: mcpscan
97
+ ```
98
+
99
+ ### Plain git hook
100
+
101
+ `mcpscan install-hook` installs a POSIX pre-commit hook (respects
102
+ `core.hooksPath`; appends safely to an existing hook; idempotent). Bypass a
103
+ single commit with `MCPSCAN_SKIP=1 git commit`.
104
+
105
+ ## What gets scanned
106
+
107
+ Dedicated MCP config files and editor settings files that embed MCP servers:
108
+
109
+ | Path | Client |
110
+ |---|---|
111
+ | `.mcp.json`, `mcp.json` | Claude Code and others |
112
+ | `.cursor/mcp.json` | Cursor |
113
+ | `.vscode/mcp.json`, `.vscode/settings.json` (`mcp.servers`) | VS Code |
114
+ | `.windsurf/mcp.json` | Windsurf |
115
+ | `.amazonq/mcp.json` | Amazon Q |
116
+ | `.roo/mcp.json` | Roo Code |
117
+ | `.kiro/settings/mcp.json` | Kiro |
118
+ | `.claude/settings.json`, `.claude/settings.local.json` | Claude Code |
119
+ | `.gemini/settings.json` | Gemini CLI |
120
+
121
+ Both `mcpServers` and VS Code's `servers` keys are understood; `command` and
122
+ `args` are analyzed together; remote (`url`-based) servers are covered. Nested
123
+ occurrences of these paths are scanned too (any subdirectory can be opened as a
124
+ workspace root). Only `.git/` and `node_modules/` are skipped; `.gitignore` is
125
+ deliberately ignored — an injected config would be exactly the file that's
126
+ untracked. Symlinked directories are never followed.
127
+
128
+ Your editor auto-loads a config path we don't scan?
129
+ [Open a "new client" issue](../../issues/new?template=new-client.md) — it's a
130
+ one-line fix and the best first contribution.
131
+
132
+ ## Detection rules
133
+
134
+ | Rule | Severity | Description |
135
+ |---|---|---|
136
+ | MCP-001 | CRITICAL | Server command executes a remote URL or network tool (curl, wget, https://) |
137
+ | MCP-002 | HIGH | Server uses npx/uvx/pipx with an unversioned package (supply-chain risk) |
138
+ | MCP-003 | HIGH | Server env block inherits high-value credential env vars (AWS_*, GITHUB_TOKEN, etc.) |
139
+ | MCP-004 | MEDIUM | Server command references a path outside the repository (absolute or ../) |
140
+ | MCP-005 | MEDIUM | MCP config file is not tracked by git (may have been injected) |
141
+ | MCP-006 | INFO | Server passed all detection rules (inventory signal) |
142
+ | MCP-007 | CRITICAL | Hardcoded secret-looking value in env or headers (`${VAR}` interpolations exempt) |
143
+ | MCP-008 | INFO/HIGH | Remote MCP server (`url`-based) declared — inventory; HIGH over insecure http:// |
144
+ | MCP-009 | MEDIUM | Shell command-chaining metacharacters in command/args (`$()`, backticks, `\| sh`) |
145
+ | MCP-PARSE | HIGH | Config file has invalid JSON — cannot verify safety |
146
+
147
+ ## Flags
148
+
149
+ | Flag | Default | Description |
150
+ |---|---|---|
151
+ | `--json` | off | Output findings as a JSON array |
152
+ | `--sarif` | off | Output SARIF 2.1.0 (GitHub code scanning) |
153
+ | `--quiet` | off | Suppress all output (exit code only) |
154
+ | `--severity <level>` | info | Minimum severity to report |
155
+ | `--fail-on <level>` | high | Exit non-zero if any finding at or above this severity |
156
+ | `--max-depth <n>` | unlimited | Limit directory traversal depth |
157
+
158
+ ## Exit codes
159
+
160
+ | Code | Meaning |
161
+ |---|---|
162
+ | 0 | No findings at or above `--fail-on` threshold |
163
+ | 1 | One or more findings at or above `--fail-on` threshold |
164
+ | 2 | Scan error (invalid argument, cannot determine git root, etc.) |
165
+
166
+ ## Limitations
167
+
168
+ Static and heuristic by design: a clean scan lowers risk, it does not certify a
169
+ repo as safe, and it says nothing about what a declared server *does* at
170
+ runtime. Bypass reports are treated as vulnerabilities — see
171
+ [SECURITY.md](SECURITY.md).
172
+
173
+ ## Contributing
174
+
175
+ See [CONTRIBUTING.md](CONTRIBUTING.md). Adding a client config path is a
176
+ one-line change with a test; new detection rules and false-positive reports are
177
+ equally welcome.
178
+
179
+ ## License
180
+
181
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,276 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync } from 'fs';
4
+ import { execFileSync } from 'child_process';
5
+ import { fileURLToPath } from 'url';
6
+ import { resolve, relative, dirname, join } from 'path';
7
+ import { discoverConfigs } from './discovery.js';
8
+ import { analyzeConfig, RULES, SEVERITY_RANK } from './engine.js';
9
+ import { toSarif } from './sarif.js';
10
+ // Single source of truth for the version: this package's package.json. The
11
+ // location differs between the built layout (dist/cli.js, one level down) and
12
+ // running the TypeScript source directly under tsx (cli.ts, package root).
13
+ // The name check prevents picking up an unrelated parent package.json.
14
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
15
+ function loadVersion() {
16
+ for (const p of [join(moduleDir, 'package.json'), join(moduleDir, '..', 'package.json')]) {
17
+ try {
18
+ const pkg = JSON.parse(readFileSync(p, 'utf-8'));
19
+ if ((pkg.name === '@mayhemx87/mcpscan' || pkg.name === 'mcpscan') && pkg.version) {
20
+ return pkg.version;
21
+ }
22
+ }
23
+ catch {
24
+ /* try next */
25
+ }
26
+ }
27
+ return '0.0.0';
28
+ }
29
+ const VERSION = loadVersion();
30
+ const SEVERITY_LABEL = {
31
+ critical: '[CRITICAL]',
32
+ high: '[HIGH] ',
33
+ medium: '[MEDIUM] ',
34
+ info: '[INFO] ',
35
+ };
36
+ function isFileGitTracked(filePath) {
37
+ try {
38
+ // cwd MUST be the file's own directory: without it the check runs
39
+ // against whatever repo the shell happens to be in, corrupting MCP-005
40
+ // both directions when scanning a repo from outside it (v0.1.0 bug).
41
+ // execFileSync (no shell): the path is untrusted input from the scanned
42
+ // repo -- interpolating it into a shell string would let a crafted
43
+ // filename execute commands.
44
+ execFileSync('git', ['ls-files', '--error-unmatch', '--', filePath], {
45
+ stdio: 'ignore',
46
+ cwd: dirname(filePath),
47
+ });
48
+ return true;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ function isInGitRepo(dir) {
55
+ try {
56
+ execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { stdio: 'ignore', cwd: dir });
57
+ return true;
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ }
63
+ function parseSeverity(s) {
64
+ if (['critical', 'high', 'medium', 'info'].includes(s))
65
+ return s;
66
+ throw new Error(`Invalid severity: ${s}`);
67
+ }
68
+ function scanDirectory(scanRoot, opts) {
69
+ const absRoot = resolve(scanRoot);
70
+ const configFiles = discoverConfigs(absRoot, opts.maxDepth);
71
+ const inGit = isInGitRepo(absRoot);
72
+ const allFindings = [];
73
+ for (const configPath of configFiles) {
74
+ let content;
75
+ try {
76
+ content = readFileSync(configPath, 'utf-8');
77
+ }
78
+ catch {
79
+ if (!opts.quiet) {
80
+ console.log(` ${configPath}\n [HIGH] MCP-SCAN: cannot read file (permission denied)\n`);
81
+ }
82
+ allFindings.push({
83
+ file: relative(absRoot, configPath),
84
+ rule_id: 'MCP-SCAN',
85
+ severity: 'high',
86
+ server_name: '(file)',
87
+ message: 'Cannot read MCP config file (permission denied).',
88
+ remediation: 'Check file permissions.',
89
+ });
90
+ continue;
91
+ }
92
+ const tracked = inGit ? isFileGitTracked(configPath) : true;
93
+ const findings = analyzeConfig(relative(absRoot, configPath), content, tracked);
94
+ allFindings.push(...findings);
95
+ }
96
+ // Filter by --severity threshold
97
+ const filtered = allFindings.filter(f => SEVERITY_RANK[f.severity] >= SEVERITY_RANK[opts.severity]);
98
+ if (opts.sarif) {
99
+ process.stdout.write(JSON.stringify(toSarif(filtered, VERSION), null, 2) + '\n');
100
+ return filtered.some(f => SEVERITY_RANK[f.severity] >= SEVERITY_RANK[opts.failOn]) ? 1 : 0;
101
+ }
102
+ if (opts.json) {
103
+ if (!opts.quiet)
104
+ process.stdout.write(JSON.stringify(filtered, null, 2) + '\n');
105
+ return filtered.some(f => SEVERITY_RANK[f.severity] >= SEVERITY_RANK[opts.failOn]) ? 1 : 0;
106
+ }
107
+ if (!opts.quiet) {
108
+ if (!opts.json) {
109
+ console.log(`mcpscan ${VERSION} -- scanning ${absRoot}\n`);
110
+ }
111
+ // Group by file
112
+ const byFile = new Map();
113
+ for (const f of filtered) {
114
+ if (!byFile.has(f.file))
115
+ byFile.set(f.file, []);
116
+ byFile.get(f.file).push(f);
117
+ }
118
+ for (const [file, findings] of byFile) {
119
+ console.log(` ${file}`);
120
+ for (const f of findings) {
121
+ console.log(` ${SEVERITY_LABEL[f.severity]} ${f.rule_id}: ${f.message}`);
122
+ if (f.rule_id !== 'MCP-006') {
123
+ console.log(` Remediation: ${f.remediation}`);
124
+ }
125
+ }
126
+ console.log('');
127
+ }
128
+ const critical = filtered.filter(f => f.severity === 'critical').length;
129
+ const high = filtered.filter(f => f.severity === 'high').length;
130
+ const medium = filtered.filter(f => f.severity === 'medium').length;
131
+ const info = filtered.filter(f => f.severity === 'info').length;
132
+ if (filtered.length === 0) {
133
+ console.log(`0 findings -- no MCP config files detected or all passed.`);
134
+ }
135
+ else {
136
+ console.log(`${filtered.length} findings (${critical} critical, ${high} high, ${medium} medium, ${info} info)`);
137
+ if (!opts.json) {
138
+ console.log('Run with --json for machine-readable output.');
139
+ }
140
+ }
141
+ }
142
+ return filtered.some(f => SEVERITY_RANK[f.severity] >= SEVERITY_RANK[opts.failOn]) ? 1 : 0;
143
+ }
144
+ const HOOK_TEMPLATE = `#!/bin/sh
145
+ # MCPScan pre-commit hook -- do not edit manually
146
+ # To bypass: MCPSCAN_SKIP=1 git commit
147
+ # mcpscan exit codes: 0 = clean, 1 = findings at/above --fail-on, 2 = scan error
148
+
149
+ if [ "\${MCPSCAN_SKIP}" = "1" ]; then
150
+ printf 'mcpscan: skipping MCP config scan (MCPSCAN_SKIP=1)\\n' >&2
151
+ exit 0
152
+ fi
153
+
154
+ if command -v mcpscan >/dev/null 2>&1; then
155
+ mcpscan . --fail-on high --quiet
156
+ MCPSCAN_EXIT=$?
157
+ else
158
+ npx --yes @mayhemx87/mcpscan@latest . --fail-on high --quiet
159
+ MCPSCAN_EXIT=$?
160
+ fi
161
+
162
+ if [ $MCPSCAN_EXIT -eq 1 ]; then
163
+ printf '\\nmcpscan: commit blocked -- high or critical MCP config findings detected.\\n' >&2
164
+ printf "Run 'mcpscan .' for details, or set MCPSCAN_SKIP=1 to bypass.\\n" >&2
165
+ elif [ $MCPSCAN_EXIT -ne 0 ]; then
166
+ printf '\\nmcpscan: commit blocked -- the scan failed to run (exit %s, NOT a security finding).\\n' "\$MCPSCAN_EXIT" >&2
167
+ printf "Run 'mcpscan .' for the error, or bypass once with MCPSCAN_SKIP=1 git commit.\\n" >&2
168
+ fi
169
+
170
+ exit $MCPSCAN_EXIT
171
+ `;
172
+ const HOOK_MARKER = '# MCPScan pre-commit hook';
173
+ function installHook(targetDir) {
174
+ const absDir = resolve(targetDir);
175
+ if (!isInGitRepo(absDir)) {
176
+ console.error(`mcpscan: no git repository found at ${absDir}`);
177
+ process.exit(2);
178
+ }
179
+ // --git-path respects core.hooksPath, worktrees, and submodules -- a
180
+ // hardcoded .git/hooks path silently installs a hook git never runs.
181
+ let hooksDir;
182
+ try {
183
+ const raw = execFileSync('git', ['rev-parse', '--git-path', 'hooks'], {
184
+ cwd: absDir,
185
+ encoding: 'utf-8',
186
+ }).trim();
187
+ hooksDir = resolve(absDir, raw);
188
+ }
189
+ catch {
190
+ console.error('mcpscan: could not determine git hooks directory');
191
+ process.exit(2);
192
+ }
193
+ mkdirSync(hooksDir, { recursive: true });
194
+ const hookPath = join(hooksDir, 'pre-commit');
195
+ if (existsSync(hookPath)) {
196
+ const existing = readFileSync(hookPath, 'utf-8');
197
+ if (existing.includes(HOOK_MARKER)) {
198
+ console.log('mcpscan: pre-commit hook already installed (idempotent -- no changes).');
199
+ return;
200
+ }
201
+ // Append to existing hook
202
+ console.warn(`mcpscan: existing pre-commit hook found at ${hookPath} -- appending mcpscan hook.`);
203
+ writeFileSync(hookPath, existing.trimEnd() + '\n\n' + HOOK_TEMPLATE, 'utf-8');
204
+ }
205
+ else {
206
+ writeFileSync(hookPath, HOOK_TEMPLATE, 'utf-8');
207
+ }
208
+ chmodSync(hookPath, 0o755);
209
+ console.log(`mcpscan: pre-commit hook installed at ${hookPath}`);
210
+ console.log('Commits will be blocked if high or critical MCP config findings are detected.');
211
+ console.log('Override: MCPSCAN_SKIP=1 git commit');
212
+ }
213
+ // ---- CLI definition ----
214
+ const program = new Command();
215
+ program
216
+ .name('mcpscan')
217
+ .description('Static scanner for malicious MCP config files in AI-integrated editor repos')
218
+ .version(VERSION, '-v, --version')
219
+ .argument('[path]', 'directory to scan', '.')
220
+ .option('--json', 'output findings as JSON array')
221
+ .option('--sarif', 'output findings as SARIF 2.1.0 (for GitHub code scanning)')
222
+ .option('--quiet', 'suppress all output (exit code only)')
223
+ .option('--max-depth <n>', 'limit directory traversal depth', parseInt)
224
+ .option('--severity <level>', 'minimum severity to report (critical|high|medium|info)', 'info')
225
+ .option('--fail-on <level>', 'exit non-zero if any finding at or above this severity (critical|high|medium)', 'high')
226
+ .action((scanPath, opts) => {
227
+ try {
228
+ const severity = parseSeverity(opts.severity);
229
+ const failOn = parseSeverity(opts.failOn);
230
+ if (opts.json && opts.sarif) {
231
+ throw new Error('--json and --sarif are mutually exclusive');
232
+ }
233
+ const maxDepth = opts.maxDepth;
234
+ if (maxDepth !== undefined && (!Number.isInteger(maxDepth) || maxDepth < 0)) {
235
+ throw new Error('--max-depth must be a non-negative integer');
236
+ }
237
+ const exitCode = scanDirectory(scanPath, {
238
+ json: Boolean(opts.json),
239
+ sarif: Boolean(opts.sarif),
240
+ quiet: Boolean(opts.quiet),
241
+ severity,
242
+ failOn,
243
+ maxDepth,
244
+ });
245
+ process.exit(exitCode);
246
+ }
247
+ catch (err) {
248
+ console.error(`mcpscan: error -- ${err.message}`);
249
+ process.exit(2);
250
+ }
251
+ });
252
+ program
253
+ .command('install-hook [path]')
254
+ .description('install mcpscan as a pre-commit hook in the git repository')
255
+ .action((hookPath = '.') => {
256
+ installHook(hookPath);
257
+ });
258
+ program
259
+ .command('rules')
260
+ .description('list all detection rules')
261
+ .action(() => {
262
+ console.log('MCPScan detection rules:\n');
263
+ for (const rule of RULES) {
264
+ console.log(` ${rule.id} [${rule.severity.toUpperCase()}]`);
265
+ console.log(` ${rule.description}\n`);
266
+ }
267
+ process.exit(0);
268
+ });
269
+ program
270
+ .command('version')
271
+ .description('print version')
272
+ .action(() => {
273
+ console.log(`mcpscan ${VERSION}`);
274
+ process.exit(0);
275
+ });
276
+ program.parse();
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Recursively discovers MCP config files under rootDir.
3
+ * Does not consult .gitignore -- security tools must scan all files.
4
+ * Skips node_modules and .git for performance.
5
+ */
6
+ export declare function discoverConfigs(rootDir: string, maxDepth?: number): string[];
@@ -0,0 +1,77 @@
1
+ import { readdirSync, lstatSync } from 'fs';
2
+ import { join, relative, resolve } from 'path';
3
+ // Only .git (never auto-loaded) and node_modules (never auto-loaded, huge) are
4
+ // skipped. Everything else is scanned -- a security tool must not assume a
5
+ // directory name like "dist" or "__tests__" makes a config harmless, because
6
+ // any subdirectory can be opened as a workspace root.
7
+ const SKIP_DIRS = new Set(['.git', 'node_modules']);
8
+ /**
9
+ * Returns true if the relative path from scan root matches a known
10
+ * auto-loading MCP config file pattern.
11
+ */
12
+ const MCP_CONFIG_SUFFIXES = [
13
+ '.mcp.json',
14
+ 'mcp.json',
15
+ '.amazonq/mcp.json',
16
+ '.cursor/mcp.json',
17
+ '.vscode/mcp.json',
18
+ '.windsurf/mcp.json',
19
+ '.roo/mcp.json',
20
+ '.kiro/settings/mcp.json',
21
+ // Embedded hosts (v0.2.0): MCP servers under a nested key; the engine
22
+ // ignores these files entirely when they carry no MCP section.
23
+ '.vscode/settings.json',
24
+ '.claude/settings.json',
25
+ '.claude/settings.local.json',
26
+ '.gemini/settings.json',
27
+ ];
28
+ function isMcpConfigPath(relPath) {
29
+ const p = relPath.replace(/\\/g, '/');
30
+ return MCP_CONFIG_SUFFIXES.some(s => p === s || p.endsWith('/' + s));
31
+ }
32
+ /**
33
+ * Recursively discovers MCP config files under rootDir.
34
+ * Does not consult .gitignore -- security tools must scan all files.
35
+ * Skips node_modules and .git for performance.
36
+ */
37
+ export function discoverConfigs(rootDir, maxDepth) {
38
+ const absRoot = resolve(rootDir);
39
+ const found = [];
40
+ walkDir(absRoot, absRoot, found, 0, maxDepth);
41
+ return found;
42
+ }
43
+ function walkDir(absRoot, currentDir, found, depth, maxDepth) {
44
+ if (maxDepth !== undefined && depth > maxDepth)
45
+ return;
46
+ let entries;
47
+ try {
48
+ entries = readdirSync(currentDir);
49
+ }
50
+ catch {
51
+ return; // permission denied or other OS error -- skip silently
52
+ }
53
+ for (const entry of entries) {
54
+ const fullPath = join(currentDir, entry);
55
+ // lstat, not stat: symlinked directories are never followed. This keeps
56
+ // traversal inside the scan root and immune to symlink loops -- a
57
+ // malicious repo must not be able to point the scanner at / or at itself.
58
+ let isDir = false;
59
+ try {
60
+ isDir = lstatSync(fullPath).isDirectory();
61
+ }
62
+ catch {
63
+ continue;
64
+ }
65
+ if (isDir) {
66
+ if (SKIP_DIRS.has(entry))
67
+ continue;
68
+ walkDir(absRoot, fullPath, found, depth + 1, maxDepth);
69
+ }
70
+ else {
71
+ const relPath = relative(absRoot, fullPath);
72
+ if (isMcpConfigPath(relPath)) {
73
+ found.push(fullPath);
74
+ }
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,28 @@
1
+ export type Severity = 'critical' | 'high' | 'medium' | 'info';
2
+ export declare const SEVERITY_RANK: Record<Severity, number>;
3
+ export interface RuleMeta {
4
+ id: string;
5
+ severity: Severity;
6
+ description: string;
7
+ }
8
+ export declare const RULES: RuleMeta[];
9
+ export interface Finding {
10
+ file: string;
11
+ rule_id: string;
12
+ severity: Severity;
13
+ server_name: string;
14
+ message: string;
15
+ remediation: string;
16
+ /** 1-based line of the server's definition in the config file, when resolvable. */
17
+ line?: number;
18
+ }
19
+ /** Embedded-host files (editor/agent settings.json) carry MCP servers under a
20
+ * nested key; a settings file with NO MCP section is out of scope entirely --
21
+ * no findings, not even MCP-005, or every untracked settings.json in the wild
22
+ * would produce noise. */
23
+ export declare function isEmbeddedHostFile(filePath: string): boolean;
24
+ /**
25
+ * Statically analyzes the content of an MCP config file and returns findings.
26
+ * Pure function -- no filesystem access, no subprocesses, no network calls.
27
+ */
28
+ export declare function analyzeConfig(filePath: string, content: string, isGitTracked: boolean): Finding[];
package/dist/engine.js ADDED
@@ -0,0 +1,289 @@
1
+ import { parse as parseJsonc, parseTree, findNodeAtLocation, } from 'jsonc-parser';
2
+ export const SEVERITY_RANK = {
3
+ critical: 4,
4
+ high: 3,
5
+ medium: 2,
6
+ info: 1,
7
+ };
8
+ export const RULES = [
9
+ { id: 'MCP-001', severity: 'critical', description: 'Server command or args execute a remote URL or network tool (curl, wget, https://)' },
10
+ { id: 'MCP-002', severity: 'high', description: 'Server uses npx/uvx/pipx with an unversioned package, in command or args (supply-chain risk)' },
11
+ { id: 'MCP-003', severity: 'high', description: 'Server env block inherits high-value credential env vars (AWS_*, GITHUB_TOKEN, etc.)' },
12
+ { id: 'MCP-004', severity: 'medium', description: 'Server command or args reference a path outside the repository (absolute or ../)' },
13
+ { id: 'MCP-005', severity: 'medium', description: 'MCP config file is not tracked by git (may have been injected)' },
14
+ { id: 'MCP-006', severity: 'info', description: 'Server passed all detection rules (inventory signal)' },
15
+ { id: 'MCP-007', severity: 'critical', description: 'Hardcoded secret-looking value in env or headers (committed credential; ${VAR} interpolations exempt)' },
16
+ { id: 'MCP-008', severity: 'info', description: 'Remote MCP server (url-based) declared -- inventory; escalates to HIGH over insecure http://' },
17
+ { id: 'MCP-009', severity: 'medium', description: 'Shell command-chaining metacharacters in command/args ($(), backticks, | sh)' },
18
+ { id: 'MCP-PARSE', severity: 'high', description: 'MCP config file has invalid JSON syntax -- cannot verify safety' },
19
+ { id: 'MCP-SCAN', severity: 'high', description: 'MCP config file could not be read (permission denied)' },
20
+ ];
21
+ const CREDENTIAL_PATTERNS = [
22
+ /^AWS_/,
23
+ /^GITHUB_TOKEN$/,
24
+ /^ANTHROPIC_API_KEY$/,
25
+ /^OPENAI_API_KEY$/,
26
+ /^AZURE_/,
27
+ /^GCP_/,
28
+ /^GOOGLE_APPLICATION_CREDENTIALS$/,
29
+ /SECRET/i,
30
+ /PRIVATE_KEY/i,
31
+ ];
32
+ /** Literal secret-looking VALUES (v0.2.0, MCP-007). A hardcoded credential in
33
+ * a committed config is worse than env inheritance. `${...}`/`$VAR`
34
+ * interpolations are exempt -- they resolve at runtime, nothing is committed. */
35
+ const SECRET_VALUE_PATTERNS = [
36
+ /AKIA[0-9A-Z]{16}/, // AWS access key id
37
+ /sk-ant-[A-Za-z0-9_-]{8,}/, // Anthropic
38
+ /sk-[A-Za-z0-9]{20,}/, // OpenAI-style
39
+ /ghp_[A-Za-z0-9]{36}/, // GitHub PAT (classic)
40
+ /github_pat_[A-Za-z0-9_]{20,}/, // GitHub PAT (fine-grained)
41
+ /xox[abps]-[A-Za-z0-9-]{10,}/, // Slack
42
+ /AIza[0-9A-Za-z_-]{35}/, // Google API key
43
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
44
+ ];
45
+ function isHighValueCredential(key) {
46
+ return CREDENTIAL_PATTERNS.some(p => p.test(key));
47
+ }
48
+ function isInterpolation(value) {
49
+ return value.includes('${') || value.trim().startsWith('$');
50
+ }
51
+ function looksLikeLiteralSecret(value) {
52
+ if (isInterpolation(value))
53
+ return false;
54
+ return SECRET_VALUE_PATTERNS.some(p => p.test(value));
55
+ }
56
+ /**
57
+ * Returns true if the command line contains npx/uvx/pipx with an unversioned
58
+ * package (handles compound commands like cmd1 && npx pkg). Runner flags
59
+ * (-y, --yes, -q, --package=...) are skipped to find the real package token.
60
+ * A version pin looks like @1.2.3 after the package name; scoped packages
61
+ * (@scope/name) need a second @.
62
+ */
63
+ function isUnversionedPackageRunner(line) {
64
+ const matches = [...line.matchAll(/\b(npx|uvx|pipx)\s+((?:[-@\w./=:]+\s+)*?)([@\w][\w./@-]*)/g)];
65
+ for (const match of matches) {
66
+ // Walk tokens after the runner, skipping flags, to the package token.
67
+ const tokens = (match[2] + match[3]).trim().split(/\s+/).filter(Boolean);
68
+ const pkg = tokens.find(t => !t.startsWith('-'));
69
+ if (!pkg)
70
+ continue;
71
+ if (pkg.startsWith('@')) {
72
+ if (!pkg.slice(1).includes('@'))
73
+ return true; // @scope/pkg without @version
74
+ }
75
+ else if (!pkg.includes('@')) {
76
+ return true; // pkg without @version
77
+ }
78
+ }
79
+ return false;
80
+ }
81
+ /** Embedded-host files (editor/agent settings.json) carry MCP servers under a
82
+ * nested key; a settings file with NO MCP section is out of scope entirely --
83
+ * no findings, not even MCP-005, or every untracked settings.json in the wild
84
+ * would produce noise. */
85
+ export function isEmbeddedHostFile(filePath) {
86
+ const p = filePath.replace(/\\/g, '/');
87
+ return p.endsWith('settings.json') || p.endsWith('settings.local.json');
88
+ }
89
+ /** Extract the server map from any known config shape:
90
+ * - dedicated files: { mcpServers: {...} } or VS Code's { servers: {...} }
91
+ * - embedded hosts: { mcpServers: {...} } (Claude/Gemini settings) or
92
+ * { mcp: { servers: {...} } } (VS Code settings.json)
93
+ * Also returns the JSON path to the map so findings can carry line numbers. */
94
+ function extractServers(config) {
95
+ if (config.mcpServers && typeof config.mcpServers === 'object') {
96
+ return { servers: config.mcpServers, path: ['mcpServers'] };
97
+ }
98
+ if (config.servers && typeof config.servers === 'object') {
99
+ return { servers: config.servers, path: ['servers'] };
100
+ }
101
+ const mcp = config.mcp;
102
+ if (mcp && typeof mcp === 'object' && mcp.servers && typeof mcp.servers === 'object') {
103
+ return { servers: mcp.servers, path: ['mcp', 'servers'] };
104
+ }
105
+ return null;
106
+ }
107
+ /**
108
+ * Statically analyzes the content of an MCP config file and returns findings.
109
+ * Pure function -- no filesystem access, no subprocesses, no network calls.
110
+ */
111
+ export function analyzeConfig(filePath, content, isGitTracked) {
112
+ const findings = [];
113
+ const embedded = isEmbeddedHostFile(filePath);
114
+ const errors = [];
115
+ let config;
116
+ try {
117
+ config = parseJsonc(content, errors);
118
+ }
119
+ catch {
120
+ return embedded ? [] : [parseErrorFinding(filePath)];
121
+ }
122
+ if (errors.length > 0 || config === null || typeof config !== 'object' || Array.isArray(config)) {
123
+ return embedded ? [] : [parseErrorFinding(filePath)];
124
+ }
125
+ const extracted = extractServers(config);
126
+ const servers = extracted?.servers ?? null;
127
+ // Embedded settings files without an MCP section are out of scope.
128
+ if (embedded && servers === null)
129
+ return [];
130
+ // Best-effort parse tree so findings can carry the line each server is
131
+ // defined on (SARIF regions, clickable output). Analysis never depends on it.
132
+ let tree;
133
+ try {
134
+ tree = parseTree(content);
135
+ }
136
+ catch {
137
+ tree = undefined;
138
+ }
139
+ const lineOf = (serverName) => {
140
+ if (!tree || !extracted)
141
+ return undefined;
142
+ const node = findNodeAtLocation(tree, [...extracted.path, serverName]);
143
+ if (!node)
144
+ return undefined;
145
+ return content.slice(0, node.offset).split('\n').length;
146
+ };
147
+ // MCP-005: file not tracked by git (may have been injected)
148
+ if (!isGitTracked) {
149
+ findings.push({
150
+ file: filePath,
151
+ rule_id: 'MCP-005',
152
+ severity: 'medium',
153
+ server_name: '(file)',
154
+ message: 'MCP config file is not tracked by git -- may have been injected.',
155
+ remediation: 'Run `git add` to track intentionally, or delete if not placed by your team.',
156
+ });
157
+ }
158
+ for (const [serverName, server] of Object.entries(servers ?? {})) {
159
+ if (!server || typeof server !== 'object')
160
+ continue;
161
+ // Analyze command AND args as one line -- the dominant real-world shape
162
+ // is {"command": "npx", "args": ["-y", "pkg"]}, invisible to any rule
163
+ // that inspects `command` alone (the v0.1.0 gap).
164
+ const args = Array.isArray(server.args) ? server.args.filter(a => typeof a === 'string') : [];
165
+ const line = [server.command ?? '', ...args].join(' ').trim();
166
+ const serverFindings = [];
167
+ // MCP-001: remote URL or network command execution
168
+ if (line && (/https?:\/\//.test(line) || /\b(curl|wget)\b/.test(line))) {
169
+ serverFindings.push({
170
+ file: filePath,
171
+ rule_id: 'MCP-001',
172
+ severity: 'critical',
173
+ server_name: serverName,
174
+ message: `Server "${serverName}" executes a remote URL or network command: ${line}`,
175
+ remediation: 'Replace with a locally-installed binary or a pinned, audited npm package.',
176
+ });
177
+ }
178
+ // MCP-002: unversioned package runner (supply-chain risk)
179
+ if (line && isUnversionedPackageRunner(line)) {
180
+ serverFindings.push({
181
+ file: filePath,
182
+ rule_id: 'MCP-002',
183
+ severity: 'high',
184
+ server_name: serverName,
185
+ message: `Server "${serverName}" runs an unversioned package: ${line}`,
186
+ remediation: 'Pin to a specific version (e.g., npx package@1.2.3) and audit the package.',
187
+ });
188
+ }
189
+ // MCP-003: high-value credential env inheritance (key names)
190
+ const env = server.env ?? {};
191
+ for (const key of Object.keys(env)) {
192
+ if (isHighValueCredential(key)) {
193
+ serverFindings.push({
194
+ file: filePath,
195
+ rule_id: 'MCP-003',
196
+ severity: 'high',
197
+ server_name: serverName,
198
+ message: `Server "${serverName}" inherits high-value credential env var: ${key}`,
199
+ remediation: 'Review whether this server requires live credentials and scope to least-privilege.',
200
+ });
201
+ break; // one MCP-003 finding per server
202
+ }
203
+ }
204
+ // MCP-004: absolute path or path traversal outside repo
205
+ const cmd = server.command ?? '';
206
+ if (cmd.startsWith('/') ||
207
+ args.some(a => a.startsWith('/')) ||
208
+ /(?:^|[\s;|&])\.\.\//.test(line)) {
209
+ serverFindings.push({
210
+ file: filePath,
211
+ rule_id: 'MCP-004',
212
+ severity: 'medium',
213
+ server_name: serverName,
214
+ message: `Server "${serverName}" references a path outside the repository: ${line}`,
215
+ remediation: 'Use a relative path within the repo or a globally-installed binary on PATH.',
216
+ });
217
+ }
218
+ // MCP-007: hardcoded secret-looking value in env or headers
219
+ for (const [source, record] of [['env', env], ['headers', server.headers ?? {}]]) {
220
+ for (const [key, value] of Object.entries(record)) {
221
+ if (typeof value === 'string' && looksLikeLiteralSecret(value)) {
222
+ serverFindings.push({
223
+ file: filePath,
224
+ rule_id: 'MCP-007',
225
+ severity: 'critical',
226
+ server_name: serverName,
227
+ message: `Server "${serverName}" has a hardcoded secret-looking value in ${source}.${key} -- a committed credential.`,
228
+ remediation: 'Remove the literal secret, rotate it immediately, and use an env interpolation (${VAR}) instead.',
229
+ });
230
+ }
231
+ }
232
+ }
233
+ // MCP-008: remote MCP server (url-based) -- invisible in v0.1.0
234
+ if (typeof server.url === 'string' && server.url.length > 0) {
235
+ const insecure = server.url.startsWith('http://');
236
+ serverFindings.push({
237
+ file: filePath,
238
+ rule_id: 'MCP-008',
239
+ severity: insecure ? 'high' : 'info',
240
+ server_name: serverName,
241
+ message: insecure
242
+ ? `Server "${serverName}" connects to a remote MCP endpoint over insecure HTTP: ${server.url}`
243
+ : `Server "${serverName}" is a remote MCP endpoint: ${server.url} -- verify the endpoint is trusted.`,
244
+ remediation: insecure
245
+ ? 'Use HTTPS for remote MCP endpoints.'
246
+ : 'Confirm this endpoint is operated by a party you trust; remote servers receive your prompts and tool traffic.',
247
+ });
248
+ }
249
+ // MCP-009: shell metacharacters (execution chaining without literal curl/URL)
250
+ if (line && (/\$\(/.test(line) || /`/.test(line) || /\|\s*(sh|bash|zsh)\b/.test(line))) {
251
+ serverFindings.push({
252
+ file: filePath,
253
+ rule_id: 'MCP-009',
254
+ severity: 'medium',
255
+ server_name: serverName,
256
+ message: `Server "${serverName}" uses shell command-chaining metacharacters: ${line}`,
257
+ remediation: 'Avoid command substitution and pipes in MCP server commands; invoke a single audited binary directly.',
258
+ });
259
+ }
260
+ // MCP-006: clean server -- no other findings for this server
261
+ if (serverFindings.length === 0) {
262
+ serverFindings.push({
263
+ file: filePath,
264
+ rule_id: 'MCP-006',
265
+ severity: 'info',
266
+ server_name: serverName,
267
+ message: `Server "${serverName}" passed all detection rules.`,
268
+ remediation: 'No action required.',
269
+ });
270
+ }
271
+ const lineNo = lineOf(serverName);
272
+ if (lineNo !== undefined) {
273
+ for (const f of serverFindings)
274
+ f.line = lineNo;
275
+ }
276
+ findings.push(...serverFindings);
277
+ }
278
+ return findings;
279
+ }
280
+ function parseErrorFinding(filePath) {
281
+ return {
282
+ file: filePath,
283
+ rule_id: 'MCP-PARSE',
284
+ severity: 'high',
285
+ server_name: '(unknown)',
286
+ message: 'MCP config file has invalid JSON syntax -- cannot verify safety.',
287
+ remediation: 'Fix the JSON syntax or remove the file if it is not a valid MCP config.',
288
+ };
289
+ }
@@ -0,0 +1,2 @@
1
+ import { type Finding } from './engine.js';
2
+ export declare function toSarif(findings: Finding[], version: string): object;
package/dist/sarif.js ADDED
@@ -0,0 +1,60 @@
1
+ import { RULES } from './engine.js';
2
+ /** SARIF 2.1.0 output for GitHub code scanning and other SARIF consumers. */
3
+ const LEVEL = {
4
+ critical: 'error',
5
+ high: 'error',
6
+ medium: 'warning',
7
+ info: 'note',
8
+ };
9
+ // GitHub code scanning buckets: 9.0+ critical, 7.0-8.9 high, 4.0-6.9 medium.
10
+ const SECURITY_SEVERITY = {
11
+ critical: '9.5',
12
+ high: '8.0',
13
+ medium: '5.0',
14
+ info: '0.0',
15
+ };
16
+ export function toSarif(findings, version) {
17
+ const ruleIndex = new Map(RULES.map((r, i) => [r.id, i]));
18
+ return {
19
+ $schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
20
+ version: '2.1.0',
21
+ runs: [
22
+ {
23
+ tool: {
24
+ driver: {
25
+ name: 'mcpscan',
26
+ version,
27
+ informationUri: 'https://github.com/mayhemx87/mcpscan',
28
+ rules: RULES.map(r => ({
29
+ id: r.id,
30
+ shortDescription: { text: r.description },
31
+ helpUri: 'https://github.com/mayhemx87/mcpscan#detection-rules',
32
+ defaultConfiguration: { level: LEVEL[r.severity] },
33
+ properties: {
34
+ 'security-severity': SECURITY_SEVERITY[r.severity],
35
+ tags: ['security', 'supply-chain', 'mcp'],
36
+ },
37
+ })),
38
+ },
39
+ },
40
+ results: findings.map(f => ({
41
+ ruleId: f.rule_id,
42
+ ...(ruleIndex.has(f.rule_id) ? { ruleIndex: ruleIndex.get(f.rule_id) } : {}),
43
+ level: LEVEL[f.severity],
44
+ message: { text: `${f.message} Remediation: ${f.remediation}` },
45
+ locations: [
46
+ {
47
+ physicalLocation: {
48
+ artifactLocation: {
49
+ uri: f.file.replace(/\\/g, '/'),
50
+ uriBaseId: '%SRCROOT%',
51
+ },
52
+ region: { startLine: f.line ?? 1 },
53
+ },
54
+ },
55
+ ],
56
+ })),
57
+ },
58
+ ],
59
+ };
60
+ }
@@ -0,0 +1,2 @@
1
+ declare const _default: import("vite").UserConfig;
2
+ export default _default;
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config';
2
+ export default defineConfig({
3
+ test: {
4
+ include: ['__tests__/**/*.test.ts'],
5
+ environment: 'node',
6
+ testTimeout: 15000,
7
+ },
8
+ });
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@mayhemx87/mcpscan",
3
+ "version": "0.3.1",
4
+ "description": "Static scanner for malicious MCP config files in AI-integrated editor repos",
5
+ "type": "module",
6
+ "bin": {
7
+ "mcpscan": "dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md"
12
+ ],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/mayhemx87/mcpscan.git"
16
+ },
17
+ "homepage": "https://github.com/mayhemx87/mcpscan#readme",
18
+ "bugs": {
19
+ "url": "https://github.com/mayhemx87/mcpscan/issues"
20
+ },
21
+ "scripts": {
22
+ "build": "tsc",
23
+ "prepare": "tsc",
24
+ "prepublishOnly": "npm test && npm run build",
25
+ "test": "vitest run",
26
+ "test:watch": "vitest"
27
+ },
28
+ "keywords": [
29
+ "mcp",
30
+ "security",
31
+ "scanner",
32
+ "static-analysis",
33
+ "supply-chain",
34
+ "sarif",
35
+ "ai",
36
+ "claude",
37
+ "cursor",
38
+ "vscode"
39
+ ],
40
+ "dependencies": {
41
+ "commander": "^12.0.0",
42
+ "jsonc-parser": "^3.3.1"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^22.0.0",
46
+ "tsx": "^4.19.2",
47
+ "typescript": "^5.4.0",
48
+ "vitest": "^4.1.10"
49
+ },
50
+ "engines": {
51
+ "node": ">=20.0.0"
52
+ },
53
+ "license": "MIT",
54
+ "overrides": {
55
+ "nanoid": "3.3.18"
56
+ }
57
+ }