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.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +78 -2
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +236 -0
  5. package/dist/code/labs.d.ts +12 -0
  6. package/dist/code/labs.js +25 -0
  7. package/dist/code/review.d.ts +8 -0
  8. package/dist/code/review.js +90 -0
  9. package/dist/code/rules/auth.d.ts +2 -0
  10. package/dist/code/rules/auth.js +94 -0
  11. package/dist/code/rules/idor.d.ts +2 -0
  12. package/dist/code/rules/idor.js +53 -0
  13. package/dist/code/rules/llmout.d.ts +2 -0
  14. package/dist/code/rules/llmout.js +50 -0
  15. package/dist/code/rules/mcpcfg.d.ts +2 -0
  16. package/dist/code/rules/mcpcfg.js +180 -0
  17. package/dist/code/rules/prompt.d.ts +2 -0
  18. package/dist/code/rules/prompt.js +52 -0
  19. package/dist/code/rules/secret.d.ts +2 -0
  20. package/dist/code/rules/secret.js +60 -0
  21. package/dist/code/rules/sqli.d.ts +3 -0
  22. package/dist/code/rules/sqli.js +73 -0
  23. package/dist/code/rules/ssrf.d.ts +2 -0
  24. package/dist/code/rules/ssrf.js +59 -0
  25. package/dist/code/rules/upload.d.ts +2 -0
  26. package/dist/code/rules/upload.js +54 -0
  27. package/dist/code/rules/xss.d.ts +2 -0
  28. package/dist/code/rules/xss.js +73 -0
  29. package/dist/code/taint.d.ts +41 -0
  30. package/dist/code/taint.js +212 -0
  31. package/dist/engine/redact.d.ts +20 -0
  32. package/dist/engine/redact.js +48 -0
  33. package/dist/engine/registry.d.ts +4 -0
  34. package/dist/engine/registry.js +9 -0
  35. package/dist/engine/types.d.ts +65 -0
  36. package/dist/engine/types.js +27 -0
  37. package/dist/index.d.ts +13 -0
  38. package/dist/index.js +12 -0
  39. package/dist/mcp/backend.d.ts +122 -0
  40. package/dist/mcp/backend.js +12 -0
  41. package/dist/mcp/local-backend.d.ts +31 -0
  42. package/dist/mcp/local-backend.js +90 -0
  43. package/dist/mcp/stdio.d.ts +5 -0
  44. package/dist/mcp/stdio.js +19 -0
  45. package/dist/mcp/tools.d.ts +9 -0
  46. package/dist/mcp/tools.js +245 -0
  47. package/dist/scanner/discover.d.ts +8 -0
  48. package/dist/scanner/discover.js +39 -0
  49. package/dist/scanner/fingerprint.d.ts +15 -0
  50. package/dist/scanner/fingerprint.js +77 -0
  51. package/dist/scanner/index.d.ts +32 -0
  52. package/dist/scanner/index.js +164 -0
  53. package/dist/scanner/known.d.ts +9 -0
  54. package/dist/scanner/known.js +90 -0
  55. package/dist/scanner/launch.d.ts +25 -0
  56. package/dist/scanner/launch.js +132 -0
  57. package/dist/scanner/parse.d.ts +11 -0
  58. package/dist/scanner/parse.js +164 -0
  59. package/dist/scanner/probe.d.ts +9 -0
  60. package/dist/scanner/probe.js +71 -0
  61. package/dist/scanner/report.d.ts +7 -0
  62. package/dist/scanner/report.js +88 -0
  63. package/dist/scanner/rules/live.d.ts +20 -0
  64. package/dist/scanner/rules/live.js +167 -0
  65. package/dist/scanner/rules/static.d.ts +14 -0
  66. package/dist/scanner/rules/static.js +361 -0
  67. package/dist/scanner/types.d.ts +112 -0
  68. package/dist/scanner/types.js +1 -0
  69. package/dist/version.d.ts +1 -0
  70. package/dist/version.js +1 -0
  71. 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
- # Temporary Holding Version
1
+ # mcpvetter
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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,2 @@
1
+ import type { CodeContext, Rule } from "../../engine/types.js";
2
+ export declare const codeAuth: Rule<CodeContext>;
@@ -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,2 @@
1
+ import type { CodeContext, Rule } from "../../engine/types.js";
2
+ export declare const codeIdor: Rule<CodeContext>;
@@ -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
+ };
@@ -0,0 +1,2 @@
1
+ import type { CodeContext, Rule } from "../../engine/types.js";
2
+ export declare const codeLlmOutput: Rule<CodeContext>;