mcptrustchecker 1.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/LICENSE +21 -0
- package/README.md +379 -0
- package/dist/acquire/clientConfig.d.ts +47 -0
- package/dist/acquire/clientConfig.js +124 -0
- package/dist/acquire/discover.d.ts +12 -0
- package/dist/acquire/discover.js +53 -0
- package/dist/acquire/index.d.ts +24 -0
- package/dist/acquire/index.js +175 -0
- package/dist/acquire/live.d.ts +47 -0
- package/dist/acquire/live.js +357 -0
- package/dist/acquire/manifest.d.ts +10 -0
- package/dist/acquire/manifest.js +94 -0
- package/dist/acquire/npm.d.ts +10 -0
- package/dist/acquire/npm.js +55 -0
- package/dist/acquire/source.d.ts +16 -0
- package/dist/acquire/source.js +109 -0
- package/dist/cli/index.d.ts +15 -0
- package/dist/cli/index.js +396 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.js +81 -0
- package/dist/data/capabilityLexicon.d.ts +51 -0
- package/dist/data/capabilityLexicon.js +204 -0
- package/dist/data/confusables.d.ts +17 -0
- package/dist/data/confusables.js +65 -0
- package/dist/data/injectionPatterns.d.ts +55 -0
- package/dist/data/injectionPatterns.js +216 -0
- package/dist/data/knownCves.d.ts +24 -0
- package/dist/data/knownCves.js +49 -0
- package/dist/data/protectedPackages.d.ts +30 -0
- package/dist/data/protectedPackages.js +89 -0
- package/dist/data/ruleCatalog.d.ts +16 -0
- package/dist/data/ruleCatalog.js +94 -0
- package/dist/data/sourcePatterns.d.ts +25 -0
- package/dist/data/sourcePatterns.js +92 -0
- package/dist/data/unicode.d.ts +34 -0
- package/dist/data/unicode.js +99 -0
- package/dist/detectors/capability.d.ts +14 -0
- package/dist/detectors/capability.js +281 -0
- package/dist/detectors/collision.d.ts +11 -0
- package/dist/detectors/collision.js +63 -0
- package/dist/detectors/flowGraph.d.ts +43 -0
- package/dist/detectors/flowGraph.js +100 -0
- package/dist/detectors/index.d.ts +18 -0
- package/dist/detectors/index.js +26 -0
- package/dist/detectors/injection.d.ts +9 -0
- package/dist/detectors/injection.js +178 -0
- package/dist/detectors/meta.d.ts +8 -0
- package/dist/detectors/meta.js +33 -0
- package/dist/detectors/posture.d.ts +9 -0
- package/dist/detectors/posture.js +128 -0
- package/dist/detectors/source.d.ts +12 -0
- package/dist/detectors/source.js +87 -0
- package/dist/detectors/supplyChain.d.ts +17 -0
- package/dist/detectors/supplyChain.js +243 -0
- package/dist/detectors/toxicFlow.d.ts +22 -0
- package/dist/detectors/toxicFlow.js +201 -0
- package/dist/detectors/unicode.d.ts +21 -0
- package/dist/detectors/unicode.js +244 -0
- package/dist/engine.d.ts +27 -0
- package/dist/engine.js +135 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +46 -0
- package/dist/lockfile.d.ts +30 -0
- package/dist/lockfile.js +87 -0
- package/dist/policy.d.ts +13 -0
- package/dist/policy.js +48 -0
- package/dist/report/badge.d.ts +14 -0
- package/dist/report/badge.js +18 -0
- package/dist/report/json.d.ts +4 -0
- package/dist/report/json.js +4 -0
- package/dist/report/markdown.d.ts +6 -0
- package/dist/report/markdown.js +110 -0
- package/dist/report/sarif.d.ts +7 -0
- package/dist/report/sarif.js +105 -0
- package/dist/report/terminal.d.ts +15 -0
- package/dist/report/terminal.js +252 -0
- package/dist/scoring/capability.d.ts +10 -0
- package/dist/scoring/capability.js +48 -0
- package/dist/scoring/index.d.ts +10 -0
- package/dist/scoring/index.js +96 -0
- package/dist/scoring/model.d.ts +50 -0
- package/dist/scoring/model.js +101 -0
- package/dist/types.d.ts +361 -0
- package/dist/types.js +12 -0
- package/dist/util/ansi.d.ts +26 -0
- package/dist/util/ansi.js +42 -0
- package/dist/util/capabilities.d.ts +17 -0
- package/dist/util/capabilities.js +68 -0
- package/dist/util/distance.d.ts +23 -0
- package/dist/util/distance.js +119 -0
- package/dist/util/hash.d.ts +20 -0
- package/dist/util/hash.js +80 -0
- package/dist/util/text.d.ts +45 -0
- package/dist/util/text.js +170 -0
- package/dist/version.d.ts +13 -0
- package/dist/version.js +13 -0
- package/package.json +72 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
|
|
2
|
+
/**
|
|
3
|
+
* Stage 3 — Capability findings. The capability *tags* are computed by the
|
|
4
|
+
* engine and shared via context; here we turn them into findings:
|
|
5
|
+
* - dangerous capabilities (command/code execution, filesystem mutation);
|
|
6
|
+
* - annotation-vs-behavior mismatch, open-world + sensitive read, and missing
|
|
7
|
+
* destructiveHint (the annotation-truthfulness story);
|
|
8
|
+
* - schema-level injection preconditions (unconstrained command / URL / path
|
|
9
|
+
* parameters — the advertised half of command-injection / SSRF / traversal);
|
|
10
|
+
* - declared server capabilities that are high-signal on their own (sampling,
|
|
11
|
+
* elicitation) — static proxies for classes rivals only catch at runtime.
|
|
12
|
+
*/
|
|
13
|
+
import { CAPABILITY_SIGNALS, COMMAND_PARAM_NAMES, PARAM_NAME_SIGNALS, PATH_PARAM_NAMES, SECRET_FIELD_NAMES, SSRF_PARAM_NAMES, } from '../data/capabilityLexicon.js';
|
|
14
|
+
import { normalizeForMatch } from '../util/text.js';
|
|
15
|
+
function capFor(caps, tool) {
|
|
16
|
+
return caps.find((c) => c.tool === tool);
|
|
17
|
+
}
|
|
18
|
+
const MUTATION_TAGS = new Set(['code-exec', 'file-write']);
|
|
19
|
+
/**
|
|
20
|
+
* Is a tool's state-mutating capability (code-exec / file-write) evidenced
|
|
21
|
+
* *operatively* — by its NAME or a PARAMETER — rather than only by a keyword
|
|
22
|
+
* that happens to appear in its prose description? A read-only getter like
|
|
23
|
+
* `get_config` whose description merely lists "defaultShell" / "blockedCommands"
|
|
24
|
+
* picks up a `code-exec` tag from the word "shell", but it does not execute
|
|
25
|
+
* anything. Only name/param evidence is strong enough to call an annotation a
|
|
26
|
+
* lie (MTC-CAP-003), so a description-only mention must not trip that rule.
|
|
27
|
+
*/
|
|
28
|
+
function mutationOperativelyEvidenced(tool) {
|
|
29
|
+
const nameNorm = normalizeForMatch(typeof tool.name === 'string' ? tool.name : '');
|
|
30
|
+
const nameTokens = new Set(nameNorm.split(' ').filter(Boolean));
|
|
31
|
+
for (const sig of CAPABILITY_SIGNALS) {
|
|
32
|
+
if (!MUTATION_TAGS.has(sig.tag))
|
|
33
|
+
continue;
|
|
34
|
+
for (const kw of sig.keywords) {
|
|
35
|
+
const k = normalizeForMatch(kw);
|
|
36
|
+
if (k.includes(' ') ? nameNorm.includes(k) : nameTokens.has(k))
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const pTokens = new Set(Object.keys(tool.inputSchema?.properties ?? {}).flatMap((n) => n.toLowerCase().split(/[^a-z0-9]+/i).filter(Boolean)));
|
|
41
|
+
for (const sig of PARAM_NAME_SIGNALS) {
|
|
42
|
+
if (!MUTATION_TAGS.has(sig.tag))
|
|
43
|
+
continue;
|
|
44
|
+
for (const pn of sig.names)
|
|
45
|
+
if (pTokens.has(pn))
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
/** True if a string-typed property has no enum/pattern constraint. */
|
|
51
|
+
function isUnconstrainedString(prop) {
|
|
52
|
+
if (!prop || typeof prop !== 'object')
|
|
53
|
+
return true;
|
|
54
|
+
const t = prop.type;
|
|
55
|
+
const stringy = t === undefined || t === 'string' || (Array.isArray(t) && t.includes('string'));
|
|
56
|
+
if (!stringy)
|
|
57
|
+
return false;
|
|
58
|
+
return !prop.enum && !prop.pattern;
|
|
59
|
+
}
|
|
60
|
+
function nameMatches(paramName, list) {
|
|
61
|
+
const tokens = paramName.toLowerCase().split(/[^a-z0-9]+/i).filter(Boolean);
|
|
62
|
+
return list.some((n) => tokens.includes(n));
|
|
63
|
+
}
|
|
64
|
+
export const capabilityDetector = {
|
|
65
|
+
id: 'capability',
|
|
66
|
+
stage: 3,
|
|
67
|
+
title: 'Capability & annotation analysis',
|
|
68
|
+
run(ctx) {
|
|
69
|
+
const findings = [];
|
|
70
|
+
for (const tool of ctx.surface.tools) {
|
|
71
|
+
if (!tool || typeof tool !== 'object' || typeof tool.name !== 'string')
|
|
72
|
+
continue;
|
|
73
|
+
const cap = capFor(ctx.capabilities, tool.name);
|
|
74
|
+
if (!cap)
|
|
75
|
+
continue;
|
|
76
|
+
const tags = cap.tags;
|
|
77
|
+
const reasons = cap.reasons;
|
|
78
|
+
const has = (t) => tags.includes(t);
|
|
79
|
+
if (has('code-exec')) {
|
|
80
|
+
findings.push({
|
|
81
|
+
ruleId: 'MTC-CAP-001',
|
|
82
|
+
title: `Tool "${tool.name}" exposes command/code execution`,
|
|
83
|
+
category: 'permissions',
|
|
84
|
+
severity: 'high',
|
|
85
|
+
confidence: 'strong',
|
|
86
|
+
description: `Tool "${tool.name}" appears to run shell commands or evaluate code ` +
|
|
87
|
+
`(${(reasons['code-exec'] ?? []).join(', ')}). Arbitrary execution driven by model input is one of ` +
|
|
88
|
+
`the most dangerous MCP capabilities; combined with any untrusted input it becomes RCE.`,
|
|
89
|
+
remediation: 'Sandbox execution, allowlist commands/arguments, and never pass model output to a shell unescaped.',
|
|
90
|
+
location: { kind: 'tool', name: tool.name },
|
|
91
|
+
owasp: 'LLM05:2025 Improper Output Handling',
|
|
92
|
+
data: { tags },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (has('file-write')) {
|
|
96
|
+
findings.push({
|
|
97
|
+
ruleId: 'MTC-CAP-002',
|
|
98
|
+
title: `Tool "${tool.name}" can modify the filesystem`,
|
|
99
|
+
category: 'permissions',
|
|
100
|
+
severity: 'medium',
|
|
101
|
+
confidence: 'strong',
|
|
102
|
+
description: `Tool "${tool.name}" can write, overwrite or delete files ` +
|
|
103
|
+
`(${(reasons['file-write'] ?? []).join(', ')}). Verify it is scoped to a safe directory.`,
|
|
104
|
+
remediation: 'Constrain file operations to an explicit, non-sensitive root; reject path traversal.',
|
|
105
|
+
location: { kind: 'tool', name: tool.name },
|
|
106
|
+
data: { tags },
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
// Annotation-vs-behavior mismatch. Only a tool that genuinely MODIFIES
|
|
110
|
+
// state (file-write / code-exec) contradicts readOnly/non-destructive — a
|
|
111
|
+
// tool that merely makes an outbound request (e.g. fetch/scrape) can be
|
|
112
|
+
// legitimately read-only, so external-sink alone is NOT a contradiction.
|
|
113
|
+
const claimsReadOnly = tool.annotations?.readOnlyHint === true;
|
|
114
|
+
const claimsNonDestructive = tool.annotations?.destructiveHint === false;
|
|
115
|
+
// Require OPERATIVE evidence (name/param), not a description-only keyword,
|
|
116
|
+
// before calling the annotation a lie — else a read-only getter that merely
|
|
117
|
+
// describes shell/exec config (get_config, get_prompts) is a false positive.
|
|
118
|
+
const actuallyMutates = (has('file-write') || has('code-exec')) && mutationOperativelyEvidenced(tool);
|
|
119
|
+
if ((claimsReadOnly || claimsNonDestructive) && actuallyMutates) {
|
|
120
|
+
findings.push({
|
|
121
|
+
ruleId: 'MTC-CAP-003',
|
|
122
|
+
title: `Annotation contradicts behavior on "${tool.name}"`,
|
|
123
|
+
category: 'permissions',
|
|
124
|
+
severity: 'medium',
|
|
125
|
+
confidence: 'strong',
|
|
126
|
+
description: `Tool "${tool.name}" advertises ${claimsReadOnly ? 'readOnlyHint=true' : 'destructiveHint=false'} but its ` +
|
|
127
|
+
`derived capabilities include state-modifying actions (${tags.join(', ')}). Tool annotations are ` +
|
|
128
|
+
`attacker-controllable and must never be trusted; a mismatch like this is exactly how a hostile server ` +
|
|
129
|
+
`hides a destructive tool.`,
|
|
130
|
+
remediation: 'Ignore server-provided annotations for security decisions; gate destructive tools on explicit user consent.',
|
|
131
|
+
location: { kind: 'tool', name: tool.name },
|
|
132
|
+
owasp: 'LLM06:2025 Excessive Agency',
|
|
133
|
+
data: { tags, annotations: tool.annotations },
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
// open-world hint + sensitive read = a lethal-trifecta signal from annotations.
|
|
137
|
+
if (tool.annotations?.openWorldHint === true && has('sensitive-source')) {
|
|
138
|
+
findings.push({
|
|
139
|
+
ruleId: 'MTC-CAP-004',
|
|
140
|
+
title: `Open-world tool "${tool.name}" also reads sensitive data`,
|
|
141
|
+
category: 'exfiltration',
|
|
142
|
+
severity: 'medium',
|
|
143
|
+
confidence: 'heuristic',
|
|
144
|
+
description: `Tool "${tool.name}" declares openWorldHint=true — it interacts with entities outside the trust boundary — ` +
|
|
145
|
+
`while also reading sensitive data. That combination is a canonical lethal-trifecta indicator.`,
|
|
146
|
+
remediation: 'Separate outward-facing tools from sensitive-data reads; require confirmation between them.',
|
|
147
|
+
location: { kind: 'tool', name: tool.name },
|
|
148
|
+
owasp: 'LLM02:2025 Sensitive Information Disclosure',
|
|
149
|
+
data: { tags },
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
// Missing destructiveHint on a genuinely destructive tool (write/exec only,
|
|
153
|
+
// to avoid flagging the very common annotation-less "send" tool).
|
|
154
|
+
const noDestructiveHint = !tool.annotations || tool.annotations.destructiveHint === undefined;
|
|
155
|
+
const genuinelyDestructive = has('file-write') || has('code-exec');
|
|
156
|
+
if (genuinelyDestructive && noDestructiveHint) {
|
|
157
|
+
findings.push({
|
|
158
|
+
ruleId: 'MTC-CAP-005',
|
|
159
|
+
title: `Mutating tool "${tool.name}" declares no destructiveHint`,
|
|
160
|
+
category: 'hygiene',
|
|
161
|
+
severity: 'low',
|
|
162
|
+
confidence: 'heuristic',
|
|
163
|
+
description: `Tool "${tool.name}" can mutate/egress but declares no destructiveHint. Clients that don't default to ` +
|
|
164
|
+
`spec-safe behavior may not prompt before running it.`,
|
|
165
|
+
remediation: 'Declare accurate annotations, and gate destructive tools on user confirmation regardless.',
|
|
166
|
+
location: { kind: 'tool', name: tool.name },
|
|
167
|
+
data: { tags },
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
// Schema-level injection preconditions (only on the relevant capability).
|
|
171
|
+
const props = tool.inputSchema?.properties ?? {};
|
|
172
|
+
for (const [pname, pschema] of Object.entries(props)) {
|
|
173
|
+
if (!isUnconstrainedString(pschema))
|
|
174
|
+
continue;
|
|
175
|
+
const field = `inputSchema.properties.${pname}`;
|
|
176
|
+
if (has('code-exec') && nameMatches(pname, COMMAND_PARAM_NAMES)) {
|
|
177
|
+
findings.push({
|
|
178
|
+
ruleId: 'MTC-CAP-006',
|
|
179
|
+
title: `Unconstrained command parameter "${pname}" on "${tool.name}"`,
|
|
180
|
+
category: 'permissions',
|
|
181
|
+
severity: 'medium',
|
|
182
|
+
confidence: 'heuristic',
|
|
183
|
+
description: `Tool "${tool.name}" takes a command-shaped parameter "${pname}" with no enum/pattern constraint. ` +
|
|
184
|
+
`Free-form, model- or attacker-controlled arguments reaching a shell is the command-injection precondition.`,
|
|
185
|
+
remediation: 'Constrain the parameter (enum/pattern), or build the command from a fixed template with escaped args.',
|
|
186
|
+
location: { kind: 'tool', name: tool.name, field },
|
|
187
|
+
owasp: 'LLM05:2025 Improper Output Handling',
|
|
188
|
+
data: { param: pname },
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
else if ((has('external-sink') || has('untrusted-input')) && nameMatches(pname, SSRF_PARAM_NAMES)) {
|
|
192
|
+
findings.push({
|
|
193
|
+
ruleId: 'MTC-CAP-007',
|
|
194
|
+
title: `Unconstrained URL/host parameter "${pname}" on "${tool.name}"`,
|
|
195
|
+
category: 'network',
|
|
196
|
+
severity: 'medium',
|
|
197
|
+
confidence: 'heuristic',
|
|
198
|
+
description: `Tool "${tool.name}" takes a URL/host parameter "${pname}" with no allowlist/pattern. An outbound-request ` +
|
|
199
|
+
`tool with an unbounded destination enables SSRF and cloud-metadata access (e.g. 169.254.169.254).`,
|
|
200
|
+
remediation: 'Allowlist destinations or constrain the parameter; block private/link-local addresses server-side.',
|
|
201
|
+
location: { kind: 'tool', name: tool.name, field },
|
|
202
|
+
owasp: 'LLM06:2025 Excessive Agency',
|
|
203
|
+
data: { param: pname },
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
else if ((has('file-write') || has('sensitive-source')) && nameMatches(pname, PATH_PARAM_NAMES)) {
|
|
207
|
+
findings.push({
|
|
208
|
+
ruleId: 'MTC-CAP-008',
|
|
209
|
+
title: `Unconstrained path parameter "${pname}" on "${tool.name}"`,
|
|
210
|
+
category: 'permissions',
|
|
211
|
+
severity: 'low',
|
|
212
|
+
confidence: 'heuristic',
|
|
213
|
+
description: `Tool "${tool.name}" takes a path parameter "${pname}" with no constraint. Without a canonicalize-and-` +
|
|
214
|
+
`contain check (not visible statically), this permits ../ traversal outside the intended root.`,
|
|
215
|
+
remediation: 'Resolve and verify the path stays within an allowed root; reject traversal sequences.',
|
|
216
|
+
location: { kind: 'tool', name: tool.name, field },
|
|
217
|
+
data: { param: pname },
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// --- Server-capability pass (declared capabilities are facts, not guesses) ---
|
|
223
|
+
const rawCaps = ctx.surface.server.capabilities;
|
|
224
|
+
const caps = rawCaps && typeof rawCaps === 'object' ? rawCaps : {};
|
|
225
|
+
if ('sampling' in caps) {
|
|
226
|
+
findings.push({
|
|
227
|
+
ruleId: 'MTC-CAP-009',
|
|
228
|
+
title: 'Server declares the sampling capability',
|
|
229
|
+
category: 'permissions',
|
|
230
|
+
severity: 'medium',
|
|
231
|
+
confidence: 'strong',
|
|
232
|
+
description: 'This server declares the MCP `sampling` capability, letting it drive the client\'s own LLM with ' +
|
|
233
|
+
'server-authored prompts. That reverse-trust channel enables token/resource drain, conversation hijack, ' +
|
|
234
|
+
'and covert tool invocation. Most benign servers do not need sampling.',
|
|
235
|
+
remediation: 'Only grant sampling to servers you fully trust; review what the server does with completions.',
|
|
236
|
+
location: { kind: 'server', field: 'capabilities.sampling' },
|
|
237
|
+
owasp: 'LLM06:2025 Excessive Agency',
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
if ('elicitation' in caps) {
|
|
241
|
+
// Plain declared elicitation is a CAPABILITY (MTC-CAP-010, not scored). Only
|
|
242
|
+
// when it also solicits a secret is it a trust THREAT (MTC-CAP-011, scored).
|
|
243
|
+
const secretField = findSecretSeekingField(ctx);
|
|
244
|
+
findings.push({
|
|
245
|
+
ruleId: secretField ? 'MTC-CAP-011' : 'MTC-CAP-010',
|
|
246
|
+
title: 'Server declares the elicitation capability' + (secretField ? ' and solicits secrets' : ''),
|
|
247
|
+
category: secretField ? 'exfiltration' : 'permissions',
|
|
248
|
+
severity: secretField ? 'high' : 'medium',
|
|
249
|
+
confidence: 'strong',
|
|
250
|
+
description: 'This server declares the MCP `elicitation` capability (it can pop mid-session input requests). This ' +
|
|
251
|
+
'enables consent-fatigue conditioning and phishing.' +
|
|
252
|
+
(secretField
|
|
253
|
+
? ` It also exposes a secret-seeking field ("${secretField}") — the spec forbids collecting secrets via elicitation.`
|
|
254
|
+
: ''),
|
|
255
|
+
remediation: 'Treat elicitation prompts as untrusted UI; never enter credentials into an elicitation request.',
|
|
256
|
+
location: { kind: 'server', field: 'capabilities.elicitation' },
|
|
257
|
+
owasp: 'LLM02:2025 Sensitive Information Disclosure',
|
|
258
|
+
...(secretField ? { evidence: secretField } : {}),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
return findings;
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
/** Find the first param/prompt-arg name that solicits a secret, if any. */
|
|
265
|
+
function findSecretSeekingField(ctx) {
|
|
266
|
+
const check = (name) => name.toLowerCase().split(/[^a-z0-9]+/i).some((tok) => SECRET_FIELD_NAMES.includes(tok));
|
|
267
|
+
for (const tool of ctx.surface.tools) {
|
|
268
|
+
if (!tool || typeof tool !== 'object')
|
|
269
|
+
continue;
|
|
270
|
+
for (const p of Object.keys(tool.inputSchema?.properties ?? {}))
|
|
271
|
+
if (check(p))
|
|
272
|
+
return p;
|
|
273
|
+
}
|
|
274
|
+
for (const prompt of ctx.surface.prompts) {
|
|
275
|
+
const args = Array.isArray(prompt?.arguments) ? prompt.arguments : [];
|
|
276
|
+
for (const a of args)
|
|
277
|
+
if (a && typeof a === 'object' && typeof a.name === 'string' && check(a.name))
|
|
278
|
+
return a.name;
|
|
279
|
+
}
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
|
|
2
|
+
/**
|
|
3
|
+
* Cross-server tool-name collision / shadowing. When a scan covers several
|
|
4
|
+
* servers (e.g. a whole client config), a malicious server can register a tool
|
|
5
|
+
* whose name collides with — or is a homoglyph/near-miss of — a trusted
|
|
6
|
+
* server's tool, hijacking selection by connection order or embedding rank
|
|
7
|
+
* (SAFE-T1004 / T1008). This complements description-level shadowing
|
|
8
|
+
* (MTC-INJ-SHADOW-1) with name-level collision across the server set.
|
|
9
|
+
*/
|
|
10
|
+
import type { Detector } from '../types.js';
|
|
11
|
+
export declare const collisionDetector: Detector;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
|
|
2
|
+
/**
|
|
3
|
+
* Cross-server tool-name collision / shadowing. When a scan covers several
|
|
4
|
+
* servers (e.g. a whole client config), a malicious server can register a tool
|
|
5
|
+
* whose name collides with — or is a homoglyph/near-miss of — a trusted
|
|
6
|
+
* server's tool, hijacking selection by connection order or embedding rank
|
|
7
|
+
* (SAFE-T1004 / T1008). This complements description-level shadowing
|
|
8
|
+
* (MTC-INJ-SHADOW-1) with name-level collision across the server set.
|
|
9
|
+
*/
|
|
10
|
+
import { skeleton } from '../data/confusables.js';
|
|
11
|
+
import { damerauLevenshtein } from '../util/distance.js';
|
|
12
|
+
export const collisionDetector = {
|
|
13
|
+
id: 'collision',
|
|
14
|
+
stage: 4,
|
|
15
|
+
title: 'Cross-server tool-name collision',
|
|
16
|
+
run(ctx) {
|
|
17
|
+
const siblings = ctx.siblingTools ?? [];
|
|
18
|
+
if (siblings.length === 0)
|
|
19
|
+
return [];
|
|
20
|
+
const findings = [];
|
|
21
|
+
const seen = new Set();
|
|
22
|
+
for (const tool of ctx.surface.tools) {
|
|
23
|
+
if (!tool || typeof tool !== 'object' || typeof tool.name !== 'string')
|
|
24
|
+
continue;
|
|
25
|
+
const nameLower = tool.name.toLowerCase();
|
|
26
|
+
const skel = skeleton(tool.name);
|
|
27
|
+
for (const sib of siblings) {
|
|
28
|
+
const sibLower = sib.name.toLowerCase();
|
|
29
|
+
const key = [tool.name, sib.server, sib.name].join('|');
|
|
30
|
+
if (seen.has(key))
|
|
31
|
+
continue;
|
|
32
|
+
let hit = null;
|
|
33
|
+
if (nameLower === sibLower) {
|
|
34
|
+
hit = { severity: 'high', confidence: 'strong', why: 'an identical name' };
|
|
35
|
+
}
|
|
36
|
+
else if (skel === skeleton(sib.name)) {
|
|
37
|
+
hit = { severity: 'high', confidence: 'strong', why: 'a homoglyph/confusable name' };
|
|
38
|
+
}
|
|
39
|
+
else if (tool.name.length >= 4 && damerauLevenshtein(nameLower, sibLower) <= 1) {
|
|
40
|
+
hit = { severity: 'medium', confidence: 'heuristic', why: 'a near-identical name (edit distance 1)' };
|
|
41
|
+
}
|
|
42
|
+
if (!hit)
|
|
43
|
+
continue;
|
|
44
|
+
seen.add(key);
|
|
45
|
+
findings.push({
|
|
46
|
+
ruleId: 'MTC-INJ-SHADOW-2',
|
|
47
|
+
title: `Tool "${tool.name}" collides with a tool on server "${sib.server}"`,
|
|
48
|
+
category: 'injection',
|
|
49
|
+
severity: hit.severity,
|
|
50
|
+
confidence: hit.confidence,
|
|
51
|
+
description: `This server's tool "${tool.name}" shares ${hit.why} with "${sib.name}" on server "${sib.server}". ` +
|
|
52
|
+
`Colliding tool names let a server hijack tool selection (by connection order or embedding rank) and ` +
|
|
53
|
+
`shadow a trusted tool.`,
|
|
54
|
+
remediation: 'Namespace tools per server and require the user to confirm which server a tool belongs to; distrust duplicate names.',
|
|
55
|
+
location: { kind: 'tool', name: tool.name },
|
|
56
|
+
owasp: 'LLM01:2025 Prompt Injection',
|
|
57
|
+
data: { collidesWith: sib },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return findings;
|
|
62
|
+
},
|
|
63
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
|
|
2
|
+
/**
|
|
3
|
+
* Dataflow graph for the toxic-flow analysis.
|
|
4
|
+
*
|
|
5
|
+
* MCP tools are not statically wired to each other — the *agent* orchestrates
|
|
6
|
+
* them. So the conservative model is: any tool's output can reach any other
|
|
7
|
+
* tool's input via the model ("agent-mediated" edge). On top of that we detect
|
|
8
|
+
* the higher-plausibility case where a consumer exposes an unconstrained
|
|
9
|
+
* free-text / URL parameter that a producer's output can be dropped straight
|
|
10
|
+
* into ("schema-wired" edge).
|
|
11
|
+
*
|
|
12
|
+
* From that graph we recover a concrete UNTRUSTED → SENSITIVE → SINK **path**
|
|
13
|
+
* (the actual attack chain, e.g. `fetch_url → read_file → http_request`) and
|
|
14
|
+
* grade confidence by how directly the legs are wired — turning a co-presence
|
|
15
|
+
* heuristic into an explainable, path-level result.
|
|
16
|
+
*/
|
|
17
|
+
import type { ToolCapability, ToolDef } from '../types.js';
|
|
18
|
+
export type FlowEdgeKind = 'schema-wired' | 'agent-mediated';
|
|
19
|
+
export interface FlowEdge {
|
|
20
|
+
from: string;
|
|
21
|
+
to: string;
|
|
22
|
+
kind: FlowEdgeKind;
|
|
23
|
+
}
|
|
24
|
+
export interface FlowPath {
|
|
25
|
+
/** Ordered tools forming the attack chain (2 or 3 hops). */
|
|
26
|
+
path: string[];
|
|
27
|
+
edges: FlowEdge[];
|
|
28
|
+
/** True when at least one leg is a direct schema wire (higher plausibility). */
|
|
29
|
+
wired: boolean;
|
|
30
|
+
}
|
|
31
|
+
/** Does this tool expose an unconstrained string param a producer can feed? */
|
|
32
|
+
export declare function consumesFreeText(tool: ToolDef | undefined): boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Recover a representative untrusted → sensitive → sink attack path. Legs are
|
|
35
|
+
* chosen deterministically (lexicographic) and preferentially across *distinct*
|
|
36
|
+
* tools so the chain reads as a real composition. Returns null if the trifecta
|
|
37
|
+
* is not completable.
|
|
38
|
+
*/
|
|
39
|
+
export declare function representativeTrifectaPath(caps: ToolCapability[], tools: ToolDef[]): FlowPath | null;
|
|
40
|
+
/** A representative sensitive-source → external-sink path (no untrusted leg). */
|
|
41
|
+
export declare function representativeExfilPath(caps: ToolCapability[], tools: ToolDef[]): FlowPath | null;
|
|
42
|
+
/** Render a path as an arrow chain for a human-readable finding. */
|
|
43
|
+
export declare function renderPath(p: FlowPath): string;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
|
|
2
|
+
/**
|
|
3
|
+
* Dataflow graph for the toxic-flow analysis.
|
|
4
|
+
*
|
|
5
|
+
* MCP tools are not statically wired to each other — the *agent* orchestrates
|
|
6
|
+
* them. So the conservative model is: any tool's output can reach any other
|
|
7
|
+
* tool's input via the model ("agent-mediated" edge). On top of that we detect
|
|
8
|
+
* the higher-plausibility case where a consumer exposes an unconstrained
|
|
9
|
+
* free-text / URL parameter that a producer's output can be dropped straight
|
|
10
|
+
* into ("schema-wired" edge).
|
|
11
|
+
*
|
|
12
|
+
* From that graph we recover a concrete UNTRUSTED → SENSITIVE → SINK **path**
|
|
13
|
+
* (the actual attack chain, e.g. `fetch_url → read_file → http_request`) and
|
|
14
|
+
* grade confidence by how directly the legs are wired — turning a co-presence
|
|
15
|
+
* heuristic into an explainable, path-level result.
|
|
16
|
+
*/
|
|
17
|
+
/** Parameter names that accept free-form content a producer's output slots into. */
|
|
18
|
+
const FREE_TEXT_PARAMS = new Set([
|
|
19
|
+
'content', 'body', 'text', 'data', 'input', 'message', 'prompt', 'query', 'q',
|
|
20
|
+
'url', 'uri', 'html', 'markdown', 'payload', 'value', 'string', 'document',
|
|
21
|
+
]);
|
|
22
|
+
/** Does this tool expose an unconstrained string param a producer can feed? */
|
|
23
|
+
export function consumesFreeText(tool) {
|
|
24
|
+
const props = tool?.inputSchema?.properties;
|
|
25
|
+
if (!props || typeof props !== 'object')
|
|
26
|
+
return false;
|
|
27
|
+
for (const [name, schema] of Object.entries(props)) {
|
|
28
|
+
if (!FREE_TEXT_PARAMS.has(name.toLowerCase()))
|
|
29
|
+
continue;
|
|
30
|
+
const t = schema?.type;
|
|
31
|
+
const stringy = t === undefined || t === 'string' || (Array.isArray(t) && t.includes('string'));
|
|
32
|
+
const constrained = Boolean(schema?.enum) ||
|
|
33
|
+
Boolean(schema?.pattern);
|
|
34
|
+
if (stringy && !constrained)
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
function edge(from, to, toolByName) {
|
|
40
|
+
// A leg is a "schema wire" when the consumer takes free text (the producer's
|
|
41
|
+
// output can be dropped straight in); otherwise the agent has to mediate it.
|
|
42
|
+
const kind = consumesFreeText(toolByName.get(to)) ? 'schema-wired' : 'agent-mediated';
|
|
43
|
+
return { from, to, kind };
|
|
44
|
+
}
|
|
45
|
+
const has = (c, tag) => c.tags.includes(tag);
|
|
46
|
+
const isSink = (c) => has(c, 'external-sink') || has(c, 'code-exec');
|
|
47
|
+
/**
|
|
48
|
+
* Recover a representative untrusted → sensitive → sink attack path. Legs are
|
|
49
|
+
* chosen deterministically (lexicographic) and preferentially across *distinct*
|
|
50
|
+
* tools so the chain reads as a real composition. Returns null if the trifecta
|
|
51
|
+
* is not completable.
|
|
52
|
+
*/
|
|
53
|
+
export function representativeTrifectaPath(caps, tools) {
|
|
54
|
+
const toolByName = new Map(tools.filter((t) => t && typeof t.name === 'string').map((t) => [t.name, t]));
|
|
55
|
+
const sorted = [...caps].sort((a, b) => (a.tool < b.tool ? -1 : a.tool > b.tool ? 1 : 0));
|
|
56
|
+
const untrusted = sorted.filter((c) => has(c, 'untrusted-input')).map((c) => c.tool);
|
|
57
|
+
const sources = sorted.filter((c) => has(c, 'sensitive-source')).map((c) => c.tool);
|
|
58
|
+
const sinks = sorted.filter(isSink).map((c) => c.tool);
|
|
59
|
+
if (!untrusted.length || !sources.length || !sinks.length)
|
|
60
|
+
return null;
|
|
61
|
+
// Prefer legs on distinct tools; fall back to reuse when the surface is small.
|
|
62
|
+
const pick = (pool, avoid) => pool.find((t) => !avoid.has(t)) ?? pool[0];
|
|
63
|
+
const used = new Set();
|
|
64
|
+
const u = pick(untrusted, used);
|
|
65
|
+
used.add(u);
|
|
66
|
+
const s = pick(sources, used);
|
|
67
|
+
used.add(s);
|
|
68
|
+
const k = pick(sinks, used);
|
|
69
|
+
// Collapse consecutive duplicates (a tool holding two roles) into one node.
|
|
70
|
+
const nodes = [];
|
|
71
|
+
for (const n of [u, s, k])
|
|
72
|
+
if (nodes[nodes.length - 1] !== n)
|
|
73
|
+
nodes.push(n);
|
|
74
|
+
const edges = [];
|
|
75
|
+
for (let i = 0; i < nodes.length - 1; i++)
|
|
76
|
+
edges.push(edge(nodes[i], nodes[i + 1], toolByName));
|
|
77
|
+
return { path: nodes, edges, wired: edges.some((e) => e.kind === 'schema-wired') };
|
|
78
|
+
}
|
|
79
|
+
/** A representative sensitive-source → external-sink path (no untrusted leg). */
|
|
80
|
+
export function representativeExfilPath(caps, tools) {
|
|
81
|
+
const toolByName = new Map(tools.filter((t) => t && typeof t.name === 'string').map((t) => [t.name, t]));
|
|
82
|
+
const sorted = [...caps].sort((a, b) => (a.tool < b.tool ? -1 : a.tool > b.tool ? 1 : 0));
|
|
83
|
+
const sources = sorted.filter((c) => has(c, 'sensitive-source')).map((c) => c.tool);
|
|
84
|
+
const sinks = sorted.filter(isSink).map((c) => c.tool);
|
|
85
|
+
const s = sources[0];
|
|
86
|
+
const k = sinks.find((x) => x !== s) ?? sinks[0];
|
|
87
|
+
if (!s || !k)
|
|
88
|
+
return null;
|
|
89
|
+
const nodes = s === k ? [s] : [s, k];
|
|
90
|
+
const edges = nodes.length === 2 ? [edge(nodes[0], nodes[1], toolByName)] : [];
|
|
91
|
+
return { path: nodes, edges, wired: edges.some((e) => e.kind === 'schema-wired') };
|
|
92
|
+
}
|
|
93
|
+
/** Render a path as an arrow chain for a human-readable finding. */
|
|
94
|
+
export function renderPath(p) {
|
|
95
|
+
if (p.path.length === 1)
|
|
96
|
+
return p.path[0];
|
|
97
|
+
return p.path
|
|
98
|
+
.map((n, i) => (i === 0 ? n : `${p.edges[i - 1].kind === 'schema-wired' ? '⇒' : '→'} ${n}`))
|
|
99
|
+
.join(' ');
|
|
100
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
|
|
2
|
+
/**
|
|
3
|
+
* Detector registry. The engine runs every registered detector, then runs the
|
|
4
|
+
* toxic-flow analysis and integrity check (which need cross-cutting state and
|
|
5
|
+
* are wired directly in the engine).
|
|
6
|
+
*/
|
|
7
|
+
import type { Detector } from '../types.js';
|
|
8
|
+
import { metaDetector } from './meta.js';
|
|
9
|
+
import { unicodeDetector } from './unicode.js';
|
|
10
|
+
import { injectionDetector } from './injection.js';
|
|
11
|
+
import { capabilityDetector } from './capability.js';
|
|
12
|
+
import { collisionDetector } from './collision.js';
|
|
13
|
+
import { supplyChainDetector } from './supplyChain.js';
|
|
14
|
+
import { postureDetector } from './posture.js';
|
|
15
|
+
import { sourceDetector } from './source.js';
|
|
16
|
+
export declare const DETECTORS: Detector[];
|
|
17
|
+
export { metaDetector, unicodeDetector, injectionDetector, capabilityDetector, collisionDetector, supplyChainDetector, postureDetector, sourceDetector, };
|
|
18
|
+
export { analyzeToxicFlows } from './toxicFlow.js';
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
|
|
2
|
+
/**
|
|
3
|
+
* Detector registry. The engine runs every registered detector, then runs the
|
|
4
|
+
* toxic-flow analysis and integrity check (which need cross-cutting state and
|
|
5
|
+
* are wired directly in the engine).
|
|
6
|
+
*/
|
|
7
|
+
import { metaDetector } from './meta.js';
|
|
8
|
+
import { unicodeDetector } from './unicode.js';
|
|
9
|
+
import { injectionDetector } from './injection.js';
|
|
10
|
+
import { capabilityDetector } from './capability.js';
|
|
11
|
+
import { collisionDetector } from './collision.js';
|
|
12
|
+
import { supplyChainDetector } from './supplyChain.js';
|
|
13
|
+
import { postureDetector } from './posture.js';
|
|
14
|
+
import { sourceDetector } from './source.js';
|
|
15
|
+
export const DETECTORS = [
|
|
16
|
+
metaDetector,
|
|
17
|
+
unicodeDetector,
|
|
18
|
+
injectionDetector,
|
|
19
|
+
capabilityDetector,
|
|
20
|
+
collisionDetector,
|
|
21
|
+
supplyChainDetector,
|
|
22
|
+
postureDetector,
|
|
23
|
+
sourceDetector,
|
|
24
|
+
];
|
|
25
|
+
export { metaDetector, unicodeDetector, injectionDetector, capabilityDetector, collisionDetector, supplyChainDetector, postureDetector, sourceDetector, };
|
|
26
|
+
export { analyzeToxicFlows } from './toxicFlow.js';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
|
|
2
|
+
/**
|
|
3
|
+
* Stage 2 — Content injection heuristics (tool poisoning / line jumping /
|
|
4
|
+
* shadowing). Runs the pattern lexicon across four channels, then escalates
|
|
5
|
+
* when multiple poisoning signals co-occur in one field — the shape of a real
|
|
6
|
+
* tool-poisoning attack rather than an incidental keyword.
|
|
7
|
+
*/
|
|
8
|
+
import type { Detector } from '../types.js';
|
|
9
|
+
export declare const injectionDetector: Detector;
|