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.
- package/README.md +28 -8
- package/cli/agents/agent-config-scanner.js +240 -1
- package/cli/agents/cicd-scanner.js +42 -0
- package/cli/agents/deep-analyzer.js +39 -19
- package/cli/agents/index.js +4 -1
- package/cli/agents/legal-risk-agent.js +41 -15
- package/cli/agents/memory-poisoning-agent.js +304 -0
- package/cli/agents/scoring-engine.js +16 -1
- package/cli/agents/supply-chain-agent.js +128 -2
- package/cli/bin/ship-safe.js +64 -0
- package/cli/commands/live-advisories.js +241 -0
- package/cli/commands/scan-mcp.js +456 -0
- package/cli/commands/scan-skill.js +14 -0
- package/cli/commands/watch.js +205 -0
- package/cli/providers/llm-provider.js +89 -1
- package/cli/utils/compliance-map.js +66 -0
- package/package.json +2 -2
|
@@ -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
|
+
}
|