mcpvetter 0.0.0-stage → 0.1.0
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 +78 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +236 -0
- package/dist/code/labs.d.ts +12 -0
- package/dist/code/labs.js +25 -0
- package/dist/code/review.d.ts +8 -0
- package/dist/code/review.js +90 -0
- package/dist/code/rules/auth.d.ts +2 -0
- package/dist/code/rules/auth.js +94 -0
- package/dist/code/rules/idor.d.ts +2 -0
- package/dist/code/rules/idor.js +53 -0
- package/dist/code/rules/llmout.d.ts +2 -0
- package/dist/code/rules/llmout.js +50 -0
- package/dist/code/rules/mcpcfg.d.ts +2 -0
- package/dist/code/rules/mcpcfg.js +180 -0
- package/dist/code/rules/prompt.d.ts +2 -0
- package/dist/code/rules/prompt.js +52 -0
- package/dist/code/rules/secret.d.ts +2 -0
- package/dist/code/rules/secret.js +60 -0
- package/dist/code/rules/sqli.d.ts +3 -0
- package/dist/code/rules/sqli.js +73 -0
- package/dist/code/rules/ssrf.d.ts +2 -0
- package/dist/code/rules/ssrf.js +59 -0
- package/dist/code/rules/upload.d.ts +2 -0
- package/dist/code/rules/upload.js +54 -0
- package/dist/code/rules/xss.d.ts +2 -0
- package/dist/code/rules/xss.js +73 -0
- package/dist/code/taint.d.ts +41 -0
- package/dist/code/taint.js +212 -0
- package/dist/engine/redact.d.ts +20 -0
- package/dist/engine/redact.js +48 -0
- package/dist/engine/registry.d.ts +4 -0
- package/dist/engine/registry.js +9 -0
- package/dist/engine/types.d.ts +65 -0
- package/dist/engine/types.js +27 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +12 -0
- package/dist/mcp/backend.d.ts +122 -0
- package/dist/mcp/backend.js +12 -0
- package/dist/mcp/local-backend.d.ts +31 -0
- package/dist/mcp/local-backend.js +90 -0
- package/dist/mcp/stdio.d.ts +5 -0
- package/dist/mcp/stdio.js +19 -0
- package/dist/mcp/tools.d.ts +9 -0
- package/dist/mcp/tools.js +245 -0
- package/dist/scanner/discover.d.ts +8 -0
- package/dist/scanner/discover.js +39 -0
- package/dist/scanner/fingerprint.d.ts +15 -0
- package/dist/scanner/fingerprint.js +77 -0
- package/dist/scanner/index.d.ts +32 -0
- package/dist/scanner/index.js +164 -0
- package/dist/scanner/known.d.ts +9 -0
- package/dist/scanner/known.js +90 -0
- package/dist/scanner/launch.d.ts +25 -0
- package/dist/scanner/launch.js +132 -0
- package/dist/scanner/parse.d.ts +11 -0
- package/dist/scanner/parse.js +164 -0
- package/dist/scanner/probe.d.ts +9 -0
- package/dist/scanner/probe.js +71 -0
- package/dist/scanner/report.d.ts +7 -0
- package/dist/scanner/report.js +88 -0
- package/dist/scanner/rules/live.d.ts +20 -0
- package/dist/scanner/rules/live.js +167 -0
- package/dist/scanner/rules/static.d.ts +14 -0
- package/dist/scanner/rules/static.js +361 -0
- package/dist/scanner/types.d.ts +112 -0
- package/dist/scanner/types.js +1 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +1 -0
- package/package.json +50 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MCPVetter
|
|
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
CHANGED
|
@@ -1,3 +1,79 @@
|
|
|
1
|
-
#
|
|
1
|
+
# mcpvetter
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Audit the MCP servers your AI assistant uses: leaked secrets, unpinned or look-alike packages, risky launch commands, unencrypted remote servers, over-broad file access, poisoned tool descriptions, hidden Unicode, tool shadowing, and silent tool changes.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx mcpvetter scan # discover + static scan, offline
|
|
7
|
+
npx mcpvetter scan --live # also read each server's tool descriptions (asks first)
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Local, no account, no telemetry. A static scan reads your MCP config files and nothing else. It never changes a config file.
|
|
11
|
+
|
|
12
|
+
## What a finding looks like
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
■ github
|
|
16
|
+
[HIGH] Plain-text secret in MCP config MCP-SECRET-001 · confidence high · static
|
|
17
|
+
server: github file: .mcp.json
|
|
18
|
+
Evidence: env GITHUB_PERSONAL_ACCESS_TOKEN=ghp_…[redacted 40 chars] (GitHub token)
|
|
19
|
+
Why it matters: Anything that can read this file, including other tools, backups, sync
|
|
20
|
+
folders, and anyone the file is shared or committed with, gets a working credential.
|
|
21
|
+
Fix: Rotate the credential first (removing it does not un-leak it), then reference it
|
|
22
|
+
from the environment or a secret store instead of the literal value. Claude Code and
|
|
23
|
+
Claude Desktop expand "${VAR}" in .mcp.json, Cursor expands "${env:VAR}", and VS Code
|
|
24
|
+
supports "${input:...}" prompts or an "envFile".
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Every finding has a stable ID, separate severity and confidence, redacted evidence, a plain-English reason, and a concrete fix. What each rule checks, and how, is published at [mcpvetter.com/rules](https://mcpvetter.com/rules) and in [src/scanner/rules](./src/scanner/rules).
|
|
28
|
+
|
|
29
|
+
## Clients discovered
|
|
30
|
+
|
|
31
|
+
Claude Code (`~/.claude.json`, `.mcp.json`), Claude Desktop, Cursor (`~/.cursor/mcp.json`, `.cursor/mcp.json`), VS Code (`mcp.json`, `settings.json`, `.vscode/mcp.json`), Windsurf, Gemini CLI. Or pass `--config <file>`.
|
|
32
|
+
|
|
33
|
+
## Commands
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
mcpvetter scan [--live] [--yes] [--config <file>]... [--json [file]] [--summary] [--fail-on <severity>]
|
|
37
|
+
mcpvetter list inventory only
|
|
38
|
+
mcpvetter explain MCP-POISON-001 explain a finding
|
|
39
|
+
mcpvetter rules every rule and what it checks
|
|
40
|
+
mcpvetter suppress <ID> --server <name> --reason "<why>"
|
|
41
|
+
mcpvetter accept <server> --reason "<why>" accept a server's new tool fingerprint
|
|
42
|
+
mcpvetter serve run as a local MCP server (stdio)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Exit codes: `0` no findings at or above `--fail-on` (default `high`), `1` findings at or above it, `2` scan could not complete. Use it in CI as-is.
|
|
46
|
+
|
|
47
|
+
Suppressions are always listed in the report, so a shared report cannot hide them.
|
|
48
|
+
|
|
49
|
+
## About `--live`
|
|
50
|
+
|
|
51
|
+
To read tool descriptions, MCPVetter has to start each stdio server, exactly as your AI client does every time it launches. It asks before each one, runs it with a minimal environment plus the env your config declares, and only calls `initialize`, `tools/list`, `prompts/list`, and `resources/list`. It never calls a tool. The first approved probe records a fingerprint of each server's tools in `~/.mcpvetter/fingerprints.json`; later probes report any change (a "rug pull") with a before/after diff.
|
|
52
|
+
|
|
53
|
+
## Inside your AI assistant
|
|
54
|
+
|
|
55
|
+
Claude Code:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
claude mcp add mcpvetter -- npx -y mcpvetter@0.1.0 serve
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Cursor / VS Code / Claude Desktop (`mcpServers` or `servers`):
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
{ "mcpServers": { "mcpvetter": { "command": "npx", "args": ["-y", "mcpvetter@0.1.0", "serve"] } } }
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Then ask: "audit my MCP setup".
|
|
68
|
+
|
|
69
|
+
Free without an account: `check_mcp_setup`, `explain_finding`. With an API key (`MCPVETTER_API_KEY`, free tier available at [mcpvetter.com/account](https://mcpvetter.com/account)): `review_code`, `secure_pattern`, `start_lab`, `check_lab`, `team_posture`. Code review runs on your machine; only rule IDs and counts are sent for your history. Plans: [mcpvetter.com/pricing](https://mcpvetter.com/pricing).
|
|
70
|
+
|
|
71
|
+
## Privacy
|
|
72
|
+
|
|
73
|
+
- Static analysis runs on your machine. Without an API key, MCPVetter makes no network requests.
|
|
74
|
+
- With an API key, only rule IDs, severities, and counts are sent. Never code, evidence, file paths, or server names.
|
|
75
|
+
- Reports redact secret values by default.
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
MIT
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
import { reviewCode } from "./code/review.js";
|
|
7
|
+
import { getRuleMeta, allRuleMetas } from "./engine/registry.js";
|
|
8
|
+
import { atOrAbove, SEVERITY_ORDER } from "./engine/types.js";
|
|
9
|
+
import { LocalBackend, apiConfig } from "./mcp/local-backend.js";
|
|
10
|
+
import { fingerprint, loadBaseline, saveBaseline, stateDir } from "./scanner/fingerprint.js";
|
|
11
|
+
import { addSuppression, loadFiles, redactArg, redactUrl, scan } from "./scanner/index.js";
|
|
12
|
+
import { probeServer } from "./scanner/probe.js";
|
|
13
|
+
import { formatFinding, formatReport, shareableSummary } from "./scanner/report.js";
|
|
14
|
+
import { VERSION } from "./version.js";
|
|
15
|
+
const HELP = `mcpvetter ${VERSION}: audit your MCP servers and AI-generated code
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
mcpvetter scan [--live] [--yes] [--config <file>]... [--json [file]] [--summary] [--fail-on <severity>]
|
|
19
|
+
mcpvetter list [--config <file>]... Inventory only, no findings
|
|
20
|
+
mcpvetter explain <ID> Explain a finding ID (e.g. MCP-POISON-001)
|
|
21
|
+
mcpvetter rules List every rule and what it checks
|
|
22
|
+
mcpvetter suppress <ID> --server <name> --reason "<why>"
|
|
23
|
+
mcpvetter accept <server> --reason "<why>" Accept a server's new tool fingerprint after drift
|
|
24
|
+
mcpvetter review <file>... [--json] [--fail-on <severity>] Review code (needs an API key)
|
|
25
|
+
mcpvetter auth <api-key> Save your API key to ~/.mcpvetter/config.json
|
|
26
|
+
mcpvetter serve Run as a local MCP server over stdio
|
|
27
|
+
|
|
28
|
+
Exit codes: 0 = no findings at or above --fail-on (default high), 1 = findings at or above it, 2 = could not complete.
|
|
29
|
+
Static scans are local and offline. Nothing is sent anywhere unless you configure an API key.
|
|
30
|
+
--live starts each configured server (exactly as your AI client would) to read its tool list. It asks first.`;
|
|
31
|
+
function severityArg(v) {
|
|
32
|
+
const s = String(v ?? "high").toLowerCase();
|
|
33
|
+
if (!SEVERITY_ORDER.includes(s))
|
|
34
|
+
throw new Error(`--fail-on must be one of ${SEVERITY_ORDER.join(", ")}`);
|
|
35
|
+
return s;
|
|
36
|
+
}
|
|
37
|
+
async function ask(q) {
|
|
38
|
+
if (!process.stdin.isTTY)
|
|
39
|
+
return false;
|
|
40
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
41
|
+
const a = (await rl.question(q)).trim().toLowerCase();
|
|
42
|
+
rl.close();
|
|
43
|
+
return a === "y" || a === "yes";
|
|
44
|
+
}
|
|
45
|
+
async function main(argv) {
|
|
46
|
+
const [cmd, ...rest] = argv;
|
|
47
|
+
const { values, positionals } = parseArgs({
|
|
48
|
+
args: rest,
|
|
49
|
+
allowPositionals: true,
|
|
50
|
+
options: {
|
|
51
|
+
live: { type: "boolean" },
|
|
52
|
+
yes: { type: "boolean", short: "y" },
|
|
53
|
+
config: { type: "string", multiple: true },
|
|
54
|
+
json: { type: "boolean" },
|
|
55
|
+
out: { type: "string", short: "o" },
|
|
56
|
+
summary: { type: "boolean" },
|
|
57
|
+
"fail-on": { type: "string" },
|
|
58
|
+
server: { type: "string" },
|
|
59
|
+
reason: { type: "string" },
|
|
60
|
+
language: { type: "string" },
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
// `--json out.json` form: treat a following .json positional as the output file.
|
|
64
|
+
let jsonOut = values.out;
|
|
65
|
+
if (values.json && !jsonOut && positionals[0]?.endsWith(".json") && cmd === "scan")
|
|
66
|
+
jsonOut = positionals.shift();
|
|
67
|
+
const origin = apiConfig().origin;
|
|
68
|
+
switch (cmd) {
|
|
69
|
+
case "scan": {
|
|
70
|
+
const failOn = severityArg(values["fail-on"]);
|
|
71
|
+
const r = await scan({
|
|
72
|
+
paths: values.config,
|
|
73
|
+
live: values.live,
|
|
74
|
+
confirm: values.live
|
|
75
|
+
? async (s) => {
|
|
76
|
+
if (values.yes)
|
|
77
|
+
return true;
|
|
78
|
+
const what = s.command ? `run "${[s.command, ...s.args.map(redactArg)].join(" ").slice(0, 100)}"` : `connect to ${redactUrl(s.url)}`;
|
|
79
|
+
return ask(`Probe "${s.name}"? This will ${what} and list its tools (no tool is called). [y/N] `);
|
|
80
|
+
}
|
|
81
|
+
: undefined,
|
|
82
|
+
onProgress: (m) => process.stderr.write(`${m}\n`),
|
|
83
|
+
});
|
|
84
|
+
if (values.json) {
|
|
85
|
+
const body = JSON.stringify(r, null, 2);
|
|
86
|
+
if (jsonOut)
|
|
87
|
+
writeFileSync(jsonOut, body);
|
|
88
|
+
else
|
|
89
|
+
process.stdout.write(body + "\n");
|
|
90
|
+
}
|
|
91
|
+
if (!values.json || jsonOut)
|
|
92
|
+
console.log(values.summary ? shareableSummary(r) : formatReport(r, origin));
|
|
93
|
+
if (r.files.length && r.files.every((f) => f.error))
|
|
94
|
+
return 2;
|
|
95
|
+
return r.findings.some((f) => atOrAbove(f.severity, failOn)) ? 1 : 0;
|
|
96
|
+
}
|
|
97
|
+
case "list": {
|
|
98
|
+
const { files } = loadFiles({ paths: values.config });
|
|
99
|
+
if (!files.length)
|
|
100
|
+
console.log("No MCP configuration found. Pass --config <file>.");
|
|
101
|
+
for (const f of files) {
|
|
102
|
+
console.log(`${f.client} · ${f.path}${f.error ? ` (error: ${f.error})` : ""}`);
|
|
103
|
+
for (const s of f.servers) {
|
|
104
|
+
const launch = redactUrl(s.url) ?? [s.command, ...s.args.map(redactArg)].join(" ");
|
|
105
|
+
console.log(` - ${s.name} [${s.transport}${s.disabled ? ", disabled" : ""}] ${launch.slice(0, 100)}${Object.keys(s.env).length ? ` env: ${Object.keys(s.env).join(", ")}` : ""}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
case "explain": {
|
|
111
|
+
const id = positionals[0]?.toUpperCase();
|
|
112
|
+
const m = id ? getRuleMeta(id) : undefined;
|
|
113
|
+
if (!m) {
|
|
114
|
+
console.error(`Unknown ID "${positionals[0] ?? ""}". Run "mcpvetter rules" for the list.`);
|
|
115
|
+
return 2;
|
|
116
|
+
}
|
|
117
|
+
console.log(`${m.id}: ${m.title}\nDefault severity: ${m.severity} · confidence ${m.confidence} · ${m.evidenceKind}\n\nWhy it matters: ${m.why}\n\nFix: ${m.fix}\n\nWhat this rule checks: ${m.method}${m.lab ? `\n\nLab: ${origin}/labs/${m.lab}` : ""}`);
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
case "rules": {
|
|
121
|
+
for (const m of allRuleMetas())
|
|
122
|
+
console.log(`${m.id.padEnd(18)} ${m.severity.padEnd(9)} ${m.title}`);
|
|
123
|
+
console.log(`\nFull methods: ${origin}/rules`);
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
case "suppress": {
|
|
127
|
+
const id = positionals[0]?.toUpperCase();
|
|
128
|
+
if (!id || !getRuleMeta(id) || !values.server || !values.reason) {
|
|
129
|
+
console.error('Usage: mcpvetter suppress <ID> --server <name|*> --reason "<why>"');
|
|
130
|
+
return 2;
|
|
131
|
+
}
|
|
132
|
+
addSuppression({ id, server: values.server, reason: values.reason });
|
|
133
|
+
console.log(`Suppressed ${id} on ${values.server}. It will still be listed under "Suppressed" in every report.`);
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
case "accept": {
|
|
137
|
+
const name = positionals[0];
|
|
138
|
+
if (!name || !values.reason) {
|
|
139
|
+
console.error('Usage: mcpvetter accept <server> --reason "<why>"');
|
|
140
|
+
return 2;
|
|
141
|
+
}
|
|
142
|
+
const s = loadFiles({ paths: values.config }).files.flatMap((f) => f.servers).find((x) => x.name === name);
|
|
143
|
+
if (!s) {
|
|
144
|
+
console.error(`No configured server named "${name}".`);
|
|
145
|
+
return 2;
|
|
146
|
+
}
|
|
147
|
+
const p = await probeServer(s);
|
|
148
|
+
if (!p.ok) {
|
|
149
|
+
console.error(`Could not probe ${name}: ${p.error}`);
|
|
150
|
+
return 2;
|
|
151
|
+
}
|
|
152
|
+
const b = loadBaseline();
|
|
153
|
+
b[p.identity] = { ...fingerprint(p), acceptedReason: values.reason };
|
|
154
|
+
saveBaseline(b);
|
|
155
|
+
mkdirSync(stateDir(), { recursive: true });
|
|
156
|
+
writeFileSync(join(stateDir(), "accept.log"), `${new Date().toISOString()}\t${name}\t${values.reason.replace(/\s+/g, " ")}\n`, { flag: "a" });
|
|
157
|
+
console.log(`Accepted new fingerprint for ${name} (${p.tools.length} tools). Reason logged to ~/.mcpvetter/accept.log.`);
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
case "review": {
|
|
161
|
+
if (!positionals.length) {
|
|
162
|
+
console.error("Usage: mcpvetter review <file>...");
|
|
163
|
+
return 2;
|
|
164
|
+
}
|
|
165
|
+
const failOn = severityArg(values["fail-on"]);
|
|
166
|
+
const backend = new LocalBackend();
|
|
167
|
+
const a = await backend.authorize("review_code");
|
|
168
|
+
if (!a.allowed) {
|
|
169
|
+
console.error(a.message);
|
|
170
|
+
return 2;
|
|
171
|
+
}
|
|
172
|
+
let worst = false;
|
|
173
|
+
const all = [];
|
|
174
|
+
for (const file of positionals) {
|
|
175
|
+
const findings = reviewCode(readFileSync(file, "utf8"), { filename: file, language: values.language }).map((f) => ({ ...f, location: { ...f.location, file } }));
|
|
176
|
+
all.push(...findings);
|
|
177
|
+
if (findings.some((f) => atOrAbove(f.severity, failOn)))
|
|
178
|
+
worst = true;
|
|
179
|
+
if (!values.json) {
|
|
180
|
+
console.log(`■ ${file}: ${findings.length ? `${findings.length} finding(s)` : "no findings"}`);
|
|
181
|
+
for (const f of findings)
|
|
182
|
+
console.log(formatFinding(f, origin) + "\n");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
await backend.recordHistory("review_code", all.map((f) => ({ id: f.id, severity: f.severity })));
|
|
186
|
+
if (values.json)
|
|
187
|
+
console.log(JSON.stringify({ version: VERSION, findings: all }, null, 2));
|
|
188
|
+
return worst ? 1 : 0;
|
|
189
|
+
}
|
|
190
|
+
case "auth": {
|
|
191
|
+
const key = positionals[0];
|
|
192
|
+
if (!key || !/^mcpv_(?:live|test)_[A-Za-z0-9]{16,}$/.test(key)) {
|
|
193
|
+
console.error(`Usage: mcpvetter auth <api-key> (get one at ${origin}/account)`);
|
|
194
|
+
return 2;
|
|
195
|
+
}
|
|
196
|
+
mkdirSync(stateDir(), { recursive: true });
|
|
197
|
+
const p = join(stateDir(), "config.json");
|
|
198
|
+
let cfg = {};
|
|
199
|
+
try {
|
|
200
|
+
cfg = JSON.parse(readFileSync(p, "utf8"));
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
/* new file */
|
|
204
|
+
}
|
|
205
|
+
writeFileSync(p, JSON.stringify({ ...cfg, apiKey: key }, null, 2), { mode: 0o600 });
|
|
206
|
+
console.log("Saved. The local MCP server and `mcpvetter review` will use this key.");
|
|
207
|
+
return 0;
|
|
208
|
+
}
|
|
209
|
+
case "serve": {
|
|
210
|
+
const { serveStdio } = await import("./mcp/stdio.js");
|
|
211
|
+
await serveStdio();
|
|
212
|
+
return -1; // keep running
|
|
213
|
+
}
|
|
214
|
+
case "--version":
|
|
215
|
+
case "-v":
|
|
216
|
+
case "version":
|
|
217
|
+
console.log(VERSION);
|
|
218
|
+
return 0;
|
|
219
|
+
case undefined:
|
|
220
|
+
case "help":
|
|
221
|
+
case "--help":
|
|
222
|
+
case "-h":
|
|
223
|
+
console.log(HELP);
|
|
224
|
+
return 0;
|
|
225
|
+
default:
|
|
226
|
+
console.error(`Unknown command "${cmd}".\n\n${HELP}`);
|
|
227
|
+
return 2;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
main(process.argv.slice(2)).then((code) => {
|
|
231
|
+
if (code >= 0)
|
|
232
|
+
process.exitCode = code;
|
|
233
|
+
}, (e) => {
|
|
234
|
+
console.error(`mcpvetter: ${e.message}`);
|
|
235
|
+
process.exitCode = 2;
|
|
236
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Finding } from "../engine/types.js";
|
|
2
|
+
import type { LabCheck } from "../mcp/backend.js";
|
|
3
|
+
export interface LabResult {
|
|
4
|
+
passed: boolean;
|
|
5
|
+
failures: string[];
|
|
6
|
+
findings: Finding[];
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Verify a lab submission: none of the lab's rules may still fire, every required secure
|
|
10
|
+
* pattern must be present, and no forbidden pattern may remain.
|
|
11
|
+
*/
|
|
12
|
+
export declare function checkLab(check: LabCheck, code: string, language?: string): LabResult;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { reviewCode } from "./review.js";
|
|
2
|
+
/**
|
|
3
|
+
* Verify a lab submission: none of the lab's rules may still fire, every required secure
|
|
4
|
+
* pattern must be present, and no forbidden pattern may remain.
|
|
5
|
+
*/
|
|
6
|
+
export function checkLab(check, code, language) {
|
|
7
|
+
const findings = reviewCode(code, { language });
|
|
8
|
+
const failures = [];
|
|
9
|
+
if (!code.trim())
|
|
10
|
+
failures.push("The submission is empty. Paste your fixed version of the lab code.");
|
|
11
|
+
for (const id of check.mustNotTrigger) {
|
|
12
|
+
for (const f of findings.filter((x) => x.id === id)) {
|
|
13
|
+
failures.push(`${f.id} still triggers${f.location?.line ? ` on line ${f.location.line}` : ""}: ${f.title}. ${f.evidence}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
for (const m of check.mustMatch) {
|
|
17
|
+
if (!new RegExp(m.re, m.flags ?? "").test(code))
|
|
18
|
+
failures.push(m.hint);
|
|
19
|
+
}
|
|
20
|
+
for (const m of check.mustNotMatch ?? []) {
|
|
21
|
+
if (new RegExp(m.re, m.flags ?? "").test(code))
|
|
22
|
+
failures.push(m.hint);
|
|
23
|
+
}
|
|
24
|
+
return { passed: failures.length === 0, failures, findings };
|
|
25
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type CodeContext, type Finding, type Rule } from "../engine/types.js";
|
|
2
|
+
export declare const CODE_RULES: Rule<CodeContext>[];
|
|
3
|
+
export declare function detectLanguage(code: string, hint?: string, filename?: string): string;
|
|
4
|
+
/** Run every code rule on a snippet. Findings are deduped per rule + line and sorted worst-first. */
|
|
5
|
+
export declare function reviewCode(code: string, opts?: {
|
|
6
|
+
language?: string;
|
|
7
|
+
filename?: string;
|
|
8
|
+
}): Finding[];
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { sortFindings, toFinding } from "../engine/types.js";
|
|
2
|
+
import { codeAuth } from "./rules/auth.js";
|
|
3
|
+
import { codeIdor } from "./rules/idor.js";
|
|
4
|
+
import { codeLlmOutput } from "./rules/llmout.js";
|
|
5
|
+
import { codeMcpConfig } from "./rules/mcpcfg.js";
|
|
6
|
+
import { codePromptInjection } from "./rules/prompt.js";
|
|
7
|
+
import { codeSecret } from "./rules/secret.js";
|
|
8
|
+
import { codeSqli } from "./rules/sqli.js";
|
|
9
|
+
import { codeSsrf } from "./rules/ssrf.js";
|
|
10
|
+
import { codeUpload } from "./rules/upload.js";
|
|
11
|
+
import { codeXss } from "./rules/xss.js";
|
|
12
|
+
export const CODE_RULES = [
|
|
13
|
+
codeSecret,
|
|
14
|
+
codeSqli,
|
|
15
|
+
codePromptInjection,
|
|
16
|
+
codeLlmOutput,
|
|
17
|
+
codeAuth,
|
|
18
|
+
codeIdor,
|
|
19
|
+
codeXss,
|
|
20
|
+
codeSsrf,
|
|
21
|
+
codeUpload,
|
|
22
|
+
codeMcpConfig,
|
|
23
|
+
];
|
|
24
|
+
const EXT = {
|
|
25
|
+
js: "javascript", mjs: "javascript", cjs: "javascript", jsx: "javascript",
|
|
26
|
+
ts: "typescript", tsx: "typescript", mts: "typescript", cts: "typescript",
|
|
27
|
+
py: "python", json: "json", jsonc: "json", html: "html", vue: "javascript",
|
|
28
|
+
svelte: "javascript", ejs: "html", hbs: "html", jinja: "html", j2: "html",
|
|
29
|
+
};
|
|
30
|
+
const ALIASES = {
|
|
31
|
+
js: "javascript", node: "javascript", nodejs: "javascript", javascript: "javascript", jsx: "javascript",
|
|
32
|
+
ts: "typescript", typescript: "typescript", tsx: "typescript",
|
|
33
|
+
py: "python", python: "python", python3: "python",
|
|
34
|
+
json: "json", jsonc: "json", html: "html", jinja: "html", jinja2: "html",
|
|
35
|
+
};
|
|
36
|
+
export function detectLanguage(code, hint, filename) {
|
|
37
|
+
if (hint) {
|
|
38
|
+
const h = ALIASES[hint.toLowerCase().trim()];
|
|
39
|
+
if (h)
|
|
40
|
+
return h;
|
|
41
|
+
}
|
|
42
|
+
const ext = filename?.split(".").pop()?.toLowerCase();
|
|
43
|
+
if (ext && EXT[ext])
|
|
44
|
+
return EXT[ext];
|
|
45
|
+
const t = code.trim();
|
|
46
|
+
if (/^[{[]/.test(t)) {
|
|
47
|
+
try {
|
|
48
|
+
JSON.parse(t);
|
|
49
|
+
return "json";
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
if (/"mcpServers"|"servers"\s*:/.test(t) && !/\b(?:const|function|def|import)\b/.test(t))
|
|
53
|
+
return "json";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (/^\s*(?:def |class \w+.*:\s*$|import \w+\s*$|from [\w.]+ import |@app\.|if __name__ ==)/m.test(code))
|
|
57
|
+
return "python";
|
|
58
|
+
if (/^\s*<(?:!doctype|html|div|template|body)/i.test(t))
|
|
59
|
+
return "html";
|
|
60
|
+
if (/:\s*(?:string|number|boolean)\b|\binterface \w+|\bimport type\b|<\w+>\(/.test(code))
|
|
61
|
+
return "typescript";
|
|
62
|
+
return "javascript";
|
|
63
|
+
}
|
|
64
|
+
/** Run every code rule on a snippet. Findings are deduped per rule + line and sorted worst-first. */
|
|
65
|
+
export function reviewCode(code, opts = {}) {
|
|
66
|
+
const language = detectLanguage(code, opts.language, opts.filename);
|
|
67
|
+
const ctx = { code, language, filename: opts.filename, lines: code.split(/\r?\n/) };
|
|
68
|
+
const seen = new Set();
|
|
69
|
+
const findings = [];
|
|
70
|
+
for (const rule of CODE_RULES) {
|
|
71
|
+
let hits;
|
|
72
|
+
try {
|
|
73
|
+
hits = rule.check(ctx);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
continue; // a rule bug must never break the whole review
|
|
77
|
+
}
|
|
78
|
+
for (const hit of hits) {
|
|
79
|
+
const key = `${rule.id}:${hit.location?.line ?? 0}`;
|
|
80
|
+
if (seen.has(key))
|
|
81
|
+
continue;
|
|
82
|
+
seen.add(key);
|
|
83
|
+
const f = toFinding(rule, hit);
|
|
84
|
+
if (opts.filename)
|
|
85
|
+
f.location = { ...f.location, file: opts.filename };
|
|
86
|
+
findings.push(f);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return sortFindings(findings);
|
|
90
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { evidence } from "../../engine/redact.js";
|
|
2
|
+
import { isCommentLine, windowText } from "../taint.js";
|
|
3
|
+
const isPy = (ctx) => ctx.language === "python";
|
|
4
|
+
const CHECKS = [
|
|
5
|
+
{
|
|
6
|
+
re: /\bjwt\.decode\s*\(/,
|
|
7
|
+
lang: "js",
|
|
8
|
+
detail: "jwt.decode() only parses the token; it does not check the signature. Use jwt.verify(token, secret, { algorithms: [\"HS256\"] }).",
|
|
9
|
+
confidence: "high",
|
|
10
|
+
unless: (ctx) => /\bjwt\.verify\s*\(|jwtVerify\s*\(/.test(ctx.code),
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
re: /verify_signature["']?\s*:\s*False|\bverify\s*=\s*False|options\s*=\s*\{[^}]*["']verify_signature["']\s*:\s*False/,
|
|
14
|
+
lang: "python",
|
|
15
|
+
detail: "Signature verification is disabled. Remove the option and pass algorithms=[\"HS256\"] (or your algorithm) to jwt.decode().",
|
|
16
|
+
confidence: "high",
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
re: /algorithms?\s*[:=]\s*\[?\s*["']none["']/i,
|
|
20
|
+
detail: "The \"none\" algorithm accepts unsigned tokens. Allow only the algorithm you sign with.",
|
|
21
|
+
confidence: "high",
|
|
22
|
+
severity: "critical",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
re: /\bjwt\.sign\s*\(/,
|
|
26
|
+
detail: "Token is signed without an expiry. Add { expiresIn: \"15m\" } (or a short lifetime that fits) and refresh tokens server-side.",
|
|
27
|
+
confidence: "medium",
|
|
28
|
+
severity: "medium",
|
|
29
|
+
unless: (ctx, i) => /expiresIn|\bexp\s*:/.test(windowText(ctx.lines, i, 6, 4)),
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
re: /\bjwt\.encode\s*\(/,
|
|
33
|
+
lang: "python",
|
|
34
|
+
detail: "Token is created without an \"exp\" claim. Include \"exp\": datetime.now(timezone.utc) + timedelta(minutes=15).",
|
|
35
|
+
confidence: "medium",
|
|
36
|
+
severity: "medium",
|
|
37
|
+
unless: (ctx, i) => /["']exp["']|\bexp\s*=/.test(windowText(ctx.lines, i, 8, 3)),
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
re: /\b\w*pass(?:word|wd)\w*["']?\]?(?:\.\w+|\[["']\w+["']\])*\s*(?:===?|!==?)\s*[\w.[\]"']*pass(?:word|wd)/i,
|
|
41
|
+
detail: "Passwords are compared as plain text, which means they are stored unhashed. Store a bcrypt/argon2 hash and compare with bcrypt.compare() / argon2.verify() / check_password_hash().",
|
|
42
|
+
confidence: "high",
|
|
43
|
+
unless: (ctx, i) => /hash|bcrypt|argon|scrypt|compare|confirm|repeat|again|new_?password\s*[!=]==?\s*confirm/i.test(ctx.lines[i]),
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
re: /(?:createHash\s*\(\s*["'](?:md5|sha1|sha256)["']|hashlib\.(?:md5|sha1|sha256)\s*\(|\bmd5\s*\(|\bsha1\s*\()/i,
|
|
47
|
+
detail: "Fast hashes (MD5, SHA-1, SHA-256) are not password hashes; leaked hashes can be cracked quickly. Use bcrypt, scrypt or argon2 with a per-user salt.",
|
|
48
|
+
confidence: "high",
|
|
49
|
+
unless: (ctx, i) => !/pass(?:word|wd)|pwd/i.test(windowText(ctx.lines, i, 1, 1)),
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
re: /localStorage\.getItem\s*\(\s*["'](?:isLoggedIn|loggedIn|isAdmin|is_admin|role|isAuthenticated|authenticated|admin)["']\s*\)/,
|
|
53
|
+
detail: "Access is decided by a value in localStorage that the user can edit. Enforce authorization on the server for every request.",
|
|
54
|
+
confidence: "high",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
re: /\b(?:httpOnly|httponly)\s*:\s*false|\bsecure\s*:\s*false|SESSION_COOKIE_(?:SECURE|HTTPONLY)\s*=\s*False|httponly\s*=\s*False/,
|
|
58
|
+
detail: "Session cookie is missing HttpOnly or Secure. Set httpOnly: true, secure: true and sameSite: \"lax\" (or stricter).",
|
|
59
|
+
confidence: "medium",
|
|
60
|
+
severity: "medium",
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
export const codeAuth = {
|
|
64
|
+
id: "CODE-AUTH-001",
|
|
65
|
+
title: "Weak authentication or session handling",
|
|
66
|
+
severity: "high",
|
|
67
|
+
confidence: "medium",
|
|
68
|
+
evidenceKind: "static",
|
|
69
|
+
lab: "lab-05-auth",
|
|
70
|
+
pattern: "auth-sessions",
|
|
71
|
+
why: "Login and session code that looks finished can still let anyone in: tokens that are never verified or never expire, passwords stored in plain text or with fast hashes, and access decisions made in the browser. These flaws turn one leaked token or database dump into full account takeover.",
|
|
72
|
+
fix: "Verify every token's signature with a fixed algorithm, give tokens a short expiry, hash passwords with bcrypt or argon2, set session cookies HttpOnly and Secure, and make every authorization decision on the server.",
|
|
73
|
+
method: "Flags: jwt.decode() in JavaScript when the snippet never calls jwt.verify(); PyJWT decode with verify_signature False; the \"none\" algorithm; jwt.sign() / jwt.encode() without an expiry nearby; equality comparison between two password values (no hash or compare call on the line); MD5/SHA-1/SHA-256 next to the word password; authorization flags read from localStorage; session cookies with httpOnly or secure set to false.",
|
|
74
|
+
check(ctx) {
|
|
75
|
+
const hits = [];
|
|
76
|
+
ctx.lines.forEach((line, i) => {
|
|
77
|
+
if (isCommentLine(line))
|
|
78
|
+
return;
|
|
79
|
+
for (const c of CHECKS) {
|
|
80
|
+
if (c.lang === "js" && isPy(ctx))
|
|
81
|
+
continue;
|
|
82
|
+
if (c.lang === "python" && !isPy(ctx))
|
|
83
|
+
continue;
|
|
84
|
+
if (!c.re.test(line))
|
|
85
|
+
continue;
|
|
86
|
+
if (c.unless?.(ctx, i))
|
|
87
|
+
continue;
|
|
88
|
+
hits.push({ evidence: evidence(line), location: { line: i + 1 }, confidence: c.confidence, severity: c.severity, fixDetail: c.detail });
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
return hits;
|
|
93
|
+
},
|
|
94
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { evidence } from "../../engine/redact.js";
|
|
2
|
+
import { computeTaint, isCommentLine, isTestFile, REQUEST_SOURCE_RE, windowText } from "../taint.js";
|
|
3
|
+
const LOOKUP_RE = /\b(?:findById|findByPk|findOne|findUnique|findUniqueOrThrow|findFirst|findOneBy|get_object_or_404|get_or_404|getById|query\.get|objects\.get|session\.get|db\.get)\s*\(|\.filter_by\s*\(|\.filter\s*\(\s*\w+\.id\s*==|WHERE\s+(?:\w+\.)?id\s*=\s*(?:\$\d|\?|%s|:\w+|\$\{)/i;
|
|
4
|
+
/** Signals that the handler scopes the record to the caller. */
|
|
5
|
+
const OWNERSHIP_RE = /\b(?:require_\w+|ensure_\w+|check_\w*(?:access|member|permission|owner)\w*|membership|is_member|Depends\(\s*\w*(?:user|auth|member|org|owner)\w*|req\.user|request\.user|current_user|currentUser|session\.user(?:Id)?|ctx\.state\.user|req\.auth|auth\.uid|getServerSession|auth\(\)|authorize|can\s*\(|permission|policy|has_?access|ensureOwner|assertOwner|forbidden|Forbidden|403)\b/;
|
|
6
|
+
/** Scoping column names; count as ownership only when the value is not itself request input. */
|
|
7
|
+
const SCOPE_NAME_RE = /\b(owner_?[iI]d|user_?[iI]d|account_?[iI]d|tenant_?[iI]d|org_?[iI]d|organizationId|created_?[bB]y)\b/g;
|
|
8
|
+
function scoped(ctx, text, requestVars) {
|
|
9
|
+
if (OWNERSHIP_RE.test(text))
|
|
10
|
+
return true;
|
|
11
|
+
for (const m of text.matchAll(SCOPE_NAME_RE)) {
|
|
12
|
+
if (!requestVars.has(m[1]))
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
/** IDOR is a web-request flaw: only analyse snippets that look like request handlers. */
|
|
18
|
+
const WEB_HANDLER_RE = /@\w+(?:\.\w+)*\.(?:route|get|post|put|patch|delete|api_route)\s*\(|\b(?:app|router|server)\.(?:get|post|put|patch|delete|all|route)\s*\(|\breq\.(?:params|query|body)|\brequest\.(?:args|form|json|view_args|match_info|path_params)|export\s+(?:async\s+)?function\s+(?:GET|POST|PUT|PATCH|DELETE)\b/;
|
|
19
|
+
const ID_NAME_RE = /^(?:id|pk|\w*[iI]d|\w*_id|uuid|slug)$/;
|
|
20
|
+
export const codeIdor = {
|
|
21
|
+
id: "CODE-IDOR-001",
|
|
22
|
+
title: "Record fetched by request ID with no ownership check (IDOR)",
|
|
23
|
+
severity: "high",
|
|
24
|
+
confidence: "medium",
|
|
25
|
+
evidenceKind: "static",
|
|
26
|
+
lab: "lab-06-idor",
|
|
27
|
+
pattern: "auth-sessions",
|
|
28
|
+
why: "The endpoint loads a record using an ID taken straight from the request and returns or changes it without checking that it belongs to the caller. Any signed-in user can change the ID in the URL or body and read or modify someone else's data.",
|
|
29
|
+
fix: "Scope the lookup to the authenticated user, for example findFirst({ where: { id, userId: req.user.id } }) or Order.query.filter_by(id=order_id, user_id=current_user.id).first_or_404(), and return 404 when nothing matches. For shared resources, check an explicit permission before returning the record.",
|
|
30
|
+
method: "Flags a record lookup (findById, findUnique, findFirst, findOne, get_object_or_404, query.get, filter_by, or SQL WHERE id = ?) whose ID argument comes from request input or a route parameter, when no ownership or authorization signal (req.user, current_user, request.user, userId, ownerId, tenantId, an authorize/permission call, or a 403) appears within the surrounding handler (8 lines before, 12 after). Only snippets that look like web request handlers are analysed, and test files are skipped. Confidence is medium because ownership can be enforced elsewhere, such as in middleware or row-level security.",
|
|
31
|
+
check(ctx) {
|
|
32
|
+
if (isTestFile(ctx.filename) || !WEB_HANDLER_RE.test(ctx.code))
|
|
33
|
+
return [];
|
|
34
|
+
const hits = [];
|
|
35
|
+
const taint = computeTaint(ctx);
|
|
36
|
+
ctx.lines.forEach((line, i) => {
|
|
37
|
+
if (isCommentLine(line) || !LOOKUP_RE.test(line))
|
|
38
|
+
return;
|
|
39
|
+
const args = line.slice(line.search(LOOKUP_RE));
|
|
40
|
+
const fromRequest = REQUEST_SOURCE_RE.test(args) ||
|
|
41
|
+
(args.match(/[A-Za-z_$][\w$]*/g) ?? []).some((id) => taint.request.has(id) && ID_NAME_RE.test(id));
|
|
42
|
+
// the lookup may also reference an id variable on a nearby SQL params line
|
|
43
|
+
const nearbyParams = !fromRequest && /WHERE/i.test(args) && windowText(ctx.lines, i, 0, 2).match(/\[\s*([A-Za-z_$][\w$.]*)\s*\]|\(\s*([A-Za-z_]\w*)\s*,\s*\)/);
|
|
44
|
+
const viaParams = nearbyParams && [nearbyParams[1], nearbyParams[2]].some((v) => v && (REQUEST_SOURCE_RE.test(v) || taint.request.has(v)));
|
|
45
|
+
if (!fromRequest && !viaParams)
|
|
46
|
+
return;
|
|
47
|
+
if (scoped(ctx, windowText(ctx.lines, i, 8, 12), taint.request))
|
|
48
|
+
return;
|
|
49
|
+
hits.push({ evidence: evidence(line), location: { line: i + 1 } });
|
|
50
|
+
});
|
|
51
|
+
return hits;
|
|
52
|
+
},
|
|
53
|
+
};
|