@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 +21 -0
- package/README.md +181 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +276 -0
- package/dist/discovery.d.ts +6 -0
- package/dist/discovery.js +77 -0
- package/dist/engine.d.ts +28 -0
- package/dist/engine.js +289 -0
- package/dist/sarif.d.ts +2 -0
- package/dist/sarif.js +60 -0
- package/dist/vitest.config.d.ts +2 -0
- package/dist/vitest.config.js +8 -0
- package/package.json +57 -0
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
|
+
[](https://github.com/mayhemx87/mcpscan/actions/workflows/ci.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+

|
|
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
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
|
+
}
|
package/dist/engine.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/sarif.d.ts
ADDED
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
|
+
}
|
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
|
+
}
|