mcp-proof 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +218 -0
- package/bin/mcp-proof.js +767 -0
- package/engine.js +475 -0
- package/package.json +38 -0
package/engine.js
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
// mcp-proof trust engine — detection logic.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors go/internal/mcptrust/tool_poisoning.go and publisher.go so the
|
|
4
|
+
// JS verdict matches what the production gateway would return for the
|
|
5
|
+
// same input. Pure CommonJS, zero dependencies — runs on any Node ≥ 18
|
|
6
|
+
// and in the browser via require/import.
|
|
7
|
+
//
|
|
8
|
+
// API:
|
|
9
|
+
// const { evaluate, scanTools, verifyPublisher, validateTransport } = require('mcp-proof');
|
|
10
|
+
// const result = evaluate({ source, transport, bind_address, tools });
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
// ── Tool-poisoning signatures ───────────────────────────────────────────
|
|
15
|
+
const SIGNATURES = [
|
|
16
|
+
{
|
|
17
|
+
name: 'instruction_hijack_ignore',
|
|
18
|
+
sev: 'high',
|
|
19
|
+
re: /\b(ignore|disregard|forget)\b[^.]{0,40}\b(previous|prior|above|earlier)\b[^.]{0,40}\b(instructions?|prompts?|rules?)\b/i,
|
|
20
|
+
reason: 'description tries to override previous instructions',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'instruction_hijack_before',
|
|
24
|
+
sev: 'medium',
|
|
25
|
+
re: /\bbefore (returning|responding|answering|the (final )?(answer|result|response))\b/i,
|
|
26
|
+
reason: 'description injects a step before the model responds',
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: 'instruction_hijack_after',
|
|
30
|
+
sev: 'medium',
|
|
31
|
+
re: /\bafter (returning|responding|completing)\b[^.]{0,80}\b(also|additionally)\b/i,
|
|
32
|
+
reason: 'description appends additional behavior to the response',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'instruction_hijack_silent',
|
|
36
|
+
sev: 'high',
|
|
37
|
+
re: /\b(do not|don't|never|silently|without (mentioning|telling))\b[^.]{0,80}\b(mention|tell|inform|notify|reveal|disclose)\b/i,
|
|
38
|
+
reason: 'description instructs the model to act silently',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'system_role_overwrite',
|
|
42
|
+
sev: 'critical',
|
|
43
|
+
re: /\bSYSTEM\s*(:|prompt)\b|\b(you are now|act as|pretend (to be|you are))\b\s+[A-Za-z]+/i,
|
|
44
|
+
reason: 'description attempts to overwrite the system persona',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'path_ssh_keys',
|
|
48
|
+
sev: 'critical',
|
|
49
|
+
re: /~?\/?\.ssh(\/|$)|id_rsa|id_ecdsa|id_ed25519|authorized_keys|known_hosts/i,
|
|
50
|
+
reason: 'description references SSH credentials',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'path_aws_creds',
|
|
54
|
+
sev: 'critical',
|
|
55
|
+
re: /~?\/?\.aws(\/credentials|\/config)?|AWS_SECRET_ACCESS_KEY|AKIA[0-9A-Z]{12,}/i,
|
|
56
|
+
reason: 'description references AWS credentials',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'path_env_files',
|
|
60
|
+
sev: 'high',
|
|
61
|
+
re: /(^|[/\s])\.env(\.[a-z0-9]+)?\b|process\.env/i,
|
|
62
|
+
reason: 'description references environment / dotenv files',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'path_etc_secrets',
|
|
66
|
+
sev: 'critical',
|
|
67
|
+
re: /\/etc\/(passwd|shadow|sudoers|gshadow)/i,
|
|
68
|
+
reason: 'description references /etc credential files',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: 'path_browser_storage',
|
|
72
|
+
sev: 'high',
|
|
73
|
+
re: /(Cookies|Login Data|Web Data|Local Storage)\b|~?\/\.config\/google-chrome|Application Support\/Google\/Chrome/i,
|
|
74
|
+
reason: 'description references browser credential stores',
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: 'path_kube_creds',
|
|
78
|
+
sev: 'high',
|
|
79
|
+
re: /~?\/?\.kube(\/config)?|KUBECONFIG/i,
|
|
80
|
+
reason: 'description references kubeconfig',
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: 'cross_tool_invocation',
|
|
84
|
+
sev: 'high',
|
|
85
|
+
re: /\b(call|invoke|use|execute|run)\b[^.]{0,60}\btool\b\s*[:'"]?\s*[a-zA-Z_][\w-]{1,40}/i,
|
|
86
|
+
reason: 'description references invoking another tool',
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: 'data_exfil_hint',
|
|
90
|
+
sev: 'high',
|
|
91
|
+
re: /\b(send|post|upload|exfiltrate|leak|forward)\b[^.]{0,80}\b(to|via)\b[^.]{0,40}\b(http|https|api|server|endpoint)\b/i,
|
|
92
|
+
reason: 'description hints at outbound exfiltration',
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: 'include_in_result',
|
|
96
|
+
sev: 'medium',
|
|
97
|
+
re: /\b(include|append|embed|attach)\b[^.]{0,80}\b(in (the )?(result|response|output)|to (the )?(output|response))\b/i,
|
|
98
|
+
reason: 'description tells the model to include extra data in the result',
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: 'inline_url',
|
|
102
|
+
sev: 'low',
|
|
103
|
+
re: /https?:\/\/[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}(:\d+)?(\/[^\s)]*)?/,
|
|
104
|
+
reason: 'description contains a URL',
|
|
105
|
+
},
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
const HIDDEN_CHARS = new RegExp(
|
|
109
|
+
"[\\u200B\\u200C\\u200D\\u2060\\u00AD\\uFEFF\\u2028\\u2029\\u202E\\u202D\\u2066\\u2067\\u2068\\u2069]",
|
|
110
|
+
"u"
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
const BASE64_RE = /[A-Za-z0-9+/]{40,}={0,2}/g;
|
|
115
|
+
|
|
116
|
+
const SUSPICIOUS_DECODED =
|
|
117
|
+
/(ignore|system|instruction|\.ssh|\/bin\/)/i;
|
|
118
|
+
|
|
119
|
+
// Skill / educational manifest context. A sensitive-path or instruction
|
|
120
|
+
// reference inside a warning or example is not a real attack directive.
|
|
121
|
+
const SKILL_BENIGN_MARKERS =
|
|
122
|
+
/\b(?:warning|⚠️|example|demonstration|sample|illustration|not\s+safe|suspicious|malicious|attack)\b/i;
|
|
123
|
+
|
|
124
|
+
function isBenignSkillContext(text) {
|
|
125
|
+
return SKILL_BENIGN_MARKERS.test(text);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function findAllMatches(corpus, re) {
|
|
129
|
+
const flags = (re.flags || '') + 'g';
|
|
130
|
+
const g = new RegExp(re.source, flags.split('').filter((v, i, a) => a.indexOf(v) === i).join(''));
|
|
131
|
+
const out = [];
|
|
132
|
+
let m;
|
|
133
|
+
while ((m = g.exec(corpus)) !== null) {
|
|
134
|
+
out.push({ text: m[0], index: m.index });
|
|
135
|
+
if (m.index === g.lastIndex) g.lastIndex++;
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Trusted publisher registry ──────────────────────────────────────────
|
|
141
|
+
const TRUSTED_NPM_SCOPES = ['@modelcontextprotocol'];
|
|
142
|
+
const TRUSTED_GITHUB_ORGS = ['modelcontextprotocol'];
|
|
143
|
+
const KNOWN_NAMES = [
|
|
144
|
+
'@modelcontextprotocol/server-filesystem',
|
|
145
|
+
'@modelcontextprotocol/server-github',
|
|
146
|
+
'@modelcontextprotocol/server-postgres',
|
|
147
|
+
'@modelcontextprotocol/server-slack',
|
|
148
|
+
'@modelcontextprotocol/server-brave-search',
|
|
149
|
+
'@modelcontextprotocol/server-puppeteer',
|
|
150
|
+
'@modelcontextprotocol/server-memory',
|
|
151
|
+
'@modelcontextprotocol/server-google-maps',
|
|
152
|
+
'@modelcontextprotocol/server-git',
|
|
153
|
+
'@modelcontextprotocol/server-google-drive',
|
|
154
|
+
'@modelcontextprotocol/server-gdrive',
|
|
155
|
+
'@modelcontextprotocol/server-everything',
|
|
156
|
+
'@modelcontextprotocol/server-fetch',
|
|
157
|
+
'@modelcontextprotocol/server-sqlite',
|
|
158
|
+
'@modelcontextprotocol/server-sentry',
|
|
159
|
+
'@modelcontextprotocol/server-sequential-thinking',
|
|
160
|
+
'@modelcontextprotocol/server-aws-kb-retrieval',
|
|
161
|
+
'@modelcontextprotocol/server-gitlab',
|
|
162
|
+
'@modelcontextprotocol/server-time',
|
|
163
|
+
'@modelcontextprotocol/server-redis',
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
const SEV_RANK = { low: 1, medium: 2, high: 3, critical: 4 };
|
|
167
|
+
|
|
168
|
+
function levenshtein(a, b) {
|
|
169
|
+
if (a === b) return 0;
|
|
170
|
+
const m = a.length;
|
|
171
|
+
const n = b.length;
|
|
172
|
+
if (m === 0) return n;
|
|
173
|
+
if (n === 0) return m;
|
|
174
|
+
let prev = new Array(n + 1);
|
|
175
|
+
let curr = new Array(n + 1);
|
|
176
|
+
for (let j = 0; j <= n; j++) prev[j] = j;
|
|
177
|
+
for (let i = 1; i <= m; i++) {
|
|
178
|
+
curr[0] = i;
|
|
179
|
+
for (let j = 1; j <= n; j++) {
|
|
180
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
181
|
+
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
|
|
182
|
+
}
|
|
183
|
+
[prev, curr] = [curr, prev];
|
|
184
|
+
}
|
|
185
|
+
return prev[n];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function verifyPublisher(source) {
|
|
189
|
+
const src = (source || '').toLowerCase().trim();
|
|
190
|
+
const res = {
|
|
191
|
+
trust: 'unknown',
|
|
192
|
+
typosquat: false,
|
|
193
|
+
integrityScore: 5.0,
|
|
194
|
+
reason: '',
|
|
195
|
+
matchedRegistry: '',
|
|
196
|
+
};
|
|
197
|
+
if (!src) {
|
|
198
|
+
res.reason = 'no source supplied';
|
|
199
|
+
res.integrityScore = 3.0;
|
|
200
|
+
return res;
|
|
201
|
+
}
|
|
202
|
+
if (src.startsWith('npm:')) {
|
|
203
|
+
const pkg = src.slice('npm:'.length);
|
|
204
|
+
res.matchedRegistry = 'npm';
|
|
205
|
+
for (const scope of TRUSTED_NPM_SCOPES) {
|
|
206
|
+
if (pkg.startsWith(scope + '/')) {
|
|
207
|
+
res.trust = 'verified';
|
|
208
|
+
res.integrityScore = 9.0;
|
|
209
|
+
res.reason = `npm scope ${scope} is on the trusted publisher registry`;
|
|
210
|
+
return res;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (!KNOWN_NAMES.includes(pkg)) {
|
|
214
|
+
for (const known of KNOWN_NAMES) {
|
|
215
|
+
if (levenshtein(pkg, known) <= 2) {
|
|
216
|
+
res.trust = 'suspicious';
|
|
217
|
+
res.typosquat = true;
|
|
218
|
+
res.integrityScore = 2.0;
|
|
219
|
+
res.reason = `package "${pkg}" resembles trusted package "${known}" — likely typosquat`;
|
|
220
|
+
return res;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
res.reason = 'npm package not on the trusted registry — treat as untrusted';
|
|
225
|
+
res.integrityScore = 5.0;
|
|
226
|
+
return res;
|
|
227
|
+
}
|
|
228
|
+
if (src.startsWith('git+https://') || src.startsWith('https://github.com/')) {
|
|
229
|
+
const u = src.replace(/^git\+/, '').replace(/^https:\/\/github\.com\//, '');
|
|
230
|
+
const org = u.split('/')[0];
|
|
231
|
+
res.matchedRegistry = 'github';
|
|
232
|
+
if (TRUSTED_GITHUB_ORGS.includes(org)) {
|
|
233
|
+
res.trust = 'verified';
|
|
234
|
+
res.integrityScore = 8.5;
|
|
235
|
+
res.reason = `github org ${org} is on the trusted publisher registry`;
|
|
236
|
+
} else {
|
|
237
|
+
res.reason = `github org ${org} not on the trusted registry`;
|
|
238
|
+
}
|
|
239
|
+
return res;
|
|
240
|
+
}
|
|
241
|
+
if (src.startsWith('file://') || src.startsWith('/')) {
|
|
242
|
+
res.integrityScore = 4.0;
|
|
243
|
+
res.reason = 'local source — origin cannot be verified';
|
|
244
|
+
return res;
|
|
245
|
+
}
|
|
246
|
+
res.reason = 'unrecognised source format';
|
|
247
|
+
res.integrityScore = 3.0;
|
|
248
|
+
return res;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function hasSuspiciousBase64(s) {
|
|
252
|
+
const matches = s.match(BASE64_RE);
|
|
253
|
+
if (!matches) return false;
|
|
254
|
+
for (const m of matches) {
|
|
255
|
+
try {
|
|
256
|
+
const decoded = Buffer.from(m, 'base64').toString('utf8');
|
|
257
|
+
if (SUSPICIOUS_DECODED.test(decoded)) return true;
|
|
258
|
+
} catch (_) {
|
|
259
|
+
/* invalid base64, skip */
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function scanTools(tools, opts) {
|
|
266
|
+
const skillContext = (opts || {}).skillContext === true;
|
|
267
|
+
if (!tools || tools.length === 0) {
|
|
268
|
+
return { detected: false, severity: 'low', reason: 'no tools supplied', perTool: [] };
|
|
269
|
+
}
|
|
270
|
+
let maxSev = 'low';
|
|
271
|
+
const perTool = [];
|
|
272
|
+
for (const t of tools) {
|
|
273
|
+
const corpus = [t.name || '', t.description || '', JSON.stringify(t.inputSchema || {})].join('\n');
|
|
274
|
+
const finding = { tool: t.name || '(unnamed)', severity: 'low', patterns: [], evidence: '', hidden: false };
|
|
275
|
+
for (const sig of SIGNATURES) {
|
|
276
|
+
const matches = findAllMatches(corpus, sig.re);
|
|
277
|
+
if (matches.length === 0) continue;
|
|
278
|
+
let selected = null;
|
|
279
|
+
for (const m of matches) {
|
|
280
|
+
const ctx = corpus.slice(
|
|
281
|
+
Math.max(0, m.index - 60),
|
|
282
|
+
Math.min(corpus.length, m.index + m.text.length + 60)
|
|
283
|
+
);
|
|
284
|
+
let sev = sig.sev;
|
|
285
|
+
let downgraded = false;
|
|
286
|
+
// For skill manifests, use the whole prompt for context framing. If the
|
|
287
|
+
// author has explicitly labeled the content as a warning/example, the
|
|
288
|
+
// match is treated as educational rather than an attack directive.
|
|
289
|
+
if (skillContext && isBenignSkillContext(corpus)) {
|
|
290
|
+
sev = 'low';
|
|
291
|
+
downgraded = true;
|
|
292
|
+
}
|
|
293
|
+
selected = { sev, downgraded, evidence: ctx.replace(/\n/g, ' ') };
|
|
294
|
+
if (!downgraded) break; // real attack wins over benign example
|
|
295
|
+
}
|
|
296
|
+
if (!selected) continue;
|
|
297
|
+
finding.patterns.push({
|
|
298
|
+
name: sig.name,
|
|
299
|
+
severity: selected.sev,
|
|
300
|
+
reason: sig.reason,
|
|
301
|
+
downgraded: selected.downgraded,
|
|
302
|
+
});
|
|
303
|
+
if (SEV_RANK[selected.sev] > SEV_RANK[finding.severity]) {
|
|
304
|
+
finding.severity = selected.sev;
|
|
305
|
+
finding.evidence = selected.evidence;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (HIDDEN_CHARS.test(corpus)) {
|
|
309
|
+
finding.hidden = true;
|
|
310
|
+
finding.patterns.push({
|
|
311
|
+
name: 'hidden_unicode',
|
|
312
|
+
severity: 'high',
|
|
313
|
+
reason: 'description contains zero-width / bidi-override / soft-hyphen characters',
|
|
314
|
+
});
|
|
315
|
+
if (SEV_RANK.high > SEV_RANK[finding.severity]) finding.severity = 'high';
|
|
316
|
+
}
|
|
317
|
+
if (hasSuspiciousBase64(corpus)) {
|
|
318
|
+
finding.hidden = true;
|
|
319
|
+
finding.patterns.push({
|
|
320
|
+
name: 'embedded_base64_payload',
|
|
321
|
+
severity: 'high',
|
|
322
|
+
reason: 'description contains a base64 blob that decodes to instruction-like content',
|
|
323
|
+
});
|
|
324
|
+
if (SEV_RANK.high > SEV_RANK[finding.severity]) finding.severity = 'high';
|
|
325
|
+
}
|
|
326
|
+
const hijack = ['instruction_hijack_before', 'instruction_hijack_after', 'instruction_hijack_silent', 'include_in_result'];
|
|
327
|
+
const sensitive = ['path_ssh_keys', 'path_aws_creds', 'path_env_files', 'path_etc_secrets', 'path_kube_creds', 'path_browser_storage'];
|
|
328
|
+
const activeNames = finding.patterns.filter((p) => !p.downgraded).map((p) => p.name);
|
|
329
|
+
if (hijack.some((h) => activeNames.includes(h)) && sensitive.some((s) => activeNames.includes(s))) {
|
|
330
|
+
finding.severity = 'critical';
|
|
331
|
+
}
|
|
332
|
+
if (finding.patterns.length > 0) {
|
|
333
|
+
perTool.push(finding);
|
|
334
|
+
if (SEV_RANK[finding.severity] > SEV_RANK[maxSev]) maxSev = finding.severity;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
detected: perTool.length > 0,
|
|
339
|
+
severity: maxSev,
|
|
340
|
+
reason: perTool.length > 0
|
|
341
|
+
? `${perTool.length}/${tools.length} tools contain instruction-hijacking or sensitive-path references`
|
|
342
|
+
: 'no instruction-hijacking patterns detected',
|
|
343
|
+
perTool,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function scanSkillManifest(markdown) {
|
|
348
|
+
const tools = [];
|
|
349
|
+
// If the skill manifest embeds a JSON tools array, use it.
|
|
350
|
+
const jsonBlock = markdown.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
351
|
+
if (jsonBlock) {
|
|
352
|
+
try {
|
|
353
|
+
const parsed = JSON.parse(jsonBlock[1].trim());
|
|
354
|
+
if (Array.isArray(parsed)) tools.push(...parsed);
|
|
355
|
+
else if (Array.isArray(parsed.tools)) tools.push(...parsed.tools);
|
|
356
|
+
} catch (_) { /* not a JSON block, fall through */ }
|
|
357
|
+
}
|
|
358
|
+
if (tools.length === 0) {
|
|
359
|
+
// Otherwise treat the entire SKILL.md prompt as one skill description.
|
|
360
|
+
tools.push({ name: 'skill', description: markdown });
|
|
361
|
+
}
|
|
362
|
+
return evaluate({
|
|
363
|
+
source: 'skill:local',
|
|
364
|
+
transport: 'stdio',
|
|
365
|
+
tools,
|
|
366
|
+
skillContext: true,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function validateTransport(transport, bind) {
|
|
371
|
+
const res = { transport, binding: bind || '', exposureRisk: 'low', reason: '' };
|
|
372
|
+
const t = (transport || '').toLowerCase();
|
|
373
|
+
if (!t || t === 'stdio') {
|
|
374
|
+
res.transport = 'stdio';
|
|
375
|
+
res.exposureRisk = 'low';
|
|
376
|
+
res.reason = 'stdio: local process, no network listener';
|
|
377
|
+
return res;
|
|
378
|
+
}
|
|
379
|
+
if (t === 'http' || t === 'sse' || t === 'http_streaming') {
|
|
380
|
+
let ip = (bind || '').trim();
|
|
381
|
+
if (!ip) {
|
|
382
|
+
res.exposureRisk = 'high';
|
|
383
|
+
res.reason = 'binding not specified — assuming public';
|
|
384
|
+
return res;
|
|
385
|
+
}
|
|
386
|
+
const portMatch = ip.match(/^(.+):(\d+)$/);
|
|
387
|
+
if (portMatch) ip = portMatch[1];
|
|
388
|
+
if (ip === '127.0.0.1' || ip === '::1' || ip === 'localhost') {
|
|
389
|
+
res.exposureRisk = 'low';
|
|
390
|
+
res.reason = 'loopback binding — local-only';
|
|
391
|
+
} else if (ip === '0.0.0.0' || ip === '::') {
|
|
392
|
+
res.exposureRisk = 'critical';
|
|
393
|
+
res.reason = '0.0.0.0 binding exposes MCP server to every network interface — BLOCK';
|
|
394
|
+
} else if (/^10\.|^192\.168\.|^172\.(1[6-9]|2\d|3[01])\./.test(ip)) {
|
|
395
|
+
res.exposureRisk = 'high';
|
|
396
|
+
res.reason = 'private network binding — exposed to LAN';
|
|
397
|
+
} else {
|
|
398
|
+
res.exposureRisk = 'critical';
|
|
399
|
+
res.reason = 'non-loopback binding — assume hostile network';
|
|
400
|
+
}
|
|
401
|
+
return res;
|
|
402
|
+
}
|
|
403
|
+
res.exposureRisk = 'high';
|
|
404
|
+
res.reason = 'unknown transport';
|
|
405
|
+
return res;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function evaluate(req) {
|
|
409
|
+
const start = Date.now();
|
|
410
|
+
const score = {
|
|
411
|
+
publisher: verifyPublisher(req.source),
|
|
412
|
+
toolPoisoning: scanTools(req.tools || [], { skillContext: !!req.skillContext }),
|
|
413
|
+
transport: validateTransport(req.transport, req.bind_address),
|
|
414
|
+
reasons: [],
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
const pubW = score.publisher.integrityScore;
|
|
418
|
+
let poisonW = 10.0;
|
|
419
|
+
switch (score.toolPoisoning.severity) {
|
|
420
|
+
case 'critical': poisonW = 0; break;
|
|
421
|
+
case 'high': poisonW = 3; break;
|
|
422
|
+
case 'medium': poisonW = 6; break;
|
|
423
|
+
}
|
|
424
|
+
let transportW = 10.0;
|
|
425
|
+
if (score.transport.exposureRisk === 'critical') transportW = 0;
|
|
426
|
+
else if (score.transport.exposureRisk === 'high') transportW = 5;
|
|
427
|
+
|
|
428
|
+
score.composite = round1(0.30 * pubW + 0.45 * poisonW + 0.25 * transportW);
|
|
429
|
+
|
|
430
|
+
let hardBlock = false;
|
|
431
|
+
if (score.toolPoisoning.detected && score.toolPoisoning.severity === 'critical') {
|
|
432
|
+
hardBlock = true;
|
|
433
|
+
score.reasons.push(`HARD BLOCK: critical tool-poisoning detected — ${score.toolPoisoning.reason}`);
|
|
434
|
+
}
|
|
435
|
+
if (score.transport.exposureRisk === 'critical') {
|
|
436
|
+
hardBlock = true;
|
|
437
|
+
score.reasons.push(`HARD BLOCK: ${score.transport.reason}`);
|
|
438
|
+
}
|
|
439
|
+
// Typosquat is also a hard-block: by definition, the package is
|
|
440
|
+
// pretending to be something it isn't. Composite score can't override.
|
|
441
|
+
if (score.publisher.typosquat) {
|
|
442
|
+
hardBlock = true;
|
|
443
|
+
score.reasons.push(`HARD BLOCK: ${score.publisher.reason}`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (hardBlock) score.decision = 'block';
|
|
447
|
+
else if (score.composite >= 7.0) score.decision = 'allow';
|
|
448
|
+
else if (score.composite >= 4.0) {
|
|
449
|
+
score.decision = 'warn';
|
|
450
|
+
score.reasons.push(`composite ${score.composite.toFixed(1)} below allow threshold 7.0 — operator review required`);
|
|
451
|
+
} else {
|
|
452
|
+
score.decision = 'block';
|
|
453
|
+
score.reasons.push(`composite ${score.composite.toFixed(1)} below block threshold 4.0`);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (score.publisher.trust === 'verified') {
|
|
457
|
+
score.reasons.push(`publisher verified (${score.publisher.matchedRegistry})`);
|
|
458
|
+
}
|
|
459
|
+
if (score.publisher.typosquat) {
|
|
460
|
+
score.reasons.push(`typosquat risk: ${score.publisher.reason}`);
|
|
461
|
+
}
|
|
462
|
+
if (score.toolPoisoning.detected) {
|
|
463
|
+
score.reasons.push(`tool poisoning: ${score.toolPoisoning.reason}`);
|
|
464
|
+
}
|
|
465
|
+
if (score.transport.exposureRisk !== 'low') {
|
|
466
|
+
score.reasons.push(`transport: ${score.transport.reason}`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
score.durationMs = Date.now() - start;
|
|
470
|
+
return score;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function round1(v) { return Math.round(v * 10) / 10; }
|
|
474
|
+
|
|
475
|
+
module.exports = { evaluate, scanTools, scanSkillManifest, verifyPublisher, validateTransport, levenshtein };
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mcp-proof",
|
|
3
|
+
"version": "0.3.2",
|
|
4
|
+
"description": "Unbranded, free MCP (Model Context Protocol) server verifier — detects tool poisoning, typosquats, hidden unicode, base64 payloads, exposed transports, and skill-manifest attacks. Same engine as the VyriAI control plane, no account required.",
|
|
5
|
+
"main": "engine.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mcp-proof": "bin/mcp-proof.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"engine.js",
|
|
11
|
+
"bin/",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node test.js"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"mcp",
|
|
26
|
+
"model-context-protocol",
|
|
27
|
+
"security",
|
|
28
|
+
"ai-security",
|
|
29
|
+
"tool-poisoning",
|
|
30
|
+
"typosquat",
|
|
31
|
+
"scanner",
|
|
32
|
+
"vyriai",
|
|
33
|
+
"llm-security"
|
|
34
|
+
],
|
|
35
|
+
"homepage": "https://github.com/rudranpatra/AI_MEMORY_CONTROL_PLANE/tree/main/tools/mcp-proof",
|
|
36
|
+
"author": "Vyriai <vyriaipro@gmail.com>",
|
|
37
|
+
"license": "MIT"
|
|
38
|
+
}
|