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,243 @@
|
|
|
1
|
+
/*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
|
|
2
|
+
/**
|
|
3
|
+
* Stage 5 — Supply-chain & typosquatting.
|
|
4
|
+
*
|
|
5
|
+
* Multi-signal, not a single edit-distance number: known-squat table, fake
|
|
6
|
+
* official scope, unscoped shadowing, confusable skeleton, Damerau-Levenshtein
|
|
7
|
+
* gated by a download anomaly, combosquat suffix stripping, plus install-script
|
|
8
|
+
* / provenance risk. Anchored to the curated protected list (never all-pairs).
|
|
9
|
+
*/
|
|
10
|
+
import { COMBOSQUAT_SUFFIXES, FAKE_SCOPE_PATTERNS, KNOWN_SQUATS, PROTECTED_PACKAGES, UNSCOPED_SHADOWS, } from '../data/protectedPackages.js';
|
|
11
|
+
import { skeleton } from '../data/confusables.js';
|
|
12
|
+
import { KNOWN_VULNS } from '../data/knownCves.js';
|
|
13
|
+
import { bareName, damerauLevenshtein, isKeyboardTypo } from '../util/distance.js';
|
|
14
|
+
const SUP_REF = ['https://github.com/ossf/package-analysis', 'https://owasp.org/www-project-top-10-ci-cd-security-risks/'];
|
|
15
|
+
function protectedNames(config) {
|
|
16
|
+
return [...PROTECTED_PACKAGES.map((p) => p.name), ...config.protectedPackages];
|
|
17
|
+
}
|
|
18
|
+
function downloadsFor(name) {
|
|
19
|
+
return PROTECTED_PACKAGES.find((p) => p.name === name)?.weeklyDownloads;
|
|
20
|
+
}
|
|
21
|
+
/** Pure typosquat analysis for a single package name. Exported for tests. */
|
|
22
|
+
export function analyzeTyposquat(name, meta, config) {
|
|
23
|
+
const findings = [];
|
|
24
|
+
if (typeof name !== 'string' || name.length === 0)
|
|
25
|
+
return findings;
|
|
26
|
+
const lower = name.toLowerCase();
|
|
27
|
+
const protectedList = protectedNames(config);
|
|
28
|
+
const protectedSet = new Set(protectedList.map((p) => p.toLowerCase()));
|
|
29
|
+
// It IS a protected package — nothing to squat.
|
|
30
|
+
if (protectedSet.has(lower))
|
|
31
|
+
return findings;
|
|
32
|
+
const push = (ruleId, severity, confidence, title, description, target) => {
|
|
33
|
+
findings.push({
|
|
34
|
+
ruleId,
|
|
35
|
+
title,
|
|
36
|
+
category: 'supply-chain',
|
|
37
|
+
severity,
|
|
38
|
+
confidence,
|
|
39
|
+
description,
|
|
40
|
+
remediation: target
|
|
41
|
+
? `Confirm you meant "${target}". Install packages only from their documented, official name.`
|
|
42
|
+
: 'Verify the package name against its official source before installing.',
|
|
43
|
+
location: { kind: 'package', name },
|
|
44
|
+
owasp: 'LLM03:2025 Supply Chain',
|
|
45
|
+
references: SUP_REF,
|
|
46
|
+
data: { candidate: name, target },
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
// 1) Known pre-computed squat.
|
|
50
|
+
if (KNOWN_SQUATS[lower]) {
|
|
51
|
+
push('MTC-SUP-001', 'high', 'strong', `Known typosquat of ${KNOWN_SQUATS[lower]}`, `"${name}" is a known impersonation of the popular package "${KNOWN_SQUATS[lower]}".`, KNOWN_SQUATS[lower]);
|
|
52
|
+
return findings; // definitive; don't pile on
|
|
53
|
+
}
|
|
54
|
+
// 2) Fake official scope.
|
|
55
|
+
for (const re of FAKE_SCOPE_PATTERNS) {
|
|
56
|
+
if (re.test(name)) {
|
|
57
|
+
push('MTC-SUP-002', 'high', 'strong', 'Impersonated official scope', `"${name}" uses a scope crafted to look like the official @modelcontextprotocol namespace.`, '@modelcontextprotocol/*');
|
|
58
|
+
return findings;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// 3) Unscoped shadow of an official scoped package.
|
|
62
|
+
if (UNSCOPED_SHADOWS.has(lower)) {
|
|
63
|
+
push('MTC-SUP-003', 'high', 'strong', 'Unscoped shadow of an official package', `"${name}" is an unscoped name that shadows an official scoped @modelcontextprotocol package.`);
|
|
64
|
+
return findings;
|
|
65
|
+
}
|
|
66
|
+
const bare = bareName(lower);
|
|
67
|
+
const skel = skeleton(bare);
|
|
68
|
+
for (const prot of protectedList) {
|
|
69
|
+
const protLower = prot.toLowerCase();
|
|
70
|
+
const protBare = bareName(protLower);
|
|
71
|
+
if (bare === protBare)
|
|
72
|
+
continue; // same bare name, different scope handled above
|
|
73
|
+
// 4) Confusable skeleton collision (homoglyph squat).
|
|
74
|
+
if (skel === skeleton(protBare) && bare !== protBare) {
|
|
75
|
+
push('MTC-SUP-004', 'high', 'strong', `Homoglyph squat of ${prot}`, `"${name}" is visually confusable with "${prot}" (identical confusable skeleton "${skel}").`, prot);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
// 5) Edit-distance near-miss, gated by download anomaly.
|
|
79
|
+
if (protBare.length >= 5) {
|
|
80
|
+
const dist = damerauLevenshtein(bare, protBare);
|
|
81
|
+
if (dist >= 1 && dist <= 2) {
|
|
82
|
+
const dl = downloadsFor(prot);
|
|
83
|
+
const highTraffic = dl !== undefined && dl >= 50_000;
|
|
84
|
+
const keyboardSlip = isKeyboardTypo(bare, protBare);
|
|
85
|
+
const severity = dist === 1 && highTraffic ? 'high' : 'medium';
|
|
86
|
+
const confidence = dist === 1 ? 'strong' : 'heuristic';
|
|
87
|
+
push('MTC-SUP-005', severity, confidence, `Near-miss of ${prot} (edit distance ${dist})`, `"${name}" is edit-distance ${dist} from the ${highTraffic ? 'high-traffic ' : ''}package "${prot}"` +
|
|
88
|
+
`${keyboardSlip ? ' (keyboard-adjacent slip)' : ''}. ${meta?.weeklyDownloads != null && meta.weeklyDownloads < 500 ? 'Its own download count is negligible, consistent with a squat.' : 'Verify this is the intended package.'}`, prot);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// 6) Combosquat: strip decorative suffixes and re-compare.
|
|
93
|
+
let stripped = bare;
|
|
94
|
+
for (const suf of COMBOSQUAT_SUFFIXES)
|
|
95
|
+
if (stripped.endsWith(suf))
|
|
96
|
+
stripped = stripped.slice(0, -suf.length);
|
|
97
|
+
if (stripped !== bare && stripped === protBare) {
|
|
98
|
+
push('MTC-SUP-006', 'medium', 'heuristic', `Possible combosquat of ${prot}`, `"${name}" is "${prot}" with a decorative suffix — a common combosquat pattern.`, prot);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return findings;
|
|
102
|
+
}
|
|
103
|
+
/** Install-script & provenance risk from package metadata. */
|
|
104
|
+
export function analyzeProvenance(meta) {
|
|
105
|
+
const findings = [];
|
|
106
|
+
const name = meta.name ?? 'package';
|
|
107
|
+
const scripts = meta.scripts ?? {};
|
|
108
|
+
const lifecycle = ['preinstall', 'install', 'postinstall'].filter((s) => scripts[s]);
|
|
109
|
+
if (lifecycle.length > 0) {
|
|
110
|
+
const hasPre = lifecycle.includes('preinstall');
|
|
111
|
+
findings.push({
|
|
112
|
+
ruleId: 'MTC-SUP-010',
|
|
113
|
+
title: `Package runs install-time scripts (${lifecycle.join(', ')})`,
|
|
114
|
+
category: 'supply-chain',
|
|
115
|
+
severity: hasPre ? 'high' : 'medium',
|
|
116
|
+
confidence: 'strong',
|
|
117
|
+
description: `"${name}" executes ${lifecycle.join('/')} script(s) at install time. The large majority of malicious ` +
|
|
118
|
+
`packages abuse install hooks to run code before you ever import them.`,
|
|
119
|
+
remediation: 'Review the scripts; install with --ignore-scripts where possible and vet what they do.',
|
|
120
|
+
location: { kind: 'package', name },
|
|
121
|
+
owasp: 'LLM03:2025 Supply Chain',
|
|
122
|
+
references: SUP_REF,
|
|
123
|
+
data: { scripts: lifecycle },
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
if (meta.pinned === false) {
|
|
127
|
+
findings.push({
|
|
128
|
+
ruleId: 'MTC-SUP-013',
|
|
129
|
+
title: `Package is not version-pinned (${meta.requestedSpec ?? 'no version'})`,
|
|
130
|
+
category: 'supply-chain',
|
|
131
|
+
severity: lifecycle.length > 0 ? 'medium' : 'low',
|
|
132
|
+
confidence: 'strong',
|
|
133
|
+
description: `"${name}" is installed with a floating/latest spec (${meta.requestedSpec ?? 'no version'}), so its tool ` +
|
|
134
|
+
`definitions and code can change silently after you approve it — the rug-pull enabler. Pinning is the ` +
|
|
135
|
+
`recommended static control.`,
|
|
136
|
+
remediation: 'Pin an exact version (and commit mcptrustchecker.lock so drift is caught on rescan).',
|
|
137
|
+
location: { kind: 'package', name },
|
|
138
|
+
owasp: 'LLM03:2025 Supply Chain',
|
|
139
|
+
data: { requestedSpec: meta.requestedSpec ?? null },
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
if (meta.repositoryUrl === null) {
|
|
143
|
+
findings.push({
|
|
144
|
+
ruleId: 'MTC-SUP-011',
|
|
145
|
+
title: 'Package has no source repository',
|
|
146
|
+
category: 'supply-chain',
|
|
147
|
+
severity: 'low',
|
|
148
|
+
confidence: 'strong',
|
|
149
|
+
description: `"${name}" declares no repository URL, so its published artifact cannot be compared against reviewable source.`,
|
|
150
|
+
remediation: 'Prefer packages that link to public, reviewable source.',
|
|
151
|
+
location: { kind: 'package', name },
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
if (meta.license === null) {
|
|
155
|
+
findings.push({
|
|
156
|
+
ruleId: 'MTC-SUP-012',
|
|
157
|
+
title: 'Package has no license',
|
|
158
|
+
category: 'hygiene',
|
|
159
|
+
severity: 'low',
|
|
160
|
+
confidence: 'strong',
|
|
161
|
+
description: `"${name}" has no declared license.`,
|
|
162
|
+
location: { kind: 'package', name },
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return findings;
|
|
166
|
+
}
|
|
167
|
+
/** Check a package's declared dependencies for squats / known-vuln names. */
|
|
168
|
+
export function analyzeDependencies(meta, config) {
|
|
169
|
+
const findings = [];
|
|
170
|
+
const deps = (Array.isArray(meta.dependencies) ? meta.dependencies : []).filter((d) => typeof d === 'string');
|
|
171
|
+
if (deps.length === 0)
|
|
172
|
+
return findings;
|
|
173
|
+
const protectedList = protectedNames(config);
|
|
174
|
+
for (const dep of deps) {
|
|
175
|
+
const lower = dep.toLowerCase();
|
|
176
|
+
if (protectedList.some((p) => p.toLowerCase() === lower))
|
|
177
|
+
continue; // it's a real one
|
|
178
|
+
let squatOf;
|
|
179
|
+
if (KNOWN_SQUATS[lower])
|
|
180
|
+
squatOf = KNOWN_SQUATS[lower];
|
|
181
|
+
else if (FAKE_SCOPE_PATTERNS.some((re) => re.test(dep)))
|
|
182
|
+
squatOf = '@modelcontextprotocol/*';
|
|
183
|
+
else {
|
|
184
|
+
const sk = skeleton(bareName(lower));
|
|
185
|
+
for (const p of protectedList) {
|
|
186
|
+
const pb = bareName(p.toLowerCase());
|
|
187
|
+
if (pb !== bareName(lower) && skeleton(pb) === sk) {
|
|
188
|
+
squatOf = p;
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (squatOf) {
|
|
194
|
+
findings.push({
|
|
195
|
+
ruleId: 'MTC-SUP-014',
|
|
196
|
+
title: `Dependency "${dep}" resembles ${squatOf}`,
|
|
197
|
+
category: 'supply-chain',
|
|
198
|
+
severity: 'medium',
|
|
199
|
+
confidence: 'heuristic',
|
|
200
|
+
description: `A declared dependency "${dep}" looks like a squat of "${squatOf}" — a transitive supply-chain risk.`,
|
|
201
|
+
remediation: `Confirm the dependency is the intended "${squatOf}".`,
|
|
202
|
+
location: { kind: 'package', name: meta.name ?? dep },
|
|
203
|
+
owasp: 'LLM03:2025 Supply Chain',
|
|
204
|
+
data: { dependency: dep, target: squatOf },
|
|
205
|
+
});
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const vuln = KNOWN_VULNS.find((v) => v.package === dep);
|
|
209
|
+
if (vuln) {
|
|
210
|
+
findings.push({
|
|
211
|
+
ruleId: 'MTC-SUP-014',
|
|
212
|
+
title: `Dependency "${dep}" has a known advisory (${vuln.id})`,
|
|
213
|
+
category: 'supply-chain',
|
|
214
|
+
severity: 'low',
|
|
215
|
+
confidence: 'heuristic',
|
|
216
|
+
description: `Declared dependency "${dep}" matches a known-vulnerable package (${vuln.id}); the pinned version is not resolvable from metadata, so verify it is patched.`,
|
|
217
|
+
remediation: `Ensure "${dep}" is upgraded past the ${vuln.id} fix.`,
|
|
218
|
+
location: { kind: 'package', name: meta.name ?? dep },
|
|
219
|
+
references: [vuln.reference],
|
|
220
|
+
owasp: 'LLM03:2025 Supply Chain',
|
|
221
|
+
data: { dependency: dep, advisory: vuln.id },
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return findings;
|
|
226
|
+
}
|
|
227
|
+
export const supplyChainDetector = {
|
|
228
|
+
id: 'supply-chain',
|
|
229
|
+
stage: 5,
|
|
230
|
+
title: 'Supply-chain & typosquatting',
|
|
231
|
+
run(ctx) {
|
|
232
|
+
const findings = [];
|
|
233
|
+
const meta = ctx.surface.packageMeta;
|
|
234
|
+
const name = meta?.name ?? (ctx.surface.source.kind === 'package' ? ctx.surface.id : undefined);
|
|
235
|
+
if (name)
|
|
236
|
+
findings.push(...analyzeTyposquat(name, meta, ctx.config));
|
|
237
|
+
if (meta) {
|
|
238
|
+
findings.push(...analyzeProvenance(meta));
|
|
239
|
+
findings.push(...analyzeDependencies(meta, ctx.config));
|
|
240
|
+
}
|
|
241
|
+
return findings;
|
|
242
|
+
},
|
|
243
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
|
|
2
|
+
/**
|
|
3
|
+
* Stage 4 — Toxic-flow graph (the flagship analysis).
|
|
4
|
+
*
|
|
5
|
+
* The "lethal trifecta": an agent session that can reach
|
|
6
|
+
* UNTRUSTED_INPUT → SENSITIVE_DATA_SOURCE → EXTERNAL_SINK
|
|
7
|
+
* holds an exfiltration primitive, whether those roles live in one tool or are
|
|
8
|
+
* composed across several tools and the client's built-ins.
|
|
9
|
+
*
|
|
10
|
+
* Honesty contract: static analysis proves the primitive *exists* (capability
|
|
11
|
+
* co-presence), not that a runtime chain *will* execute. Findings are worded
|
|
12
|
+
* accordingly, and only a single-tool completed trifecta is `confirmed`.
|
|
13
|
+
*/
|
|
14
|
+
import type { DetectorContext, Finding, ServerSurface, ToolCapability, ToxicFlow } from '../types.js';
|
|
15
|
+
interface FlowAnalysis {
|
|
16
|
+
flows: ToxicFlow[];
|
|
17
|
+
findings: Finding[];
|
|
18
|
+
}
|
|
19
|
+
export declare function analyzeToxicFlows(ctx: DetectorContext): FlowAnalysis;
|
|
20
|
+
/** Convenience wrapper matching the pipeline stage description. */
|
|
21
|
+
export declare function toxicFlowStage(surface: ServerSurface, capabilities: ToolCapability[], config: DetectorContext['config']): FlowAnalysis;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
|
|
2
|
+
/**
|
|
3
|
+
* Stage 4 — Toxic-flow graph (the flagship analysis).
|
|
4
|
+
*
|
|
5
|
+
* The "lethal trifecta": an agent session that can reach
|
|
6
|
+
* UNTRUSTED_INPUT → SENSITIVE_DATA_SOURCE → EXTERNAL_SINK
|
|
7
|
+
* holds an exfiltration primitive, whether those roles live in one tool or are
|
|
8
|
+
* composed across several tools and the client's built-ins.
|
|
9
|
+
*
|
|
10
|
+
* Honesty contract: static analysis proves the primitive *exists* (capability
|
|
11
|
+
* co-presence), not that a runtime chain *will* execute. Findings are worded
|
|
12
|
+
* accordingly, and only a single-tool completed trifecta is `confirmed`.
|
|
13
|
+
*/
|
|
14
|
+
import { CLIENT_BUILTINS } from '../data/capabilityLexicon.js';
|
|
15
|
+
import { representativeTrifectaPath, representativeExfilPath, renderPath } from './flowGraph.js';
|
|
16
|
+
const FLOW_REFS = [];
|
|
17
|
+
function isSinkCap(c) {
|
|
18
|
+
return c.tags.includes('external-sink') || c.tags.includes('code-exec');
|
|
19
|
+
}
|
|
20
|
+
export function analyzeToxicFlows(ctx) {
|
|
21
|
+
const caps = [...ctx.capabilities];
|
|
22
|
+
if (ctx.config.includeBuiltins) {
|
|
23
|
+
for (const b of CLIENT_BUILTINS)
|
|
24
|
+
caps.push({ tool: b.tool, tags: b.tags, reasons: {} });
|
|
25
|
+
}
|
|
26
|
+
const untrusted = caps.filter((c) => c.tags.includes('untrusted-input')).map((c) => c.tool);
|
|
27
|
+
const sources = caps.filter((c) => c.tags.includes('sensitive-source')).map((c) => c.tool);
|
|
28
|
+
const sinks = caps.filter(isSinkCap).map((c) => c.tool);
|
|
29
|
+
const selfTrifecta = caps.filter((c) => c.tags.includes('untrusted-input') && c.tags.includes('sensitive-source') && isSinkCap(c));
|
|
30
|
+
const selfTrifectaNames = new Set(selfTrifecta.map((c) => c.tool));
|
|
31
|
+
const selfExfil = caps.filter((c) => !selfTrifectaNames.has(c.tool) &&
|
|
32
|
+
c.tags.includes('sensitive-source') &&
|
|
33
|
+
isSinkCap(c) &&
|
|
34
|
+
!c.tags.includes('untrusted-input'));
|
|
35
|
+
const fullTrifectaPresent = untrusted.length > 0 && sources.length > 0 && sinks.length > 0;
|
|
36
|
+
const flows = [];
|
|
37
|
+
const findings = [];
|
|
38
|
+
let flowSeq = 0;
|
|
39
|
+
const nextId = () => `flow-${++flowSeq}`;
|
|
40
|
+
// 1) Self-contained completed trifecta — one tool ingests, reads, and exfiltrates.
|
|
41
|
+
for (const c of selfTrifecta) {
|
|
42
|
+
const id = nextId();
|
|
43
|
+
flows.push({
|
|
44
|
+
id,
|
|
45
|
+
severity: 'critical',
|
|
46
|
+
confidence: 'confirmed',
|
|
47
|
+
untrustedInput: [c.tool],
|
|
48
|
+
sensitiveSource: [c.tool],
|
|
49
|
+
externalSink: [c.tool],
|
|
50
|
+
selfContained: true,
|
|
51
|
+
description: `Tool "${c.tool}" alone ingests untrusted input, reads sensitive data, and can send it out.`,
|
|
52
|
+
});
|
|
53
|
+
findings.push({
|
|
54
|
+
ruleId: 'MTC-FLOW-001',
|
|
55
|
+
title: `Self-contained exfiltration primitive: "${c.tool}"`,
|
|
56
|
+
category: 'exfiltration',
|
|
57
|
+
severity: 'critical',
|
|
58
|
+
confidence: 'confirmed',
|
|
59
|
+
description: `Tool "${c.tool}" holds all three trifecta roles by itself (untrusted-input + sensitive-source + ` +
|
|
60
|
+
`external-sink). A single call can pull attacker-controlled content, read private data, and exfiltrate ` +
|
|
61
|
+
`it — no composition required.`,
|
|
62
|
+
remediation: 'Split responsibilities across separate, individually-consented tools; never combine untrusted input, secret reads, and egress in one tool.',
|
|
63
|
+
location: { kind: 'flow', name: c.tool },
|
|
64
|
+
owasp: 'LLM02:2025 Sensitive Information Disclosure',
|
|
65
|
+
references: FLOW_REFS,
|
|
66
|
+
data: { tags: c.tags },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
// 2) Cross-tool completed trifecta (only if no single tool already completes it).
|
|
70
|
+
if (fullTrifectaPresent && selfTrifecta.length === 0) {
|
|
71
|
+
const id = nextId();
|
|
72
|
+
const p = representativeTrifectaPath(caps, ctx.surface.tools);
|
|
73
|
+
const chain = p ? renderPath(p) : `[${untrusted.join(', ')}] → [${sources.join(', ')}] → [${sinks.join(', ')}]`;
|
|
74
|
+
flows.push({
|
|
75
|
+
id,
|
|
76
|
+
severity: 'critical',
|
|
77
|
+
confidence: 'strong',
|
|
78
|
+
untrustedInput: untrusted,
|
|
79
|
+
sensitiveSource: sources,
|
|
80
|
+
externalSink: sinks,
|
|
81
|
+
selfContained: false,
|
|
82
|
+
path: p?.path,
|
|
83
|
+
pathWired: p?.wired,
|
|
84
|
+
description: `A cross-tool exfiltration chain exists: ${chain}.`,
|
|
85
|
+
});
|
|
86
|
+
findings.push({
|
|
87
|
+
ruleId: 'MTC-FLOW-002',
|
|
88
|
+
title: 'Completed toxic-flow trifecta across tools',
|
|
89
|
+
category: 'exfiltration',
|
|
90
|
+
severity: 'critical',
|
|
91
|
+
confidence: 'strong',
|
|
92
|
+
description: `This server (with${ctx.config.includeBuiltins ? '' : 'out'} client built-ins) exposes a complete ` +
|
|
93
|
+
`data-exfiltration chain: ${chain}. Untrusted input is ingested, private data is read, and it can be sent ` +
|
|
94
|
+
`to an external sink` +
|
|
95
|
+
(p?.wired
|
|
96
|
+
? ` — and at least one leg is a direct schema wire (⇒), where a producer's output drops straight into a ` +
|
|
97
|
+
`free-text parameter of the next tool, so the chain needs little agent cooperation.`
|
|
98
|
+
: ` via the agent composing the tools (→).`) +
|
|
99
|
+
` Static analysis proves the primitive exists, not that a specific run will occur.`,
|
|
100
|
+
remediation: 'Remove one leg of the trifecta: isolate untrusted-input tools from secret-reading tools and from egress tools, or require human approval between them.',
|
|
101
|
+
location: { kind: 'flow', name: p?.path?.join(' → ') },
|
|
102
|
+
owasp: 'LLM02:2025 Sensitive Information Disclosure',
|
|
103
|
+
references: FLOW_REFS,
|
|
104
|
+
data: { untrusted, sources, sinks, path: p?.path, edges: p?.edges, wired: p?.wired },
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
// 3) Self-contained exfil (reads secrets + can egress, no untrusted input leg).
|
|
108
|
+
for (const c of selfExfil) {
|
|
109
|
+
const id = nextId();
|
|
110
|
+
flows.push({
|
|
111
|
+
id,
|
|
112
|
+
severity: 'high',
|
|
113
|
+
confidence: 'strong',
|
|
114
|
+
untrustedInput: [],
|
|
115
|
+
sensitiveSource: [c.tool],
|
|
116
|
+
externalSink: [c.tool],
|
|
117
|
+
selfContained: true,
|
|
118
|
+
description: `Tool "${c.tool}" both reads sensitive data and can send it externally.`,
|
|
119
|
+
});
|
|
120
|
+
findings.push({
|
|
121
|
+
ruleId: 'MTC-FLOW-003',
|
|
122
|
+
title: `Read-and-egress in one tool: "${c.tool}"`,
|
|
123
|
+
category: 'exfiltration',
|
|
124
|
+
severity: 'high',
|
|
125
|
+
confidence: 'strong',
|
|
126
|
+
description: `Tool "${c.tool}" can both read sensitive data and send data to an external destination. Even without an ` +
|
|
127
|
+
`explicit untrusted-input leg, this is a single-call data-exfiltration path if the model is ever ` +
|
|
128
|
+
`manipulated.`,
|
|
129
|
+
remediation: 'Separate reading from sending; require explicit user confirmation before egress of file/secret contents.',
|
|
130
|
+
location: { kind: 'flow', name: c.tool },
|
|
131
|
+
owasp: 'LLM02:2025 Sensitive Information Disclosure',
|
|
132
|
+
references: FLOW_REFS,
|
|
133
|
+
data: { tags: c.tags },
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
// 4) Cross-tool data-out path: a source tool and a *different* sink tool exist.
|
|
137
|
+
const crossExfil = !fullTrifectaPresent && sources.some((s) => sinks.some((k) => k !== s));
|
|
138
|
+
if (crossExfil) {
|
|
139
|
+
const id = nextId();
|
|
140
|
+
const p = representativeExfilPath(caps, ctx.surface.tools);
|
|
141
|
+
flows.push({
|
|
142
|
+
id,
|
|
143
|
+
severity: 'high',
|
|
144
|
+
confidence: 'strong',
|
|
145
|
+
untrustedInput: [],
|
|
146
|
+
sensitiveSource: sources,
|
|
147
|
+
externalSink: sinks,
|
|
148
|
+
selfContained: false,
|
|
149
|
+
path: p?.path,
|
|
150
|
+
pathWired: p?.wired,
|
|
151
|
+
description: `A sensitive-source → external-sink chain exists: ${p ? renderPath(p) : ''}.`,
|
|
152
|
+
});
|
|
153
|
+
findings.push({
|
|
154
|
+
ruleId: 'MTC-FLOW-004',
|
|
155
|
+
title: 'Sensitive-source and external-sink co-exist',
|
|
156
|
+
category: 'exfiltration',
|
|
157
|
+
severity: 'high',
|
|
158
|
+
confidence: 'strong',
|
|
159
|
+
description: `Tools that read sensitive data ([${sources.join(', ')}]) and tools that can send data out ` +
|
|
160
|
+
`([${sinks.join(', ')}]) are exposed together. An agent can move private data to the sink.`,
|
|
161
|
+
remediation: 'Keep secret-reading and egress capabilities on separate, separately-approved servers.',
|
|
162
|
+
location: { kind: 'flow' },
|
|
163
|
+
owasp: 'LLM02:2025 Sensitive Information Disclosure',
|
|
164
|
+
references: FLOW_REFS,
|
|
165
|
+
data: { sources, sinks },
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
// 5) Untrusted input reaching an action sink (no sensitive source involved).
|
|
169
|
+
if (!fullTrifectaPresent && untrusted.length > 0 && sinks.length > 0 && sources.length === 0) {
|
|
170
|
+
const id = nextId();
|
|
171
|
+
flows.push({
|
|
172
|
+
id,
|
|
173
|
+
severity: 'medium',
|
|
174
|
+
confidence: 'strong',
|
|
175
|
+
untrustedInput: untrusted,
|
|
176
|
+
sensitiveSource: [],
|
|
177
|
+
externalSink: sinks,
|
|
178
|
+
selfContained: false,
|
|
179
|
+
description: 'Untrusted input can drive an external action even though no sensitive source is exposed.',
|
|
180
|
+
});
|
|
181
|
+
findings.push({
|
|
182
|
+
ruleId: 'MTC-FLOW-005',
|
|
183
|
+
title: 'Untrusted input can drive an external action',
|
|
184
|
+
category: 'exfiltration',
|
|
185
|
+
severity: 'medium',
|
|
186
|
+
confidence: 'strong',
|
|
187
|
+
description: `Untrusted-input tools ([${untrusted.join(', ')}]) co-exist with external-action tools ([${sinks.join(', ')}]). ` +
|
|
188
|
+
`A prompt injection could cause unwanted external actions, though no direct sensitive-data leak path was found.`,
|
|
189
|
+
remediation: 'Require confirmation for state-changing/egress actions triggered after processing untrusted content.',
|
|
190
|
+
location: { kind: 'flow' },
|
|
191
|
+
owasp: 'LLM06:2025 Excessive Agency',
|
|
192
|
+
references: FLOW_REFS,
|
|
193
|
+
data: { untrusted, sinks },
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return { flows, findings };
|
|
197
|
+
}
|
|
198
|
+
/** Convenience wrapper matching the pipeline stage description. */
|
|
199
|
+
export function toxicFlowStage(surface, capabilities, config) {
|
|
200
|
+
return analyzeToxicFlows({ surface, capabilities, config });
|
|
201
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
|
|
2
|
+
/**
|
|
3
|
+
* Stage 1 — Lexical / Unicode integrity.
|
|
4
|
+
*
|
|
5
|
+
* Scans every text field for the invisible-character families used to smuggle
|
|
6
|
+
* instructions past human review, and **decodes** the two families that carry a
|
|
7
|
+
* payload (Tags block, variation-selector channel) so the recovered text is
|
|
8
|
+
* surfaced as evidence. Also runs a mixed-script (homoglyph) check using Unicode
|
|
9
|
+
* script properties — a codepoint blocklist cannot catch visible confusables.
|
|
10
|
+
*/
|
|
11
|
+
import type { Detector } from '../types.js';
|
|
12
|
+
import { type InvisibleFamily } from '../data/unicode.js';
|
|
13
|
+
interface FamilyHit {
|
|
14
|
+
family: InvisibleFamily;
|
|
15
|
+
count: number;
|
|
16
|
+
decoded?: string;
|
|
17
|
+
}
|
|
18
|
+
/** Group invisible-character hits by family, decoding payload channels. */
|
|
19
|
+
export declare function analyzeText(text: string): FamilyHit[];
|
|
20
|
+
export declare const unicodeDetector: Detector;
|
|
21
|
+
export {};
|