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.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +379 -0
  3. package/dist/acquire/clientConfig.d.ts +47 -0
  4. package/dist/acquire/clientConfig.js +124 -0
  5. package/dist/acquire/discover.d.ts +12 -0
  6. package/dist/acquire/discover.js +53 -0
  7. package/dist/acquire/index.d.ts +24 -0
  8. package/dist/acquire/index.js +175 -0
  9. package/dist/acquire/live.d.ts +47 -0
  10. package/dist/acquire/live.js +357 -0
  11. package/dist/acquire/manifest.d.ts +10 -0
  12. package/dist/acquire/manifest.js +94 -0
  13. package/dist/acquire/npm.d.ts +10 -0
  14. package/dist/acquire/npm.js +55 -0
  15. package/dist/acquire/source.d.ts +16 -0
  16. package/dist/acquire/source.js +109 -0
  17. package/dist/cli/index.d.ts +15 -0
  18. package/dist/cli/index.js +396 -0
  19. package/dist/config.d.ts +15 -0
  20. package/dist/config.js +81 -0
  21. package/dist/data/capabilityLexicon.d.ts +51 -0
  22. package/dist/data/capabilityLexicon.js +204 -0
  23. package/dist/data/confusables.d.ts +17 -0
  24. package/dist/data/confusables.js +65 -0
  25. package/dist/data/injectionPatterns.d.ts +55 -0
  26. package/dist/data/injectionPatterns.js +216 -0
  27. package/dist/data/knownCves.d.ts +24 -0
  28. package/dist/data/knownCves.js +49 -0
  29. package/dist/data/protectedPackages.d.ts +30 -0
  30. package/dist/data/protectedPackages.js +89 -0
  31. package/dist/data/ruleCatalog.d.ts +16 -0
  32. package/dist/data/ruleCatalog.js +94 -0
  33. package/dist/data/sourcePatterns.d.ts +25 -0
  34. package/dist/data/sourcePatterns.js +92 -0
  35. package/dist/data/unicode.d.ts +34 -0
  36. package/dist/data/unicode.js +99 -0
  37. package/dist/detectors/capability.d.ts +14 -0
  38. package/dist/detectors/capability.js +281 -0
  39. package/dist/detectors/collision.d.ts +11 -0
  40. package/dist/detectors/collision.js +63 -0
  41. package/dist/detectors/flowGraph.d.ts +43 -0
  42. package/dist/detectors/flowGraph.js +100 -0
  43. package/dist/detectors/index.d.ts +18 -0
  44. package/dist/detectors/index.js +26 -0
  45. package/dist/detectors/injection.d.ts +9 -0
  46. package/dist/detectors/injection.js +178 -0
  47. package/dist/detectors/meta.d.ts +8 -0
  48. package/dist/detectors/meta.js +33 -0
  49. package/dist/detectors/posture.d.ts +9 -0
  50. package/dist/detectors/posture.js +128 -0
  51. package/dist/detectors/source.d.ts +12 -0
  52. package/dist/detectors/source.js +87 -0
  53. package/dist/detectors/supplyChain.d.ts +17 -0
  54. package/dist/detectors/supplyChain.js +243 -0
  55. package/dist/detectors/toxicFlow.d.ts +22 -0
  56. package/dist/detectors/toxicFlow.js +201 -0
  57. package/dist/detectors/unicode.d.ts +21 -0
  58. package/dist/detectors/unicode.js +244 -0
  59. package/dist/engine.d.ts +27 -0
  60. package/dist/engine.js +135 -0
  61. package/dist/index.d.ts +38 -0
  62. package/dist/index.js +46 -0
  63. package/dist/lockfile.d.ts +30 -0
  64. package/dist/lockfile.js +87 -0
  65. package/dist/policy.d.ts +13 -0
  66. package/dist/policy.js +48 -0
  67. package/dist/report/badge.d.ts +14 -0
  68. package/dist/report/badge.js +18 -0
  69. package/dist/report/json.d.ts +4 -0
  70. package/dist/report/json.js +4 -0
  71. package/dist/report/markdown.d.ts +6 -0
  72. package/dist/report/markdown.js +110 -0
  73. package/dist/report/sarif.d.ts +7 -0
  74. package/dist/report/sarif.js +105 -0
  75. package/dist/report/terminal.d.ts +15 -0
  76. package/dist/report/terminal.js +252 -0
  77. package/dist/scoring/capability.d.ts +10 -0
  78. package/dist/scoring/capability.js +48 -0
  79. package/dist/scoring/index.d.ts +10 -0
  80. package/dist/scoring/index.js +96 -0
  81. package/dist/scoring/model.d.ts +50 -0
  82. package/dist/scoring/model.js +101 -0
  83. package/dist/types.d.ts +361 -0
  84. package/dist/types.js +12 -0
  85. package/dist/util/ansi.d.ts +26 -0
  86. package/dist/util/ansi.js +42 -0
  87. package/dist/util/capabilities.d.ts +17 -0
  88. package/dist/util/capabilities.js +68 -0
  89. package/dist/util/distance.d.ts +23 -0
  90. package/dist/util/distance.js +119 -0
  91. package/dist/util/hash.d.ts +20 -0
  92. package/dist/util/hash.js +80 -0
  93. package/dist/util/text.d.ts +45 -0
  94. package/dist/util/text.js +170 -0
  95. package/dist/version.d.ts +13 -0
  96. package/dist/version.js +13 -0
  97. package/package.json +72 -0
@@ -0,0 +1,244 @@
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 { collectTextFields } from '../util/text.js';
12
+ import { classifyCodepoint, decodeTagCodepoint, decodeVariationSelectorByte, FAMILY_LABELS, } from '../data/unicode.js';
13
+ // ESC (U+001B) then a CSI ([ ... final byte) or OSC (] ... BEL/ST) sequence.
14
+ const ANSI_ESCAPE = /\u001b(?:\[[0-9;?]*[ -\/]*[@-~]|\][^\u0007\u001b]*(?:\u0007|\u001b\\))/;
15
+ function bytesToReadable(bytes) {
16
+ const printable = bytes.every((b) => b >= 0x20 && b <= 0x7e);
17
+ if (printable)
18
+ return Buffer.from(bytes).toString('latin1');
19
+ return bytes.map((b) => b.toString(16).padStart(2, '0')).join(' ');
20
+ }
21
+ const EMOJI_BASE = /\p{Extended_Pictographic}/u;
22
+ function isVariationSelector(cp) {
23
+ return cp !== undefined && classifyCodepoint(cp) === 'variation-selector';
24
+ }
25
+ /**
26
+ * Is a variation selector at index `i` part of a genuine smuggling channel
27
+ * rather than a legitimate emoji-presentation / keycap selector?
28
+ *
29
+ * - U+E0100–U+E01EF (supplementary selectors) encode bytes 16–255 and have no
30
+ * legitimate use in tool metadata → always a channel.
31
+ * - Otherwise (VS1–VS16, U+FE00–U+FE0F): a LONE selector is a legitimate
32
+ * presentation selector (⚠️) or keycap sequence (1️⃣) and must NOT be flagged.
33
+ * Only a RUN of ≥2 consecutive selectors — the one-selector-per-payload-byte
34
+ * signature of emoji smuggling — counts.
35
+ */
36
+ function vsIsSmuggling(chars, i, cp) {
37
+ if (cp >= 0xe0100 && cp <= 0xe01ef)
38
+ return true;
39
+ const prev = i > 0 ? chars[i - 1]?.codePointAt(0) : undefined;
40
+ const next = chars[i + 1]?.codePointAt(0);
41
+ return isVariationSelector(prev) || isVariationSelector(next);
42
+ }
43
+ /** Is the neighbor in direction `dir` (skipping variation selectors) an emoji? */
44
+ function neighborIsEmoji(chars, from, dir) {
45
+ for (let j = from; j >= 0 && j < chars.length; j += dir) {
46
+ const c = chars[j]?.codePointAt(0);
47
+ if (isVariationSelector(c))
48
+ continue; // skip presentation selectors in the sequence
49
+ return c !== undefined && EMOJI_BASE.test(String.fromCodePoint(c));
50
+ }
51
+ return false;
52
+ }
53
+ /**
54
+ * A ZERO-WIDTH JOINER (U+200D) flanked by emoji is a legitimate emoji ZWJ
55
+ * sequence (👨‍💻, 🧑‍🔬) — not smuggling. Only exempt that exact shape.
56
+ */
57
+ function zwjIsEmojiSequence(chars, i, cp) {
58
+ if (cp !== 0x200d)
59
+ return false;
60
+ return neighborIsEmoji(chars, i - 1, -1) && neighborIsEmoji(chars, i + 1, 1);
61
+ }
62
+ /** Group invisible-character hits by family, decoding payload channels. */
63
+ export function analyzeText(text) {
64
+ if (typeof text !== 'string')
65
+ return [];
66
+ const acc = new Map();
67
+ const chars = [...text];
68
+ for (let i = 0; i < chars.length; i++) {
69
+ const cp = chars[i].codePointAt(0);
70
+ if (cp === undefined)
71
+ continue;
72
+ const fam = classifyCodepoint(cp);
73
+ if (!fam)
74
+ continue;
75
+ // Skip legitimate emoji constructs that reuse smuggling-family codepoints.
76
+ if (fam === 'variation-selector' && !vsIsSmuggling(chars, i, cp))
77
+ continue;
78
+ if (fam === 'zero-width' && zwjIsEmojiSequence(chars, i, cp))
79
+ continue;
80
+ const entry = acc.get(fam) ?? { count: 0, tagChars: [], vsBytes: [] };
81
+ entry.count += 1;
82
+ if (fam === 'tags') {
83
+ const d = decodeTagCodepoint(cp);
84
+ if (d)
85
+ entry.tagChars.push(d);
86
+ }
87
+ else if (fam === 'variation-selector') {
88
+ const b = decodeVariationSelectorByte(cp);
89
+ if (b !== null)
90
+ entry.vsBytes.push(b);
91
+ }
92
+ acc.set(fam, entry);
93
+ }
94
+ const hits = [];
95
+ for (const [family, entry] of acc) {
96
+ const hit = { family, count: entry.count };
97
+ if (family === 'tags' && entry.tagChars.length > 0)
98
+ hit.decoded = entry.tagChars.join('');
99
+ if (family === 'variation-selector' && entry.vsBytes.length > 2)
100
+ hit.decoded = bytesToReadable(entry.vsBytes);
101
+ hits.push(hit);
102
+ }
103
+ return hits;
104
+ }
105
+ /** Resolve the Unicode script names present in a string (Latin, Cyrillic, …). */
106
+ function scriptsOf(text) {
107
+ const scripts = new Set();
108
+ const named = [
109
+ ['Latin', /\p{Script=Latin}/u],
110
+ ['Cyrillic', /\p{Script=Cyrillic}/u],
111
+ // Greek only counts when the character is genuinely Latin-CONFUSABLE (Α Β Ε
112
+ // Κ Μ Ν Ο Ρ Τ Υ Χ ο ρ …) — not math symbols like Δ Ω Σ Φ Ψ Π μ, which appear
113
+ // constantly in legitimate technical metadata ("ΔT", "kΩ") and are not a
114
+ // homoglyph attack.
115
+ ['Greek', /[ΑΒΕΖΗΙΚΜΝΟΡΤΥΧκνορυχ]/u],
116
+ ['Armenian', /\p{Script=Armenian}/u],
117
+ ['Hebrew', /\p{Script=Hebrew}/u],
118
+ ];
119
+ // Evaluate per token so a legitimately multilingual description isn't flagged;
120
+ // only a single token that mixes scripts is confusable.
121
+ for (const token of text.split(/\s+/)) {
122
+ if (token.length < 2)
123
+ continue;
124
+ const tokenScripts = new Set();
125
+ for (const [name, re] of named)
126
+ if (re.test(token))
127
+ tokenScripts.add(name);
128
+ if (tokenScripts.size > 1) {
129
+ for (const s of tokenScripts)
130
+ scripts.add(s);
131
+ }
132
+ }
133
+ return scripts;
134
+ }
135
+ function severityFor(family, count, threshold) {
136
+ switch (family) {
137
+ case 'tags':
138
+ return { severity: 'critical', confidence: 'confirmed' };
139
+ case 'variation-selector':
140
+ return count > 2 ? { severity: 'high', confidence: 'confirmed' } : { severity: 'medium', confidence: 'heuristic' };
141
+ case 'bidi-override':
142
+ return { severity: 'high', confidence: 'strong' };
143
+ case 'control':
144
+ return { severity: 'high', confidence: 'strong' };
145
+ case 'zero-width':
146
+ case 'invisible-math':
147
+ return count >= threshold
148
+ ? { severity: 'high', confidence: 'strong' }
149
+ : { severity: 'medium', confidence: 'heuristic' };
150
+ case 'bidi-isolate':
151
+ case 'bidi-mark':
152
+ case 'default-ignorable':
153
+ return { severity: 'medium', confidence: 'heuristic' };
154
+ case 'unusual-whitespace':
155
+ return { severity: 'low', confidence: 'heuristic' };
156
+ default:
157
+ return { severity: 'low', confidence: 'speculative' };
158
+ }
159
+ }
160
+ const FAMILY_RULE = {
161
+ tags: 'MTC-UNI-001',
162
+ 'variation-selector': 'MTC-UNI-002',
163
+ 'bidi-override': 'MTC-UNI-003',
164
+ 'bidi-isolate': 'MTC-UNI-004',
165
+ 'bidi-mark': 'MTC-UNI-004',
166
+ 'zero-width': 'MTC-UNI-005',
167
+ 'invisible-math': 'MTC-UNI-005',
168
+ 'default-ignorable': 'MTC-UNI-006',
169
+ 'unusual-whitespace': 'MTC-UNI-007',
170
+ control: 'MTC-UNI-008',
171
+ };
172
+ export const unicodeDetector = {
173
+ id: 'unicode',
174
+ stage: 1,
175
+ title: 'Lexical / Unicode integrity',
176
+ run(ctx) {
177
+ const findings = [];
178
+ const threshold = ctx.config.invisibleCharThreshold;
179
+ for (const field of collectTextFields(ctx.surface)) {
180
+ for (const hit of analyzeText(field.text)) {
181
+ const { severity, confidence } = severityFor(hit.family, hit.count, threshold);
182
+ const label = FAMILY_LABELS[hit.family];
183
+ const decodedNote = hit.decoded ? ` Decoded hidden payload: "${hit.decoded}".` : '';
184
+ findings.push({
185
+ ruleId: FAMILY_RULE[hit.family],
186
+ title: `Hidden ${label} in ${field.location.field ?? field.location.kind}`,
187
+ category: 'injection',
188
+ severity,
189
+ confidence,
190
+ description: `Found ${hit.count} ${label} character(s) in the ${field.location.field ?? 'text'} of ` +
191
+ `${field.location.kind}${field.location.name ? ` "${field.location.name}"` : ''}. ` +
192
+ `These are invisible to a human reviewer but are read verbatim by the model.${decodedNote}`,
193
+ remediation: 'Reject or normalize server metadata containing invisible/smuggling characters. ' +
194
+ 'Legitimate tool descriptions do not need them.',
195
+ location: field.location,
196
+ evidence: hit.decoded ?? `${hit.count}× ${hit.family}`,
197
+ owasp: 'LLM01:2025 Prompt Injection',
198
+ references: ['https://unicode.org/reports/tr39/'],
199
+ data: { family: hit.family, count: hit.count, decoded: hit.decoded },
200
+ });
201
+ }
202
+ // ANSI / CSI terminal escape sequences: hidden/overwritten text and color
203
+ // tricks enable consent phishing in a terminal client. No legitimate
204
+ // reason for them to be in server metadata.
205
+ if (ANSI_ESCAPE.test(field.text)) {
206
+ findings.push({
207
+ ruleId: 'MTC-UNI-010',
208
+ title: `ANSI terminal escape sequence in ${field.location.field ?? field.location.kind}`,
209
+ category: 'injection',
210
+ severity: 'high',
211
+ confidence: 'strong',
212
+ description: 'A terminal escape (ANSI/CSI/OSC) sequence is embedded in server metadata. In a terminal client these ' +
213
+ 'can move the cursor, hide, recolor, or overwrite text — so the action a user approves can differ from ' +
214
+ 'what is displayed (consent phishing).',
215
+ remediation: 'Strip control/escape sequences from server metadata before displaying it.',
216
+ location: field.location,
217
+ evidence: JSON.stringify(field.text.slice(0, 60)),
218
+ owasp: 'LLM01:2025 Prompt Injection',
219
+ });
220
+ }
221
+ // Mixed-script / homoglyph check (visible confusables).
222
+ const scripts = scriptsOf(field.text);
223
+ if (scripts.size > 1) {
224
+ findings.push({
225
+ ruleId: 'MTC-UNI-009',
226
+ title: `Mixed-script (homoglyph) text in ${field.location.field ?? field.location.kind}`,
227
+ category: 'injection',
228
+ severity: 'high',
229
+ confidence: 'strong',
230
+ description: `A single token mixes multiple scripts (${[...scripts].join(', ')}). This is the signature of a ` +
231
+ `homoglyph attack — visually identical characters from another script used to impersonate a ` +
232
+ `trusted name or slip past keyword filters.`,
233
+ remediation: 'Restrict metadata to a single script, or normalize confusables before display.',
234
+ location: field.location,
235
+ evidence: field.text.slice(0, 120),
236
+ owasp: 'LLM01:2025 Prompt Injection',
237
+ references: ['https://unicode.org/reports/tr39/'],
238
+ data: { scripts: [...scripts] },
239
+ });
240
+ }
241
+ }
242
+ return findings;
243
+ },
244
+ };
@@ -0,0 +1,27 @@
1
+ /*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
2
+ /**
3
+ * The scan engine — orchestrates the pipeline over a normalized surface:
4
+ * detectors → toxic-flow graph → integrity (TOFU) → filter → score → report.
5
+ * Pure and synchronous-in-spirit (detectors may be async), with no I/O of its
6
+ * own beyond what a caller passes in (the lockfile). That keeps it fully
7
+ * testable and deterministic.
8
+ */
9
+ import type { ScanReport, ServerSurface, McpTrustCheckerConfig } from './types.js';
10
+ import { type Lockfile } from './lockfile.js';
11
+ export interface ScanOptions {
12
+ config?: McpTrustCheckerConfig;
13
+ /**
14
+ * Pre-loaded lockfile for the integrity check. `undefined` skips integrity;
15
+ * `null` means "no pin yet" (first-seen). Load with `readLockfile`.
16
+ */
17
+ lockfile?: Lockfile | null;
18
+ /** ISO timestamp to stamp the report with. Omit for a reproducible report. */
19
+ scannedAt?: string;
20
+ /** Tool names from other servers in the same scan (cross-server collision). */
21
+ siblingTools?: {
22
+ server: string;
23
+ name: string;
24
+ }[];
25
+ }
26
+ /** Run the full pipeline over a normalized surface. */
27
+ export declare function scanSurface(rawSurface: ServerSurface, options?: ScanOptions): Promise<ScanReport>;
package/dist/engine.js ADDED
@@ -0,0 +1,135 @@
1
+ /*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
2
+ /**
3
+ * The scan engine — orchestrates the pipeline over a normalized surface:
4
+ * detectors → toxic-flow graph → integrity (TOFU) → filter → score → report.
5
+ * Pure and synchronous-in-spirit (detectors may be async), with no I/O of its
6
+ * own beyond what a caller passes in (the lockfile). That keeps it fully
7
+ * testable and deterministic.
8
+ */
9
+ import { resolveConfig } from './config.js';
10
+ import { extractCapabilities } from './util/capabilities.js';
11
+ import { DETECTORS } from './detectors/index.js';
12
+ import { analyzeToxicFlows } from './detectors/toxicFlow.js';
13
+ import { checkIntegrity } from './lockfile.js';
14
+ import { surfaceDigest } from './util/hash.js';
15
+ import { computeScore } from './scoring/index.js';
16
+ import { computeCapabilityProfile } from './scoring/capability.js';
17
+ import { METHODOLOGY_VERSION, TOOL_NAME, TOOL_VERSION } from './version.js';
18
+ const SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
19
+ const SEVERITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
20
+ function findingKey(f) {
21
+ // Include the title so distinct findings that share a rule+location but differ
22
+ // in detail (e.g. two different squatting dependencies of one package) are not
23
+ // collapsed by dedup. Title carries the discriminating name/target.
24
+ return [f.ruleId, f.location?.kind, f.location?.name, f.location?.field, f.title, f.evidence].join('|');
25
+ }
26
+ function isObject(v) {
27
+ return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
28
+ }
29
+ /**
30
+ * Defensively normalize a surface at the engine boundary so a hand-built
31
+ * (library-API) surface or a non-compliant live server can't crash any stage:
32
+ * tools/prompts/resources become arrays of objects, and the server object is
33
+ * always present. Field-level type quirks are handled by the individual stages.
34
+ */
35
+ function sanitizeSurface(s) {
36
+ const arr = (v) => (Array.isArray(v) ? v : []);
37
+ return {
38
+ ...s,
39
+ source: isObject(s.source) ? s.source : { kind: 'manifest', origin: '' },
40
+ server: isObject(s.server) ? s.server : {},
41
+ tools: arr(s.tools).filter(isObject),
42
+ prompts: arr(s.prompts).filter(isObject),
43
+ resources: arr(s.resources).filter(isObject),
44
+ };
45
+ }
46
+ /** Run the full pipeline over a normalized surface. */
47
+ export async function scanSurface(rawSurface, options = {}) {
48
+ const surface = sanitizeSurface(rawSurface);
49
+ const config = resolveConfig(options.config);
50
+ const capabilities = extractCapabilities(surface, config);
51
+ const ctx = { surface, config, capabilities, siblingTools: options.siblingTools ?? [] };
52
+ const raw = [];
53
+ for (const detector of DETECTORS) {
54
+ if (config.disabledRules.includes(detector.id))
55
+ continue;
56
+ raw.push(...(await detector.run(ctx)));
57
+ }
58
+ const { flows, findings: flowFindings } = analyzeToxicFlows(ctx);
59
+ raw.push(...flowFindings);
60
+ // Integrity (rug-pull) check, if a lockfile context was provided.
61
+ let integrity;
62
+ if (options.lockfile !== undefined) {
63
+ integrity = checkIntegrity(surface, options.lockfile);
64
+ if (integrity.status === 'drift') {
65
+ raw.push({
66
+ ruleId: 'MTC-TOFU-001',
67
+ title: 'Server surface changed since it was pinned (possible rug pull)',
68
+ category: 'supply-chain',
69
+ severity: 'high',
70
+ confidence: 'confirmed',
71
+ description: `The canonical fingerprint of this server no longer matches its pinned value in the lockfile. ` +
72
+ `Tool definitions can change silently after you approve them (a rug pull); review every change before ` +
73
+ `trusting it again.\n` +
74
+ (integrity.changes ?? []).map((c) => ` • ${c.detail}`).join('\n'),
75
+ remediation: 'Review the diff; re-pin only after confirming the changes are legitimate (`mcptrustchecker pin`).',
76
+ location: { kind: 'server' },
77
+ owasp: 'LLM03:2025 Supply Chain',
78
+ evidence: `${integrity.changes?.length ?? 0} change(s) since pin`,
79
+ data: { changes: integrity.changes },
80
+ });
81
+ }
82
+ }
83
+ // Filter: disabled rules, whole-rule allowlist waivers, and location-scoped
84
+ // baseline suppressions (rule + optional tool/field, with a reason).
85
+ const disabled = new Set(config.disabledRules);
86
+ const allowed = new Set(config.allowlist);
87
+ const suppressions = config.suppress ?? [];
88
+ const isSuppressed = (f) => suppressions.some((s) => s.rule === f.ruleId &&
89
+ (s.tool === undefined || s.tool === f.location?.name) &&
90
+ (s.field === undefined || s.field === f.location?.field));
91
+ const seen = new Set();
92
+ const findings = [];
93
+ for (const f of raw) {
94
+ if (disabled.has(f.ruleId) || allowed.has(f.ruleId) || isSuppressed(f))
95
+ continue;
96
+ const key = findingKey(f);
97
+ if (seen.has(key))
98
+ continue;
99
+ seen.add(key);
100
+ findings.push(f);
101
+ }
102
+ findings.sort((a, b) => {
103
+ if (SEVERITY_ORDER[a.severity] !== SEVERITY_ORDER[b.severity])
104
+ return SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
105
+ if (a.category !== b.category)
106
+ return a.category < b.category ? -1 : 1;
107
+ return a.ruleId < b.ruleId ? -1 : a.ruleId > b.ruleId ? 1 : 0;
108
+ });
109
+ const score = computeScore(findings);
110
+ const findingsBySeverity = Object.fromEntries(SEVERITIES.map((s) => [s, 0]));
111
+ for (const f of findings)
112
+ findingsBySeverity[f.severity] += 1;
113
+ return {
114
+ tool: { name: TOOL_NAME, version: TOOL_VERSION, methodologyVersion: METHODOLOGY_VERSION },
115
+ target: {
116
+ id: surface.id,
117
+ source: surface.source,
118
+ server: { name: surface.server.name, version: surface.server.version },
119
+ },
120
+ ...(options.scannedAt ? { scannedAt: options.scannedAt } : {}),
121
+ findings,
122
+ score,
123
+ capabilities,
124
+ capabilityProfile: computeCapabilityProfile(capabilities, flows),
125
+ toxicFlows: flows,
126
+ integrity,
127
+ surfaceDigest: surfaceDigest(surface),
128
+ stats: {
129
+ tools: surface.tools.length,
130
+ prompts: surface.prompts.length,
131
+ resources: surface.resources.length,
132
+ findingsBySeverity,
133
+ },
134
+ };
135
+ }
@@ -0,0 +1,38 @@
1
+ /*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
2
+ /**
3
+ * MCP Trust Checker public API.
4
+ *
5
+ * The same engine that powers the CLI is exported here so a marketplace or CI
6
+ * service can embed it directly:
7
+ *
8
+ * import { scanSurface, surfaceFromManifest } from 'mcptrustchecker';
9
+ * const surface = surfaceFromManifest(toolsJson, 'my-server');
10
+ * const report = await scanSurface(surface);
11
+ * console.log(report.score.grade, report.score.score);
12
+ *
13
+ * Everything in `scanSurface` is deterministic and offline: same methodology
14
+ * version + same surface ⇒ identical score.
15
+ */
16
+ export * from './types.js';
17
+ export { METHODOLOGY_VERSION, TOOL_VERSION, TOOL_NAME, UNICODE_DATA_VERSION } from './version.js';
18
+ export { scanSurface, type ScanOptions } from './engine.js';
19
+ export { computeScore } from './scoring/index.js';
20
+ export * as scoringModel from './scoring/model.js';
21
+ export { resolveConfig, loadConfigFile, loadIgnoreFile, DEFAULT_CONFIG } from './config.js';
22
+ export { evaluatePolicy, type PolicyViolation } from './policy.js';
23
+ export { resolveTargets, type ResolveOptions, type ResolvedTarget } from './acquire/index.js';
24
+ export { surfaceFromManifest } from './acquire/manifest.js';
25
+ export { surfaceFromPackageDir, readSourceFiles } from './acquire/source.js';
26
+ export { acquireStdio, acquireHttp, ALLOWED_COMMANDS, type StdioSpec, type LiveOptions } from './acquire/live.js';
27
+ export { parseClientConfig, isClientConfig, packageFromStdio } from './acquire/clientConfig.js';
28
+ export { fetchNpmMeta, fetchPypiMeta } from './acquire/npm.js';
29
+ export { extractCapabilities, extractToolCapability } from './util/capabilities.js';
30
+ export { analyzeToxicFlows } from './detectors/toxicFlow.js';
31
+ export { readLockfile, writeLockfile, pinSurface, checkIntegrity, emptyLockfile, type Lockfile, type LockEntry, } from './lockfile.js';
32
+ export { surfaceDigest, canonicalSurface } from './util/hash.js';
33
+ export { renderTerminal } from './report/terminal.js';
34
+ export { renderJson } from './report/json.js';
35
+ export { renderSarif } from './report/sarif.js';
36
+ export { renderMarkdown } from './report/markdown.js';
37
+ export { renderBadge } from './report/badge.js';
38
+ export { DETECTORS } from './detectors/index.js';
package/dist/index.js ADDED
@@ -0,0 +1,46 @@
1
+ /*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
2
+ /**
3
+ * MCP Trust Checker public API.
4
+ *
5
+ * The same engine that powers the CLI is exported here so a marketplace or CI
6
+ * service can embed it directly:
7
+ *
8
+ * import { scanSurface, surfaceFromManifest } from 'mcptrustchecker';
9
+ * const surface = surfaceFromManifest(toolsJson, 'my-server');
10
+ * const report = await scanSurface(surface);
11
+ * console.log(report.score.grade, report.score.score);
12
+ *
13
+ * Everything in `scanSurface` is deterministic and offline: same methodology
14
+ * version + same surface ⇒ identical score.
15
+ */
16
+ export * from './types.js';
17
+ export { METHODOLOGY_VERSION, TOOL_VERSION, TOOL_NAME, UNICODE_DATA_VERSION } from './version.js';
18
+ // Core engine
19
+ export { scanSurface } from './engine.js';
20
+ export { computeScore } from './scoring/index.js';
21
+ export * as scoringModel from './scoring/model.js';
22
+ // Config
23
+ export { resolveConfig, loadConfigFile, loadIgnoreFile, DEFAULT_CONFIG } from './config.js';
24
+ export { evaluatePolicy } from './policy.js';
25
+ // Acquisition
26
+ export { resolveTargets } from './acquire/index.js';
27
+ export { surfaceFromManifest } from './acquire/manifest.js';
28
+ export { surfaceFromPackageDir, readSourceFiles } from './acquire/source.js';
29
+ export { acquireStdio, acquireHttp, ALLOWED_COMMANDS } from './acquire/live.js';
30
+ export { parseClientConfig, isClientConfig, packageFromStdio } from './acquire/clientConfig.js';
31
+ export { fetchNpmMeta, fetchPypiMeta } from './acquire/npm.js';
32
+ // Capabilities & flows (useful for custom analysis / UIs)
33
+ export { extractCapabilities, extractToolCapability } from './util/capabilities.js';
34
+ export { analyzeToxicFlows } from './detectors/toxicFlow.js';
35
+ // Integrity / lockfile
36
+ export { readLockfile, writeLockfile, pinSurface, checkIntegrity, emptyLockfile, } from './lockfile.js';
37
+ // Hashing (rug-pull fingerprint)
38
+ export { surfaceDigest, canonicalSurface } from './util/hash.js';
39
+ // Reporters
40
+ export { renderTerminal } from './report/terminal.js';
41
+ export { renderJson } from './report/json.js';
42
+ export { renderSarif } from './report/sarif.js';
43
+ export { renderMarkdown } from './report/markdown.js';
44
+ export { renderBadge } from './report/badge.js';
45
+ // Detectors (for extension / introspection)
46
+ export { DETECTORS } from './detectors/index.js';
@@ -0,0 +1,30 @@
1
+ /*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
2
+ /**
3
+ * Rug-pull / Trust-On-First-Use (TOFU) integrity lockfile.
4
+ *
5
+ * On first scan we pin the canonical fingerprint of each server's tool surface.
6
+ * On every rescan we diff against the pin: any silent change to a tool's
7
+ * description or schema (the MCPoison rug-pull) shows up as drift and must be
8
+ * re-approved. Users are encouraged to commit `mcptrustchecker.lock` to git.
9
+ */
10
+ import type { IntegrityResult, ServerSurface } from './types.js';
11
+ export interface LockEntry {
12
+ digest: string;
13
+ tools: Record<string, string>;
14
+ instructionsDigest: string;
15
+ pinnedAt?: string;
16
+ }
17
+ export interface Lockfile {
18
+ lockfileVersion: 1;
19
+ methodologyVersion: string;
20
+ servers: Record<string, LockEntry>;
21
+ }
22
+ export declare function emptyLockfile(): Lockfile;
23
+ export declare function readLockfile(path: string): Lockfile | null;
24
+ export declare function writeLockfile(path: string, lock: Lockfile): void;
25
+ /** Build a lock entry from the current surface. */
26
+ export declare function entryFor(surface: ServerSurface, pinnedAt?: string): LockEntry;
27
+ /** Add/update this surface's pin in the lockfile (returns a new object). */
28
+ export declare function pinSurface(lock: Lockfile, surface: ServerSurface, pinnedAt?: string): Lockfile;
29
+ /** Compare the current surface against its pinned entry. */
30
+ export declare function checkIntegrity(surface: ServerSurface, lock: Lockfile | null): IntegrityResult;
@@ -0,0 +1,87 @@
1
+ /*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
2
+ /**
3
+ * Rug-pull / Trust-On-First-Use (TOFU) integrity lockfile.
4
+ *
5
+ * On first scan we pin the canonical fingerprint of each server's tool surface.
6
+ * On every rescan we diff against the pin: any silent change to a tool's
7
+ * description or schema (the MCPoison rug-pull) shows up as drift and must be
8
+ * re-approved. Users are encouraged to commit `mcptrustchecker.lock` to git.
9
+ */
10
+ import { createHash } from 'node:crypto';
11
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
12
+ import { canonicalTool, surfaceDigest, toolDigest } from './util/hash.js';
13
+ import { METHODOLOGY_VERSION } from './version.js';
14
+ function hashString(s) {
15
+ return createHash('sha256').update(s).digest('hex');
16
+ }
17
+ export function emptyLockfile() {
18
+ return { lockfileVersion: 1, methodologyVersion: METHODOLOGY_VERSION, servers: {} };
19
+ }
20
+ export function readLockfile(path) {
21
+ if (!existsSync(path))
22
+ return null;
23
+ try {
24
+ return JSON.parse(readFileSync(path, 'utf8'));
25
+ }
26
+ catch (err) {
27
+ throw new Error(`Failed to parse lockfile ${path}: ${err.message}`);
28
+ }
29
+ }
30
+ export function writeLockfile(path, lock) {
31
+ writeFileSync(path, JSON.stringify(lock, null, 2) + '\n', 'utf8');
32
+ }
33
+ /** Build a lock entry from the current surface. */
34
+ export function entryFor(surface, pinnedAt) {
35
+ const tools = {};
36
+ for (const t of surface.tools)
37
+ tools[t.name] = toolDigest(t);
38
+ return {
39
+ digest: surfaceDigest(surface),
40
+ tools,
41
+ instructionsDigest: hashString(surface.server.instructions ?? ''),
42
+ ...(pinnedAt ? { pinnedAt } : {}),
43
+ };
44
+ }
45
+ /** Add/update this surface's pin in the lockfile (returns a new object). */
46
+ export function pinSurface(lock, surface, pinnedAt) {
47
+ return {
48
+ ...lock,
49
+ methodologyVersion: METHODOLOGY_VERSION,
50
+ servers: { ...lock.servers, [surface.id]: entryFor(surface, pinnedAt) },
51
+ };
52
+ }
53
+ /** Compare the current surface against its pinned entry. */
54
+ export function checkIntegrity(surface, lock) {
55
+ const currentDigest = surfaceDigest(surface);
56
+ const entry = lock?.servers[surface.id];
57
+ if (!entry)
58
+ return { status: 'first-seen', currentDigest };
59
+ if (entry.digest === currentDigest)
60
+ return { status: 'unchanged', currentDigest, previousDigest: entry.digest };
61
+ const changes = [];
62
+ const currentTools = {};
63
+ for (const t of surface.tools)
64
+ currentTools[t.name] = toolDigest(t);
65
+ for (const name of Object.keys(currentTools)) {
66
+ if (!(name in entry.tools)) {
67
+ changes.push({ kind: 'tool-added', name, detail: `Tool "${name}" is new since the pin.` });
68
+ }
69
+ else if (entry.tools[name] !== currentTools[name]) {
70
+ const tool = surface.tools.find((t) => t.name === name);
71
+ changes.push({
72
+ kind: 'tool-changed',
73
+ name,
74
+ detail: `Tool "${name}" changed after approval (description/schema drift): ${JSON.stringify(canonicalTool(tool)).slice(0, 200)}…`,
75
+ });
76
+ }
77
+ }
78
+ for (const name of Object.keys(entry.tools)) {
79
+ if (!(name in currentTools)) {
80
+ changes.push({ kind: 'tool-removed', name, detail: `Tool "${name}" was removed since the pin.` });
81
+ }
82
+ }
83
+ if (entry.instructionsDigest !== hashString(surface.server.instructions ?? '')) {
84
+ changes.push({ kind: 'instructions-changed', detail: 'Server instructions changed since the pin.' });
85
+ }
86
+ return { status: 'drift', currentDigest, previousDigest: entry.digest, changes };
87
+ }
@@ -0,0 +1,13 @@
1
+ /*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
2
+ /**
3
+ * Policy-as-code. A team declares what "acceptable" means once (in
4
+ * `mcptrustchecker.config.json` under `policy`) and every scan is gated against
5
+ * it in CI — beyond a single grade/score threshold. Pure and deterministic.
6
+ */
7
+ import type { Policy, ScanReport } from './types.js';
8
+ export interface PolicyViolation {
9
+ policy: 'minGrade' | 'maxCapability' | 'denyRules' | 'denyCapabilities';
10
+ message: string;
11
+ }
12
+ /** Evaluate a report against a policy; empty array means it passes. */
13
+ export declare function evaluatePolicy(report: ScanReport, policy: Policy | undefined): PolicyViolation[];