mcpsec 0.2.1 → 0.3.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/README.md +59 -2
- package/package.json +1 -1
- package/src/cli/index.ts +61 -10
- package/src/lib/registry-client.ts +188 -0
- package/src/scanner/registry-scanner.ts +297 -0
- package/src/scanner/report.ts +1 -1
package/README.md
CHANGED
|
@@ -14,6 +14,22 @@ MCP gives AI agents access to tools, files, databases, and APIs. A single malici
|
|
|
14
14
|
|
|
15
15
|
mcpsec finds these problems before attackers do.
|
|
16
16
|
|
|
17
|
+
## Real-World Results
|
|
18
|
+
|
|
19
|
+
We tested mcpsec against 10 popular MCP servers (GitHub, Slack, Postgres, Brave Search, Puppeteer, etc.) configured exactly as their official README files recommend.
|
|
20
|
+
|
|
21
|
+
**Score: 2/100**
|
|
22
|
+
|
|
23
|
+
| Severity | Findings | Examples |
|
|
24
|
+
|----------|----------|---------|
|
|
25
|
+
| Critical | 3 | GitHub PAT, Slack bot token, and Postgres password in plain text config |
|
|
26
|
+
| High | 1 | API key hardcoded in env block |
|
|
27
|
+
| Medium | 1 | Unverified third-party npm package via `npx -y` |
|
|
28
|
+
|
|
29
|
+
The only servers that passed clean were ones that don't require credentials (filesystem, memory, sqlite). Every server that needs an API key had it hardcoded in the config file - because that's what the docs tell you to do.
|
|
30
|
+
|
|
31
|
+
Against deliberately vulnerable MCP server configs (from security research projects), mcpsec found **17 findings** including hardcoded AWS keys, Stripe live keys, SSRF endpoints, and supply chain risks.
|
|
32
|
+
|
|
17
33
|
## Quick Start
|
|
18
34
|
|
|
19
35
|
```bash
|
|
@@ -44,7 +60,7 @@ npx mcpsec scan --baseline --json
|
|
|
44
60
|
## Example Output
|
|
45
61
|
|
|
46
62
|
```
|
|
47
|
-
mcpsec - MCP Security Scanner v0.
|
|
63
|
+
mcpsec - MCP Security Scanner v0.3.0
|
|
48
64
|
──────────────────────────────────────────────────
|
|
49
65
|
|
|
50
66
|
Configurations Found
|
|
@@ -85,6 +101,41 @@ npx mcpsec scan --baseline --json
|
|
|
85
101
|
Fix: Pin to a specific version: npx package@1.2.3
|
|
86
102
|
```
|
|
87
103
|
|
|
104
|
+
## Registry Scanning
|
|
105
|
+
|
|
106
|
+
Scan MCP servers directly from the official [MCP Registry](https://registry.modelcontextprotocol.io/) to assess their security before installing them.
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
# Scan top servers from the registry
|
|
110
|
+
npx mcpsec scan --registry
|
|
111
|
+
|
|
112
|
+
# Limit number of servers
|
|
113
|
+
npx mcpsec scan --registry --limit 50
|
|
114
|
+
|
|
115
|
+
# Search for servers by keyword
|
|
116
|
+
npx mcpsec scan --registry --search "database"
|
|
117
|
+
|
|
118
|
+
# Scan a specific server by name
|
|
119
|
+
npx mcpsec scan --registry --server "filesystem"
|
|
120
|
+
|
|
121
|
+
# Combine with output formats
|
|
122
|
+
npx mcpsec scan --registry --json
|
|
123
|
+
npx mcpsec scan --registry --sarif
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Registry-Specific Checks
|
|
127
|
+
|
|
128
|
+
In addition to running the standard static analysis pipeline, registry mode performs supply-chain checks against npm:
|
|
129
|
+
|
|
130
|
+
| Check | Category | Severity |
|
|
131
|
+
|-------|----------|----------|
|
|
132
|
+
| Package not found on npm | Supply Chain | High |
|
|
133
|
+
| Package < 1 week old | Supply Chain | Medium |
|
|
134
|
+
| Package < 100 weekly downloads | Supply Chain | Low |
|
|
135
|
+
| Single maintainer on npm | Supply Chain | Info |
|
|
136
|
+
| No repository URL in registry entry | Supply Chain | Low |
|
|
137
|
+
| Repository URL returns 404 | Supply Chain | Medium |
|
|
138
|
+
|
|
88
139
|
## What It Detects
|
|
89
140
|
|
|
90
141
|
### Static Analysis (default)
|
|
@@ -234,6 +285,10 @@ Options:
|
|
|
234
285
|
--json Output results as JSON
|
|
235
286
|
--sarif Output results as SARIF 2.1.0 (for GitHub Code Scanning)
|
|
236
287
|
--path <file> Scan a specific config file
|
|
288
|
+
--registry Scan servers from the official MCP registry
|
|
289
|
+
--limit <n> Max servers to fetch from registry (default: 20)
|
|
290
|
+
--search <query> Search registry servers by keyword
|
|
291
|
+
--server <name> Scan a specific registry server by name
|
|
237
292
|
--save-baseline [file] Save scan results as baseline (default: .mcpsec-baseline.json)
|
|
238
293
|
--baseline [file] Compare scan against baseline and show diff
|
|
239
294
|
--no-color Disable colored output
|
|
@@ -269,11 +324,13 @@ src/
|
|
|
269
324
|
injection-patterns.ts Prompt injection / tool poisoning patterns
|
|
270
325
|
url-validator.ts SSRF detection (cloud metadata, private IPs)
|
|
271
326
|
mcp-client.ts MCP protocol client (stdio + HTTP)
|
|
327
|
+
registry-client.ts MCP registry + npm API client
|
|
272
328
|
scanner/
|
|
273
329
|
config-scanner.ts Config-level checks (transport, docker, supply chain)
|
|
274
330
|
credential-scanner.ts API key and credential detection
|
|
275
331
|
tool-scanner.ts Injection and command injection scanning
|
|
276
332
|
live-scanner.ts Live server tool/resource/prompt analysis
|
|
333
|
+
registry-scanner.ts Registry supply-chain checks (npm age, downloads)
|
|
277
334
|
report.ts Score calculation and report output
|
|
278
335
|
sarif.ts SARIF 2.1.0 output for GitHub Code Scanning
|
|
279
336
|
baseline.ts Baseline save/load and diff engine
|
|
@@ -283,7 +340,7 @@ src/
|
|
|
283
340
|
|
|
284
341
|
- [x] Cross-server tool shadowing detection
|
|
285
342
|
- [x] GitHub Actions action (`uses: robdtaylor/sentinel-mcp@v1`)
|
|
286
|
-
- [
|
|
343
|
+
- [x] MCP server registry scanning
|
|
287
344
|
- [x] Baseline / diff mode (track changes between scans)
|
|
288
345
|
- [x] SARIF output format (`--sarif` for GitHub Code Scanning)
|
|
289
346
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcpsec",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Security scanner for MCP (Model Context Protocol) servers - detects tool poisoning, credential exposure, prompt injection, and SSRF",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Rob Taylor <robdtaylor@users.noreply.github.com>",
|
package/src/cli/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { discoverConfigs, scanConfigs } from '../scanner/config-scanner';
|
|
|
13
13
|
import { credentialScanner } from '../scanner/credential-scanner';
|
|
14
14
|
import { toolScanner } from '../scanner/tool-scanner';
|
|
15
15
|
import { liveScanner } from '../scanner/live-scanner';
|
|
16
|
+
import { registryScanner } from '../scanner/registry-scanner';
|
|
16
17
|
import { generateReport, printReport, printReportJSON } from '../scanner/report';
|
|
17
18
|
import { printReportSARIF } from '../scanner/sarif';
|
|
18
19
|
import {
|
|
@@ -39,6 +40,10 @@ const HELP = `
|
|
|
39
40
|
--json Output results as JSON
|
|
40
41
|
--sarif Output results as SARIF 2.1.0 (for GitHub Code Scanning)
|
|
41
42
|
--path <file> Scan a specific config file
|
|
43
|
+
--registry Scan servers from the official MCP registry
|
|
44
|
+
--limit <n> Max servers to fetch from registry (default: 20)
|
|
45
|
+
--search <query> Search registry servers by keyword
|
|
46
|
+
--server <name> Scan a specific registry server by name
|
|
42
47
|
--save-baseline [file] Save scan results as baseline (default: ${DEFAULT_BASELINE_PATH})
|
|
43
48
|
--baseline [file] Compare scan against baseline and show diff
|
|
44
49
|
--no-color Disable colored output
|
|
@@ -50,6 +55,11 @@ const HELP = `
|
|
|
50
55
|
mcpsec scan --live
|
|
51
56
|
mcpsec scan --json
|
|
52
57
|
mcpsec scan --path ~/.cursor/mcp.json
|
|
58
|
+
mcpsec scan --registry
|
|
59
|
+
mcpsec scan --registry --limit 50
|
|
60
|
+
mcpsec scan --registry --search "database"
|
|
61
|
+
mcpsec scan --registry --server "filesystem"
|
|
62
|
+
mcpsec scan --registry --json
|
|
53
63
|
mcpsec scan --save-baseline
|
|
54
64
|
mcpsec scan --baseline
|
|
55
65
|
mcpsec scan --baseline --json
|
|
@@ -65,7 +75,7 @@ async function main() {
|
|
|
65
75
|
}
|
|
66
76
|
|
|
67
77
|
if (command === '--version' || command === '-v') {
|
|
68
|
-
console.log('mcpsec v0.
|
|
78
|
+
console.log('mcpsec v0.3.0');
|
|
69
79
|
process.exit(0);
|
|
70
80
|
}
|
|
71
81
|
|
|
@@ -82,6 +92,15 @@ async function main() {
|
|
|
82
92
|
const pathIndex = args.indexOf('--path');
|
|
83
93
|
const specificPath = pathIndex !== -1 ? args[pathIndex + 1] : undefined;
|
|
84
94
|
|
|
95
|
+
// Registry flags
|
|
96
|
+
const registryMode = args.includes('--registry');
|
|
97
|
+
const limitIndex = args.indexOf('--limit');
|
|
98
|
+
const registryLimit = limitIndex !== -1 ? parseInt(args[limitIndex + 1], 10) || 20 : 20;
|
|
99
|
+
const searchIndex = args.indexOf('--search');
|
|
100
|
+
const registrySearch = searchIndex !== -1 ? args[searchIndex + 1] : undefined;
|
|
101
|
+
const serverIndex = args.indexOf('--server');
|
|
102
|
+
const registryServer = serverIndex !== -1 ? args[serverIndex + 1] : undefined;
|
|
103
|
+
|
|
85
104
|
// Baseline flags
|
|
86
105
|
const saveBaselineIndex = args.indexOf('--save-baseline');
|
|
87
106
|
const saveBaselineMode = saveBaselineIndex !== -1;
|
|
@@ -106,8 +125,45 @@ async function main() {
|
|
|
106
125
|
|
|
107
126
|
// Discover configs
|
|
108
127
|
let configs: MCPConfigFile[];
|
|
128
|
+
const allFindings: Finding[] = [];
|
|
129
|
+
|
|
130
|
+
if (registryMode) {
|
|
131
|
+
// Registry scanning mode
|
|
132
|
+
if (!jsonOutput && !sarifOutput) {
|
|
133
|
+
const label = registryServer
|
|
134
|
+
? `server "${registryServer}"`
|
|
135
|
+
: registrySearch
|
|
136
|
+
? `"${registrySearch}" (limit: ${registryLimit})`
|
|
137
|
+
: `top ${registryLimit} servers`;
|
|
138
|
+
process.stderr.write(`\n\x1b[1m🌐 Registry Scan: ${label}\x1b[0m\n`);
|
|
139
|
+
process.stderr.write(`\x1b[2m Fetching from registry.modelcontextprotocol.io...\x1b[0m\n`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const result = await registryScanner.scanRegistry({
|
|
144
|
+
limit: registryLimit,
|
|
145
|
+
search: registrySearch,
|
|
146
|
+
server: registryServer,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
configs = result.configs;
|
|
150
|
+
allFindings.push(...result.findings);
|
|
151
|
+
|
|
152
|
+
if (configs.length === 0) {
|
|
153
|
+
if (!jsonOutput && !sarifOutput) {
|
|
154
|
+
console.error(' No servers found matching your criteria.');
|
|
155
|
+
}
|
|
156
|
+
process.exit(0);
|
|
157
|
+
}
|
|
109
158
|
|
|
110
|
-
|
|
159
|
+
if (!jsonOutput && !sarifOutput) {
|
|
160
|
+
process.stderr.write(`\x1b[2m Found ${configs.length} server(s), running security analysis...\x1b[0m\n`);
|
|
161
|
+
}
|
|
162
|
+
} catch (err) {
|
|
163
|
+
console.error(`Registry fetch failed: ${(err as Error).message}`);
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
} else if (specificPath) {
|
|
111
167
|
// Scan a specific file
|
|
112
168
|
const { existsSync, readFileSync } = await import('fs');
|
|
113
169
|
if (!existsSync(specificPath)) {
|
|
@@ -140,23 +196,18 @@ async function main() {
|
|
|
140
196
|
configs = discoverConfigs();
|
|
141
197
|
}
|
|
142
198
|
|
|
143
|
-
// Run
|
|
144
|
-
const allFindings: Finding[] = [];
|
|
145
|
-
|
|
146
|
-
// Config-level checks
|
|
199
|
+
// Run standard scanners (config, credential, tool)
|
|
147
200
|
const configFindings = scanConfigs(configs);
|
|
148
201
|
allFindings.push(...configFindings);
|
|
149
202
|
|
|
150
|
-
// Credential scanner
|
|
151
203
|
const credFindings = await credentialScanner.scan(configs);
|
|
152
204
|
allFindings.push(...credFindings);
|
|
153
205
|
|
|
154
|
-
// Tool/injection scanner
|
|
155
206
|
const toolFindings = await toolScanner.scan(configs);
|
|
156
207
|
allFindings.push(...toolFindings);
|
|
157
208
|
|
|
158
|
-
// Live server scanner (connects to running servers)
|
|
159
|
-
if (liveMode) {
|
|
209
|
+
// Live server scanner (connects to running servers -- not used in registry mode)
|
|
210
|
+
if (liveMode && !registryMode) {
|
|
160
211
|
if (!jsonOutput) {
|
|
161
212
|
process.stderr.write('\n\x1b[1m🔴 Live Server Scan\x1b[0m\n');
|
|
162
213
|
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel MCP - Registry Client
|
|
3
|
+
*
|
|
4
|
+
* HTTP client for fetching MCP server listings from public registries
|
|
5
|
+
* and npm package metadata for supply-chain analysis.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// Types
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
export interface RegistryServer {
|
|
13
|
+
name: string;
|
|
14
|
+
description: string;
|
|
15
|
+
packages: { type: string; name: string }[];
|
|
16
|
+
repository?: string;
|
|
17
|
+
version?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface NpmPackageInfo {
|
|
21
|
+
exists: boolean;
|
|
22
|
+
created?: string;
|
|
23
|
+
weeklyDownloads?: number;
|
|
24
|
+
maintainers?: number;
|
|
25
|
+
repository?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Official MCP Registry
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
const REGISTRY_BASE = 'https://registry.modelcontextprotocol.io/v0.1';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Fetch server listings from the official MCP registry
|
|
36
|
+
*/
|
|
37
|
+
export async function fetchRegistryServers(options: {
|
|
38
|
+
limit?: number;
|
|
39
|
+
search?: string;
|
|
40
|
+
server?: string;
|
|
41
|
+
}): Promise<RegistryServer[]> {
|
|
42
|
+
const { limit = 20, search, server } = options;
|
|
43
|
+
|
|
44
|
+
let url = `${REGISTRY_BASE}/servers`;
|
|
45
|
+
const params = new URLSearchParams();
|
|
46
|
+
|
|
47
|
+
if (search) {
|
|
48
|
+
params.set('q', search);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (params.toString()) {
|
|
52
|
+
url += `?${params.toString()}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const response = await fetch(url);
|
|
56
|
+
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
throw new Error(`Registry API error: ${response.status} ${response.statusText}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const data = await response.json();
|
|
62
|
+
|
|
63
|
+
// The API returns { servers: [{ server: {...}, _meta: {...} }, ...] }
|
|
64
|
+
const rawEntries: any[] = Array.isArray(data)
|
|
65
|
+
? data
|
|
66
|
+
: (data.servers || data.items || data.results || []);
|
|
67
|
+
|
|
68
|
+
// Each entry wraps the actual server data in a `server` key
|
|
69
|
+
let servers: RegistryServer[] = rawEntries.map((entry: any) => {
|
|
70
|
+
const s = entry.server || entry;
|
|
71
|
+
return {
|
|
72
|
+
name: s.name || s.id || '',
|
|
73
|
+
description: s.description || '',
|
|
74
|
+
packages: normalizePackages(s),
|
|
75
|
+
repository: (typeof s.repository === 'object' ? s.repository?.url : s.repository) || undefined,
|
|
76
|
+
version: s.version || s.latest_version || undefined,
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Filter to a specific server name if requested
|
|
81
|
+
if (server) {
|
|
82
|
+
const needle = server.toLowerCase();
|
|
83
|
+
servers = servers.filter(
|
|
84
|
+
(s) => s.name.toLowerCase() === needle || s.name.toLowerCase().includes(needle)
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return servers.slice(0, limit);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Normalize the various package formats from the registry API.
|
|
93
|
+
*
|
|
94
|
+
* The official registry uses:
|
|
95
|
+
* packages: [{ registryType: "npm", name: "@scope/pkg" }]
|
|
96
|
+
* or: packages: [{ registryType: "oci", identifier: "docker.io/img:tag" }]
|
|
97
|
+
* or: remotes: [{ type: "streamable-http", url: "..." }] (no installable package)
|
|
98
|
+
*/
|
|
99
|
+
function normalizePackages(entry: any): { type: string; name: string }[] {
|
|
100
|
+
// Direct packages array
|
|
101
|
+
if (Array.isArray(entry.packages)) {
|
|
102
|
+
return entry.packages
|
|
103
|
+
.map((p: any) => ({
|
|
104
|
+
type: p.registryType || p.registry_type || p.type || 'npm',
|
|
105
|
+
name: p.name || p.identifier || p.package_name || '',
|
|
106
|
+
}))
|
|
107
|
+
.filter((p: { name: string }) => p.name);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// npm field directly
|
|
111
|
+
if (entry.npm) {
|
|
112
|
+
return [{ type: 'npm', name: entry.npm }];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// pypi field directly
|
|
116
|
+
if (entry.pypi) {
|
|
117
|
+
return [{ type: 'pypi', name: entry.pypi }];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// package_name field
|
|
121
|
+
if (entry.package_name) {
|
|
122
|
+
return [{ type: 'npm', name: entry.package_name }];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ============================================================================
|
|
129
|
+
// npm Registry
|
|
130
|
+
// ============================================================================
|
|
131
|
+
|
|
132
|
+
const NPM_REGISTRY = 'https://registry.npmjs.org';
|
|
133
|
+
const NPM_API = 'https://api.npmjs.org';
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Fetch package metadata from npm for supply-chain analysis
|
|
137
|
+
*/
|
|
138
|
+
export async function fetchNpmPackageInfo(packageName: string): Promise<NpmPackageInfo | null> {
|
|
139
|
+
try {
|
|
140
|
+
// Fetch package metadata
|
|
141
|
+
const metaResponse = await fetch(`${NPM_REGISTRY}/${encodeURIComponent(packageName)}`);
|
|
142
|
+
|
|
143
|
+
if (metaResponse.status === 404) {
|
|
144
|
+
return { exists: false };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!metaResponse.ok) {
|
|
148
|
+
return null; // API error, can't determine
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const meta = await metaResponse.json();
|
|
152
|
+
|
|
153
|
+
// Extract creation date
|
|
154
|
+
const created = meta.time?.created || undefined;
|
|
155
|
+
|
|
156
|
+
// Extract maintainer count
|
|
157
|
+
const maintainers = Array.isArray(meta.maintainers) ? meta.maintainers.length : undefined;
|
|
158
|
+
|
|
159
|
+
// Extract repository URL
|
|
160
|
+
const repository = typeof meta.repository === 'string'
|
|
161
|
+
? meta.repository
|
|
162
|
+
: meta.repository?.url || undefined;
|
|
163
|
+
|
|
164
|
+
// Fetch weekly download count (separate API)
|
|
165
|
+
let weeklyDownloads: number | undefined;
|
|
166
|
+
try {
|
|
167
|
+
const dlResponse = await fetch(
|
|
168
|
+
`${NPM_API}/downloads/point/last-week/${encodeURIComponent(packageName)}`
|
|
169
|
+
);
|
|
170
|
+
if (dlResponse.ok) {
|
|
171
|
+
const dlData = await dlResponse.json();
|
|
172
|
+
weeklyDownloads = dlData.downloads;
|
|
173
|
+
}
|
|
174
|
+
} catch {
|
|
175
|
+
// Download stats unavailable, not critical
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
exists: true,
|
|
180
|
+
created,
|
|
181
|
+
weeklyDownloads,
|
|
182
|
+
maintainers,
|
|
183
|
+
repository,
|
|
184
|
+
};
|
|
185
|
+
} catch {
|
|
186
|
+
return null; // Network error
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel MCP - Registry Scanner
|
|
3
|
+
*
|
|
4
|
+
* Fetches MCP servers from public registries, converts them to synthetic
|
|
5
|
+
* MCPConfigFile objects for the existing scanner pipeline, and runs
|
|
6
|
+
* registry-specific supply-chain checks (npm package age, downloads, etc).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Finding, MCPConfigFile, Scanner } from '../lib/types';
|
|
10
|
+
import { fetchRegistryServers, fetchNpmPackageInfo } from '../lib/registry-client';
|
|
11
|
+
import type { RegistryServer } from '../lib/registry-client';
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// Registry Scanner
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
export interface RegistryScanOptions {
|
|
18
|
+
limit?: number;
|
|
19
|
+
search?: string;
|
|
20
|
+
server?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Convert a registry server entry into a synthetic MCPConfigFile
|
|
25
|
+
* so existing scanners (credential, tool, config) can process it unchanged.
|
|
26
|
+
*/
|
|
27
|
+
export function toSyntheticConfig(server: RegistryServer): MCPConfigFile {
|
|
28
|
+
const servers: Record<string, any> = {};
|
|
29
|
+
|
|
30
|
+
// Build a synthetic server config based on the package type
|
|
31
|
+
const npmPkg = server.packages.find((p) => p.type === 'npm');
|
|
32
|
+
const pypiPkg = server.packages.find((p) => p.type === 'pypi');
|
|
33
|
+
const ociPkg = server.packages.find((p) => p.type === 'oci');
|
|
34
|
+
|
|
35
|
+
if (npmPkg) {
|
|
36
|
+
servers[server.name] = {
|
|
37
|
+
command: 'npx',
|
|
38
|
+
args: ['-y', npmPkg.name],
|
|
39
|
+
};
|
|
40
|
+
} else if (pypiPkg) {
|
|
41
|
+
servers[server.name] = {
|
|
42
|
+
command: 'uvx',
|
|
43
|
+
args: [pypiPkg.name],
|
|
44
|
+
};
|
|
45
|
+
} else if (ociPkg) {
|
|
46
|
+
servers[server.name] = {
|
|
47
|
+
command: 'docker',
|
|
48
|
+
args: ['run', '-i', ociPkg.name],
|
|
49
|
+
};
|
|
50
|
+
} else {
|
|
51
|
+
// Remote-only or unknown -- create a placeholder
|
|
52
|
+
servers[server.name] = {
|
|
53
|
+
command: 'unknown',
|
|
54
|
+
args: [],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
path: `registry://modelcontextprotocol.io/${server.name}`,
|
|
60
|
+
client: 'MCP Registry',
|
|
61
|
+
servers,
|
|
62
|
+
raw: server,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Run registry-specific supply-chain checks against npm packages.
|
|
68
|
+
* These checks go beyond what static config analysis catches.
|
|
69
|
+
*/
|
|
70
|
+
async function checkNpmSupplyChain(
|
|
71
|
+
server: RegistryServer,
|
|
72
|
+
configPath: string,
|
|
73
|
+
): Promise<Finding[]> {
|
|
74
|
+
const findings: Finding[] = [];
|
|
75
|
+
const npmPkg = server.packages.find((p) => p.type === 'npm');
|
|
76
|
+
|
|
77
|
+
if (!npmPkg) return findings;
|
|
78
|
+
|
|
79
|
+
const info = await fetchNpmPackageInfo(npmPkg.name);
|
|
80
|
+
|
|
81
|
+
if (!info) return findings; // API unavailable, skip
|
|
82
|
+
|
|
83
|
+
// Package not found on npm
|
|
84
|
+
if (!info.exists) {
|
|
85
|
+
findings.push({
|
|
86
|
+
id: 'REG-NPM-404',
|
|
87
|
+
severity: 'high',
|
|
88
|
+
category: 'supply-chain',
|
|
89
|
+
title: `npm package not found: ${npmPkg.name}`,
|
|
90
|
+
description: `The npm package "${npmPkg.name}" listed for "${server.name}" does not exist on the npm registry. This may indicate a typosquat risk or a removed package.`,
|
|
91
|
+
server: server.name,
|
|
92
|
+
configFile: configPath,
|
|
93
|
+
evidence: `package: ${npmPkg.name}`,
|
|
94
|
+
remediation: 'Verify the package name is correct. Do not install packages that cannot be found on npm.',
|
|
95
|
+
});
|
|
96
|
+
return findings; // No point checking further
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Package less than 1 week old
|
|
100
|
+
if (info.created) {
|
|
101
|
+
const createdDate = new Date(info.created);
|
|
102
|
+
const ageMs = Date.now() - createdDate.getTime();
|
|
103
|
+
const ageDays = ageMs / (1000 * 60 * 60 * 24);
|
|
104
|
+
|
|
105
|
+
if (ageDays < 7) {
|
|
106
|
+
findings.push({
|
|
107
|
+
id: 'REG-NPM-NEW',
|
|
108
|
+
severity: 'medium',
|
|
109
|
+
category: 'supply-chain',
|
|
110
|
+
title: `Very new npm package: ${npmPkg.name}`,
|
|
111
|
+
description: `The npm package "${npmPkg.name}" was created ${Math.floor(ageDays)} day(s) ago. New packages have not been vetted by the community and may pose supply-chain risks.`,
|
|
112
|
+
server: server.name,
|
|
113
|
+
configFile: configPath,
|
|
114
|
+
evidence: `created: ${info.created}`,
|
|
115
|
+
remediation: 'Wait for the package to establish a track record before installing. Review the source code manually.',
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Low weekly downloads
|
|
121
|
+
if (info.weeklyDownloads !== undefined && info.weeklyDownloads < 100) {
|
|
122
|
+
findings.push({
|
|
123
|
+
id: 'REG-NPM-LOW-DL',
|
|
124
|
+
severity: 'low',
|
|
125
|
+
category: 'supply-chain',
|
|
126
|
+
title: `Low download count: ${npmPkg.name}`,
|
|
127
|
+
description: `The npm package "${npmPkg.name}" has only ${info.weeklyDownloads} weekly downloads. Low adoption means fewer eyes reviewing the code for security issues.`,
|
|
128
|
+
server: server.name,
|
|
129
|
+
configFile: configPath,
|
|
130
|
+
evidence: `weekly downloads: ${info.weeklyDownloads}`,
|
|
131
|
+
remediation: 'Review the package source code before installing. Consider well-established alternatives.',
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Single maintainer
|
|
136
|
+
if (info.maintainers !== undefined && info.maintainers <= 1) {
|
|
137
|
+
findings.push({
|
|
138
|
+
id: 'REG-NPM-SOLE',
|
|
139
|
+
severity: 'info',
|
|
140
|
+
category: 'supply-chain',
|
|
141
|
+
title: `Single maintainer: ${npmPkg.name}`,
|
|
142
|
+
description: `The npm package "${npmPkg.name}" has only ${info.maintainers} maintainer(s). Single-maintainer packages are more susceptible to account takeover attacks.`,
|
|
143
|
+
server: server.name,
|
|
144
|
+
configFile: configPath,
|
|
145
|
+
evidence: `maintainers: ${info.maintainers}`,
|
|
146
|
+
remediation: 'Monitor the package for unexpected changes. Consider pinning to a specific verified version.',
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return findings;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Check registry-level metadata for supply-chain signals
|
|
155
|
+
*/
|
|
156
|
+
function checkRegistryMetadata(
|
|
157
|
+
server: RegistryServer,
|
|
158
|
+
configPath: string,
|
|
159
|
+
): Finding[] {
|
|
160
|
+
const findings: Finding[] = [];
|
|
161
|
+
|
|
162
|
+
// No repository URL
|
|
163
|
+
if (!server.repository) {
|
|
164
|
+
findings.push({
|
|
165
|
+
id: 'REG-NO-REPO',
|
|
166
|
+
severity: 'low',
|
|
167
|
+
category: 'supply-chain',
|
|
168
|
+
title: `No repository URL: ${server.name}`,
|
|
169
|
+
description: `The registry entry for "${server.name}" has no linked source repository. Without a repo, it's harder to audit the server's code.`,
|
|
170
|
+
server: server.name,
|
|
171
|
+
configFile: configPath,
|
|
172
|
+
evidence: 'repository: not provided',
|
|
173
|
+
remediation: 'Prefer MCP servers that link to a public source repository for auditability.',
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// No packages at all
|
|
178
|
+
if (server.packages.length === 0) {
|
|
179
|
+
findings.push({
|
|
180
|
+
id: 'REG-NO-PKG',
|
|
181
|
+
severity: 'low',
|
|
182
|
+
category: 'supply-chain',
|
|
183
|
+
title: `No install packages listed: ${server.name}`,
|
|
184
|
+
description: `The registry entry for "${server.name}" has no npm or PyPI package listed. The installation method is unclear.`,
|
|
185
|
+
server: server.name,
|
|
186
|
+
configFile: configPath,
|
|
187
|
+
evidence: 'packages: []',
|
|
188
|
+
remediation: 'Check the server documentation for manual installation instructions.',
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return findings;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Check if repository URL is reachable (HTTP HEAD request)
|
|
197
|
+
*/
|
|
198
|
+
async function checkRepositoryReachable(
|
|
199
|
+
server: RegistryServer,
|
|
200
|
+
configPath: string,
|
|
201
|
+
): Promise<Finding[]> {
|
|
202
|
+
if (!server.repository) return [];
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const response = await fetch(server.repository, {
|
|
206
|
+
method: 'HEAD',
|
|
207
|
+
redirect: 'follow',
|
|
208
|
+
signal: AbortSignal.timeout(5000),
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
if (response.status === 404) {
|
|
212
|
+
return [{
|
|
213
|
+
id: 'REG-REPO-404',
|
|
214
|
+
severity: 'medium',
|
|
215
|
+
category: 'supply-chain',
|
|
216
|
+
title: `Repository not found: ${server.name}`,
|
|
217
|
+
description: `The repository URL for "${server.name}" returns 404. The source code may have been deleted or moved.`,
|
|
218
|
+
server: server.name,
|
|
219
|
+
configFile: configPath,
|
|
220
|
+
evidence: `repository: ${server.repository} (HTTP ${response.status})`,
|
|
221
|
+
remediation: 'Do not use MCP servers whose source repository has been removed.',
|
|
222
|
+
}];
|
|
223
|
+
}
|
|
224
|
+
} catch {
|
|
225
|
+
// Timeout or network error - don't flag, could be transient
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return [];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ============================================================================
|
|
232
|
+
// Exported Scanner
|
|
233
|
+
// ============================================================================
|
|
234
|
+
|
|
235
|
+
export const registryScanner: Scanner & {
|
|
236
|
+
scanRegistry(options: RegistryScanOptions): Promise<{
|
|
237
|
+
configs: MCPConfigFile[];
|
|
238
|
+
findings: Finding[];
|
|
239
|
+
}>;
|
|
240
|
+
} = {
|
|
241
|
+
name: 'Registry Scanner',
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Standard Scanner interface - not used for registry mode,
|
|
245
|
+
* but satisfies the interface for type compatibility.
|
|
246
|
+
*/
|
|
247
|
+
async scan(_configs: MCPConfigFile[]): Promise<Finding[]> {
|
|
248
|
+
return [];
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Main entry point: fetch from registry, build synthetic configs,
|
|
253
|
+
* and run registry-specific checks.
|
|
254
|
+
*/
|
|
255
|
+
async scanRegistry(options: RegistryScanOptions): Promise<{
|
|
256
|
+
configs: MCPConfigFile[];
|
|
257
|
+
findings: Finding[];
|
|
258
|
+
}> {
|
|
259
|
+
// Fetch servers from registry
|
|
260
|
+
const servers = await fetchRegistryServers(options);
|
|
261
|
+
|
|
262
|
+
if (servers.length === 0) {
|
|
263
|
+
return { configs: [], findings: [] };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Convert to synthetic configs
|
|
267
|
+
const configs = servers.map(toSyntheticConfig);
|
|
268
|
+
|
|
269
|
+
// Run registry-specific checks in parallel
|
|
270
|
+
const allFindings: Finding[] = [];
|
|
271
|
+
let findingCounter = 0;
|
|
272
|
+
|
|
273
|
+
for (const server of servers) {
|
|
274
|
+
const configPath = `registry://modelcontextprotocol.io/${server.name}`;
|
|
275
|
+
|
|
276
|
+
// Metadata checks (synchronous)
|
|
277
|
+
const metaFindings = checkRegistryMetadata(server, configPath);
|
|
278
|
+
allFindings.push(...metaFindings);
|
|
279
|
+
|
|
280
|
+
// npm supply-chain checks (async)
|
|
281
|
+
const npmFindings = await checkNpmSupplyChain(server, configPath);
|
|
282
|
+
allFindings.push(...npmFindings);
|
|
283
|
+
|
|
284
|
+
// Repository reachability check (async)
|
|
285
|
+
const repoFindings = await checkRepositoryReachable(server, configPath);
|
|
286
|
+
allFindings.push(...repoFindings);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Assign unique IDs
|
|
290
|
+
for (const finding of allFindings) {
|
|
291
|
+
findingCounter++;
|
|
292
|
+
finding.id = `${finding.id}-${findingCounter}`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return { configs, findings: allFindings };
|
|
296
|
+
},
|
|
297
|
+
};
|
package/src/scanner/report.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import type { ScanReport, ScanSummary, Finding, MCPConfigFile, ScanStatus } from '../lib/types';
|
|
8
8
|
|
|
9
|
-
const VERSION = '0.
|
|
9
|
+
const VERSION = '0.3.0';
|
|
10
10
|
|
|
11
11
|
// ============================================================================
|
|
12
12
|
// Score Calculation
|