ship-safe 6.3.0 → 7.0.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.
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Live Advisory Feed
3
+ * ===================
4
+ *
5
+ * Queries the GitHub Advisory Database and OSV.dev API for real-time
6
+ * advisories on your exact dependency versions. Unlike static CVE checks,
7
+ * this catches actively-compromised packages (Axios 1.8.2, LiteLLM 1.82.7)
8
+ * within hours of publication.
9
+ *
10
+ * USAGE:
11
+ * ship-safe advisories . # Check npm + PyPI deps
12
+ * ship-safe advisories . --ecosystem npm
13
+ * ship-safe advisories . --json
14
+ *
15
+ * APIs used:
16
+ * - OSV.dev (https://api.osv.dev) — aggregates GitHub Advisories, PyPI, npm
17
+ * - No API key needed — fully open, rate-limited to 1000 req/min
18
+ */
19
+
20
+ import fs from 'fs';
21
+ import path from 'path';
22
+
23
+ // =============================================================================
24
+ // DEPENDENCY EXTRACTION
25
+ // =============================================================================
26
+
27
+ /**
28
+ * Extract package names + versions from project manifests.
29
+ * Returns: [{ name, version, ecosystem, file }]
30
+ */
31
+ export function extractDependencies(rootPath) {
32
+ const deps = [];
33
+
34
+ // npm / Node.js
35
+ const pkgPath = path.join(rootPath, 'package.json');
36
+ if (fs.existsSync(pkgPath)) {
37
+ try {
38
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
39
+ const allDeps = {
40
+ ...(pkg.dependencies || {}),
41
+ ...(pkg.devDependencies || {}),
42
+ };
43
+ for (const [name, versionRange] of Object.entries(allDeps)) {
44
+ // Strip semver prefix (^, ~, >=)
45
+ const version = String(versionRange).replace(/^[\^~>=<]+/, '').trim();
46
+ if (/^\d/.test(version)) {
47
+ deps.push({ name, version, ecosystem: 'npm', file: pkgPath });
48
+ }
49
+ }
50
+ } catch { /* skip */ }
51
+ }
52
+
53
+ // Also check package-lock.json for pinned versions (more accurate)
54
+ const lockPath = path.join(rootPath, 'package-lock.json');
55
+ if (fs.existsSync(lockPath)) {
56
+ try {
57
+ const lock = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
58
+ const packages = lock.packages || {};
59
+ for (const [pkgKey, info] of Object.entries(packages)) {
60
+ if (!pkgKey || pkgKey === '') continue; // root entry
61
+ const name = pkgKey.replace(/^node_modules\//, '');
62
+ if (info.version && /^\d/.test(info.version)) {
63
+ // Only add if not already present from package.json
64
+ if (!deps.find(d => d.name === name && d.ecosystem === 'npm')) {
65
+ deps.push({ name, version: info.version, ecosystem: 'npm', file: lockPath });
66
+ }
67
+ }
68
+ }
69
+ } catch { /* skip */ }
70
+ }
71
+
72
+ // Python
73
+ const reqPath = path.join(rootPath, 'requirements.txt');
74
+ if (fs.existsSync(reqPath)) {
75
+ try {
76
+ const lines = fs.readFileSync(reqPath, 'utf-8').split('\n');
77
+ for (const line of lines) {
78
+ const m = line.trim().match(/^([\w-]+)==([\d.]+)/);
79
+ if (m) {
80
+ deps.push({ name: m[1], version: m[2], ecosystem: 'PyPI', file: reqPath });
81
+ }
82
+ }
83
+ } catch { /* skip */ }
84
+ }
85
+
86
+ // Poetry (pyproject.toml)
87
+ const pyprojectPath = path.join(rootPath, 'pyproject.toml');
88
+ if (fs.existsSync(pyprojectPath)) {
89
+ try {
90
+ const content = fs.readFileSync(pyprojectPath, 'utf-8');
91
+ const depSection = content.match(/\[tool\.poetry\.dependencies\]([\s\S]*?)(?:\n\[|$)/);
92
+ if (depSection) {
93
+ const lines = depSection[1].split('\n');
94
+ for (const line of lines) {
95
+ const m = line.match(/^([\w-]+)\s*=\s*"([\d.]+)"/);
96
+ if (m) {
97
+ deps.push({ name: m[1], version: m[2], ecosystem: 'PyPI', file: pyprojectPath });
98
+ }
99
+ }
100
+ }
101
+ } catch { /* skip */ }
102
+ }
103
+
104
+ return deps;
105
+ }
106
+
107
+ // =============================================================================
108
+ // OSV.DEV API
109
+ // =============================================================================
110
+
111
+ /**
112
+ * Query OSV.dev for known vulnerabilities affecting a specific package version.
113
+ * Uses the batch query endpoint for efficiency.
114
+ *
115
+ * @param {{ name: string, version: string, ecosystem: string }[]} deps
116
+ * @returns {Promise<object[]>} — Array of advisory objects
117
+ */
118
+ export async function queryOSV(deps) {
119
+ if (deps.length === 0) return [];
120
+
121
+ // OSV batch query supports up to 1000 packages per request
122
+ const batchSize = 1000;
123
+ const allResults = [];
124
+
125
+ for (let i = 0; i < deps.length; i += batchSize) {
126
+ const batch = deps.slice(i, i + batchSize);
127
+ const queries = batch.map(d => ({
128
+ package: { name: d.name, ecosystem: d.ecosystem },
129
+ version: d.version,
130
+ }));
131
+
132
+ try {
133
+ const response = await fetch('https://api.osv.dev/v1/querybatch', {
134
+ method: 'POST',
135
+ headers: { 'Content-Type': 'application/json' },
136
+ body: JSON.stringify({ queries }),
137
+ });
138
+
139
+ if (!response.ok) {
140
+ throw new Error(`OSV API error: HTTP ${response.status}`);
141
+ }
142
+
143
+ const data = await response.json();
144
+ const results = data.results || [];
145
+
146
+ for (let j = 0; j < results.length; j++) {
147
+ const vulns = results[j].vulns || [];
148
+ for (const vuln of vulns) {
149
+ allResults.push({
150
+ id: vuln.id,
151
+ summary: vuln.summary || '',
152
+ severity: extractSeverity(vuln),
153
+ package: batch[j].name,
154
+ version: batch[j].version,
155
+ ecosystem: batch[j].ecosystem,
156
+ file: deps[i + j].file,
157
+ aliases: vuln.aliases || [],
158
+ published: vuln.published || null,
159
+ modified: vuln.modified || null,
160
+ isMalware: (vuln.id || '').startsWith('MAL-') ||
161
+ (vuln.summary || '').toLowerCase().includes('malicious') ||
162
+ (vuln.summary || '').toLowerCase().includes('malware'),
163
+ });
164
+ }
165
+ }
166
+ } catch (err) {
167
+ // Network error — return what we have so far
168
+ if (allResults.length === 0) {
169
+ throw new Error(`Failed to reach OSV.dev: ${err.message}. Run with --offline to skip live checks.`);
170
+ }
171
+ }
172
+ }
173
+
174
+ return allResults;
175
+ }
176
+
177
+ /**
178
+ * Extract the highest severity from an OSV vulnerability object.
179
+ */
180
+ function extractSeverity(vuln) {
181
+ // Check database_specific severity first
182
+ if (vuln.database_specific?.severity) {
183
+ return vuln.database_specific.severity.toLowerCase();
184
+ }
185
+
186
+ // Check CVSS in severity array
187
+ const sevEntries = vuln.severity || [];
188
+ for (const entry of sevEntries) {
189
+ if (entry.type === 'CVSS_V3') {
190
+ const score = parseFloat(entry.score) || 0;
191
+ if (score >= 9.0) return 'critical';
192
+ if (score >= 7.0) return 'high';
193
+ if (score >= 4.0) return 'medium';
194
+ return 'low';
195
+ }
196
+ }
197
+
198
+ // Malware is always critical
199
+ if ((vuln.id || '').startsWith('MAL-')) return 'critical';
200
+
201
+ return 'medium';
202
+ }
203
+
204
+ // =============================================================================
205
+ // MAIN COMMAND
206
+ // =============================================================================
207
+
208
+ /**
209
+ * Run the live advisory check.
210
+ * Returns findings in ship-safe standard format.
211
+ */
212
+ export async function runLiveAdvisories(rootPath, options = {}) {
213
+ const deps = extractDependencies(rootPath);
214
+
215
+ if (deps.length === 0) {
216
+ return { advisories: [], deps: 0, checked: 0 };
217
+ }
218
+
219
+ // Filter by ecosystem if requested
220
+ const filtered = options.ecosystem
221
+ ? deps.filter(d => d.ecosystem.toLowerCase() === options.ecosystem.toLowerCase())
222
+ : deps;
223
+
224
+ const advisories = await queryOSV(filtered);
225
+
226
+ // Sort: malware first, then by severity
227
+ const sevOrder = { critical: 0, high: 1, medium: 2, low: 3 };
228
+ advisories.sort((a, b) => {
229
+ if (a.isMalware && !b.isMalware) return -1;
230
+ if (!a.isMalware && b.isMalware) return 1;
231
+ return (sevOrder[a.severity] || 2) - (sevOrder[b.severity] || 2);
232
+ });
233
+
234
+ return {
235
+ advisories,
236
+ deps: filtered.length,
237
+ checked: filtered.length,
238
+ };
239
+ }
240
+
241
+ export default { extractDependencies, queryOSV, runLiveAdvisories };
@@ -0,0 +1,456 @@
1
+ /**
2
+ * Scan MCP Command
3
+ * ================
4
+ *
5
+ * Fetches and analyzes an MCP server's tool manifest before connecting to it.
6
+ * Checks for malicious tool definitions, prompt injection in descriptions,
7
+ * exfiltration patterns, excessive permissions, and known-bad server hashes.
8
+ *
9
+ * USAGE:
10
+ * ship-safe scan-mcp <url> Analyze a remote MCP server
11
+ * ship-safe scan-mcp <path> Analyze a local MCP manifest file
12
+ *
13
+ * The command connects to the server's /tools endpoint (or reads the manifest
14
+ * JSON directly) and inspects every tool definition for attack patterns.
15
+ *
16
+ * MCP tool definitions are the new ToxicSkills surface — 36% of agent skills
17
+ * had security flaws; early MCP server audits show similar rates.
18
+ */
19
+
20
+ import fs from 'fs';
21
+ import path from 'path';
22
+ import chalk from 'chalk';
23
+ import { createHash } from 'crypto';
24
+ import * as output from '../utils/output.js';
25
+ import { ThreatIntel } from '../utils/threat-intel.js';
26
+
27
+ // =============================================================================
28
+ // MCP TOOL DESCRIPTION PATTERNS
29
+ // Injected into tool descriptions/annotations to hijack agents that use them.
30
+ // =============================================================================
31
+
32
+ const MCP_TOOL_PATTERNS = [
33
+ // ── Prompt injection in tool description ──────────────────────────────────
34
+ {
35
+ name: 'Prompt injection: override instructions',
36
+ regex: /(?:ignore\s+(?:all\s+)?(?:previous|prior|above|your)\s+instructions|disregard\s+(?:all\s+)?(?:previous|above)|your\s+(?:new|real|actual|true)\s+(?:instructions|role|goal)\s+(?:is|are))/gi,
37
+ severity: 'critical',
38
+ target: 'description',
39
+ },
40
+ {
41
+ name: 'Prompt injection: role hijacking',
42
+ regex: /(?:you\s+are\s+now\s+(?:a|an)|act\s+as\s+(?:a\s+)?(?:hacker|attacker|malicious|evil|unrestricted)|pretend\s+(?:you\s+are|to\s+be)\s+(?:a\s+)?(?:different|new|unrestricted))/gi,
43
+ severity: 'critical',
44
+ target: 'description',
45
+ },
46
+
47
+ // ── Silent exfiltration instructions in tool description ──────────────────
48
+ {
49
+ name: 'Silent data exfiltration',
50
+ regex: /(?:silently|quietly|without\s+(?:notif|alert|inform|telling|showing)|in\s+the\s+background)\s+.{0,60}(?:curl|wget|fetch|POST|send).{0,60}(?:http|https):\/\//gi,
51
+ severity: 'critical',
52
+ target: 'description',
53
+ },
54
+ {
55
+ name: 'Output suppression',
56
+ regex: /(?:do\s+not\s+(?:show|display|reveal|mention|tell|report|log)\s+(?:this|these|the\s+(?:output|result|response|command|action))|hide\s+(?:this|the)\s+(?:output|result|action|command|request))/gi,
57
+ severity: 'high',
58
+ target: 'description',
59
+ },
60
+
61
+ // ── Credential harvesting ─────────────────────────────────────────────────
62
+ {
63
+ name: 'Credential harvesting',
64
+ regex: /(?:extract|retrieve|collect|gather|find|read|access|get)\s+.{0,40}(?:api[_\s]?key|secret|token|password|credential|\.env|npmrc|ssh[_\s]?key|private[_\s]?key)/gi,
65
+ severity: 'critical',
66
+ target: 'description',
67
+ },
68
+ {
69
+ name: 'Sensitive path access',
70
+ regex: /(?:~\/\.(?:ssh|aws|npmrc|netrc|gnupg|config\/gcloud)|\/etc\/(?:passwd|shadow|hosts)|%APPDATA%|%USERPROFILE%)/gi,
71
+ severity: 'critical',
72
+ target: 'description',
73
+ },
74
+
75
+ // ── Known data exfiltration service domains ───────────────────────────────
76
+ {
77
+ name: 'Exfiltration service domain',
78
+ regex: /(?:webhook\.site|requestbin\.com|hookbin\.com|pipedream\.net|ngrok\.io|ngrok\.app|burpcollaborator\.net|interact\.sh|oastify\.com|canarytokens\.com)/gi,
79
+ severity: 'critical',
80
+ target: 'any',
81
+ },
82
+
83
+ // ── Dangerous tool input schema patterns ──────────────────────────────────
84
+ {
85
+ name: 'Shell command input parameter',
86
+ regex: /(?:"command"\s*:\s*\{[^}]*"type"\s*:\s*"string"|"cmd"\s*:\s*\{[^}]*"type"\s*:\s*"string"|"shell"\s*:\s*\{[^}]*"type"\s*:\s*"string")/gi,
87
+ severity: 'medium',
88
+ target: 'schema',
89
+ },
90
+ {
91
+ name: 'Arbitrary code execution parameter',
92
+ regex: /(?:"code"\s*:\s*\{[^}]*"type"\s*:\s*"string"|"script"\s*:\s*"(?:string|object)"|"eval"\s*:\s*\{[^}]*"type"\s*:\s*"string")/gi,
93
+ severity: 'high',
94
+ target: 'schema',
95
+ },
96
+
97
+ // ── Permission escalation in description ──────────────────────────────────
98
+ {
99
+ name: 'Permission escalation',
100
+ regex: /(?:grant\s+(?:me|this\s+(?:tool|server|skill)|yourself)\s+(?:admin|root|sudo|full|all)\s+(?:access|permissions?|rights?)|elevate\s+(?:privileges?|permissions?|rights?)|run\s+as\s+(?:admin|root|sudo))/gi,
101
+ severity: 'high',
102
+ target: 'description',
103
+ },
104
+
105
+ // ── Encoded payload in description ────────────────────────────────────────
106
+ {
107
+ name: 'Encoded payload block',
108
+ regex: /[A-Za-z0-9+\/]{60,}={0,2}/g,
109
+ severity: 'medium',
110
+ target: 'any',
111
+ },
112
+ ];
113
+
114
+ // Dangerous tool name keywords — flag tools whose names suggest shell/exec access
115
+ const DANGEROUS_TOOL_NAMES = [
116
+ /^(?:exec|execute|shell|bash|sh|cmd|terminal|run_command|system|subprocess)$/i,
117
+ /(?:_exec|_shell|_bash|_cmd|_terminal|exec_|shell_|bash_cmd)/i,
118
+ ];
119
+
120
+ // =============================================================================
121
+ // MAIN COMMAND
122
+ // =============================================================================
123
+
124
+ export async function scanMcpCommand(target, options = {}) {
125
+ if (!target) {
126
+ output.error('Usage: ship-safe scan-mcp <url|path>');
127
+ output.info(' Analyze an MCP server\'s tool manifest for security issues before connecting.');
128
+ process.exit(1);
129
+ }
130
+
131
+ console.log();
132
+ output.header('Ship Safe — MCP Server Security Analysis');
133
+ console.log();
134
+
135
+ let manifest, serverName, source;
136
+
137
+ if (target.startsWith('http://') || target.startsWith('https://')) {
138
+ console.log(chalk.gray(` Fetching MCP manifest from: ${target}`));
139
+ try {
140
+ manifest = await fetchMcpManifest(target);
141
+ serverName = new URL(target).hostname;
142
+ source = target;
143
+ } catch (err) {
144
+ output.error(`Failed to fetch MCP manifest: ${err.message}`);
145
+ process.exit(1);
146
+ }
147
+ } else {
148
+ const filePath = path.resolve(target);
149
+ if (!fs.existsSync(filePath)) {
150
+ output.error(`File not found: ${filePath}`);
151
+ process.exit(1);
152
+ }
153
+ try {
154
+ manifest = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
155
+ serverName = path.basename(filePath);
156
+ source = filePath;
157
+ } catch (err) {
158
+ output.error(`Failed to parse manifest: ${err.message}`);
159
+ process.exit(1);
160
+ }
161
+ }
162
+
163
+ const tools = extractTools(manifest);
164
+ console.log(chalk.gray(` Server: ${serverName}`));
165
+ console.log(chalk.gray(` Tools found: ${tools.length}`));
166
+ console.log();
167
+
168
+ if (tools.length === 0) {
169
+ output.warning('No tools found in manifest. Is this a valid MCP tools response?');
170
+ return;
171
+ }
172
+
173
+ const findings = analyzeManifest(manifest, tools, serverName, source);
174
+
175
+ if (options.json) {
176
+ console.log(JSON.stringify({ server: serverName, source, toolCount: tools.length, findings, summary: getSummary(findings) }, null, 2));
177
+ return;
178
+ }
179
+
180
+ printFindings(findings, serverName, tools.length);
181
+
182
+ if (getSummary(findings).critical > 0) {
183
+ process.exit(1);
184
+ }
185
+ }
186
+
187
+ // =============================================================================
188
+ // MCP MANIFEST FETCHING
189
+ // =============================================================================
190
+
191
+ async function fetchMcpManifest(baseUrl) {
192
+ const url = baseUrl.replace(/\/$/, '');
193
+
194
+ // Try MCP tools/list endpoint first (JSON-RPC 2.0)
195
+ const jsonRpcBody = JSON.stringify({
196
+ jsonrpc: '2.0',
197
+ id: 1,
198
+ method: 'tools/list',
199
+ params: {},
200
+ });
201
+
202
+ const jsonRpcRes = await fetch(`${url}`, {
203
+ method: 'POST',
204
+ headers: { 'Content-Type': 'application/json' },
205
+ body: jsonRpcBody,
206
+ signal: AbortSignal.timeout(10000),
207
+ }).catch(() => null);
208
+
209
+ if (jsonRpcRes?.ok) {
210
+ const data = await jsonRpcRes.json();
211
+ if (data?.result?.tools) return data.result;
212
+ }
213
+
214
+ // Fall back to GET /tools (some servers expose this)
215
+ const getRes = await fetch(`${url}/tools`, {
216
+ signal: AbortSignal.timeout(10000),
217
+ }).catch(() => null);
218
+
219
+ if (getRes?.ok) {
220
+ return await getRes.json();
221
+ }
222
+
223
+ // Fall back to root endpoint
224
+ const rootRes = await fetch(`${url}`, {
225
+ signal: AbortSignal.timeout(10000),
226
+ }).catch(() => null);
227
+
228
+ if (rootRes?.ok) {
229
+ const text = await rootRes.text();
230
+ try {
231
+ return JSON.parse(text);
232
+ } catch {
233
+ throw new Error('Server responded but returned non-JSON content');
234
+ }
235
+ }
236
+
237
+ throw new Error('Could not retrieve MCP manifest (tried tools/list JSON-RPC, GET /tools, GET /)');
238
+ }
239
+
240
+ // =============================================================================
241
+ // TOOL EXTRACTION — handles multiple MCP manifest formats
242
+ // =============================================================================
243
+
244
+ function extractTools(manifest) {
245
+ // MCP tools/list result: { tools: [...] }
246
+ if (Array.isArray(manifest?.tools)) return manifest.tools;
247
+ // Direct array
248
+ if (Array.isArray(manifest)) return manifest;
249
+ // { result: { tools: [...] } }
250
+ if (Array.isArray(manifest?.result?.tools)) return manifest.result.tools;
251
+ return [];
252
+ }
253
+
254
+ // =============================================================================
255
+ // ANALYSIS
256
+ // =============================================================================
257
+
258
+ function analyzeManifest(manifest, tools, serverName, source) {
259
+ const findings = [];
260
+ const rawJson = JSON.stringify(manifest);
261
+
262
+ // 1. Threat intel hash check on full manifest
263
+ const hash = createHash('sha256').update(rawJson).digest('hex');
264
+ const intelMatch = ThreatIntel.lookupHash(hash);
265
+ if (intelMatch) {
266
+ findings.push({
267
+ check: 'threat-intel',
268
+ name: `Known malicious MCP server: ${intelMatch.name}`,
269
+ severity: 'critical',
270
+ tool: null,
271
+ matched: `SHA-256: ${hash} — ${intelMatch.description}`,
272
+ });
273
+ }
274
+
275
+ // 2. Threat intel signature check on raw manifest
276
+ const sigMatches = ThreatIntel.matchSignatures(rawJson);
277
+ for (const sig of sigMatches) {
278
+ findings.push({
279
+ check: 'threat-intel',
280
+ name: `Threat intel match: ${sig.description}`,
281
+ severity: sig.severity || 'critical',
282
+ tool: null,
283
+ matched: `Pattern: ${sig.pattern}`,
284
+ });
285
+ }
286
+
287
+ // 3. Per-tool analysis
288
+ for (const tool of tools) {
289
+ findings.push(...analyzeToolDefinition(tool));
290
+ }
291
+
292
+ // 4. Server-level checks
293
+ findings.push(...checkServerLevel(manifest, serverName));
294
+
295
+ return findings;
296
+ }
297
+
298
+ function analyzeToolDefinition(tool) {
299
+ const findings = [];
300
+ const name = tool.name || '(unnamed)';
301
+ const description = tool.description || '';
302
+ const schemaStr = JSON.stringify(tool.inputSchema || tool.input_schema || {});
303
+
304
+ // Check description against patterns
305
+ for (const pattern of MCP_TOOL_PATTERNS) {
306
+ if (pattern.target === 'schema') continue; // schema checked separately below
307
+ pattern.regex.lastIndex = 0;
308
+ const text = pattern.target === 'description' ? description
309
+ : pattern.target === 'any' ? description + ' ' + schemaStr
310
+ : description;
311
+ if (pattern.regex.test(text)) {
312
+ findings.push({
313
+ check: 'static-analysis',
314
+ name: pattern.name,
315
+ severity: pattern.severity,
316
+ tool: name,
317
+ matched: (description + schemaStr).slice(0, 120),
318
+ });
319
+ }
320
+ }
321
+
322
+ // Check schema for dangerous input patterns
323
+ for (const pattern of MCP_TOOL_PATTERNS) {
324
+ if (pattern.target !== 'schema') continue;
325
+ pattern.regex.lastIndex = 0;
326
+ if (pattern.regex.test(schemaStr)) {
327
+ findings.push({
328
+ check: 'schema-analysis',
329
+ name: pattern.name,
330
+ severity: pattern.severity,
331
+ tool: name,
332
+ matched: schemaStr.slice(0, 120),
333
+ });
334
+ }
335
+ }
336
+
337
+ // Check tool name against dangerous name list
338
+ for (const namePattern of DANGEROUS_TOOL_NAMES) {
339
+ if (namePattern.test(name)) {
340
+ findings.push({
341
+ check: 'tool-name',
342
+ name: `Dangerous tool name: "${name}"`,
343
+ severity: 'high',
344
+ tool: name,
345
+ matched: `Tool name matches high-risk pattern: ${namePattern}`,
346
+ });
347
+ break;
348
+ }
349
+ }
350
+
351
+ // Check for excessive required parameters (information harvesting)
352
+ const required = tool.inputSchema?.required || tool.input_schema?.required || [];
353
+ const properties = tool.inputSchema?.properties || tool.input_schema?.properties || {};
354
+ const propNames = Object.keys(properties);
355
+ const sensitiveParams = propNames.filter(p =>
356
+ /(?:api[_\s]?key|token|password|secret|credential|auth|private)/i.test(p)
357
+ );
358
+ if (sensitiveParams.length > 0) {
359
+ findings.push({
360
+ check: 'schema-analysis',
361
+ name: `Tool requires sensitive parameters: ${sensitiveParams.join(', ')}`,
362
+ severity: 'high',
363
+ tool: name,
364
+ matched: `Required sensitive params: [${sensitiveParams.join(', ')}]`,
365
+ });
366
+ }
367
+
368
+ return findings;
369
+ }
370
+
371
+ function checkServerLevel(manifest, serverName) {
372
+ const findings = [];
373
+ const raw = JSON.stringify(manifest);
374
+
375
+ // Check for excessively large manifest (may hide payloads)
376
+ if (raw.length > 500_000) {
377
+ findings.push({
378
+ check: 'server-level',
379
+ name: 'Unusually large manifest',
380
+ severity: 'medium',
381
+ tool: null,
382
+ matched: `Manifest size: ${(raw.length / 1024).toFixed(1)} KB (>500 KB is suspicious)`,
383
+ });
384
+ }
385
+
386
+ // Check for tools with no description (reduces reviewability)
387
+ const tools = extractTools(manifest);
388
+ const noDesc = tools.filter(t => !t.description || t.description.trim().length < 10);
389
+ if (noDesc.length > 0 && noDesc.length === tools.length) {
390
+ findings.push({
391
+ check: 'server-level',
392
+ name: 'All tools lack descriptions',
393
+ severity: 'medium',
394
+ tool: null,
395
+ matched: `${noDesc.length}/${tools.length} tools have no meaningful description — cannot assess intent`,
396
+ });
397
+ }
398
+
399
+ return findings;
400
+ }
401
+
402
+ // =============================================================================
403
+ // OUTPUT
404
+ // =============================================================================
405
+
406
+ function printFindings(findings, serverName, toolCount) {
407
+ const summary = getSummary(findings);
408
+
409
+ if (findings.length === 0) {
410
+ console.log(chalk.green.bold(` ✔ ${serverName}: No security issues found across ${toolCount} tool(s).`));
411
+ console.log();
412
+ return;
413
+ }
414
+
415
+ console.log(chalk.red.bold(` ✘ ${serverName}: ${findings.length} issue(s) found across ${toolCount} tool(s)`));
416
+ console.log();
417
+
418
+ // Group by tool
419
+ const byTool = new Map();
420
+ for (const f of findings) {
421
+ const key = f.tool || '(server-level)';
422
+ if (!byTool.has(key)) byTool.set(key, []);
423
+ byTool.get(key).push(f);
424
+ }
425
+
426
+ for (const [toolName, toolFindings] of byTool) {
427
+ if (toolName !== '(server-level)') {
428
+ console.log(chalk.cyan(` Tool: ${toolName}`));
429
+ } else {
430
+ console.log(chalk.cyan(` Server-level`));
431
+ }
432
+
433
+ for (const f of toolFindings) {
434
+ const sevColor = f.severity === 'critical' ? chalk.red.bold
435
+ : f.severity === 'high' ? chalk.yellow
436
+ : chalk.blue;
437
+ console.log(` ${sevColor(`[${f.severity.toUpperCase()}]`)} ${chalk.white(f.name)}`);
438
+ if (f.matched) console.log(chalk.gray(` ${f.matched.slice(0, 120)}`));
439
+ }
440
+ console.log();
441
+ }
442
+
443
+ if (summary.critical > 0) {
444
+ console.log(chalk.red.bold(' ⚠ DO NOT CONNECT to this MCP server — critical security issues detected.'));
445
+ console.log();
446
+ }
447
+ }
448
+
449
+ function getSummary(findings) {
450
+ return {
451
+ total: findings.length,
452
+ critical: findings.filter(f => f.severity === 'critical').length,
453
+ high: findings.filter(f => f.severity === 'high').length,
454
+ medium: findings.filter(f => f.severity === 'medium').length,
455
+ };
456
+ }