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,24 @@
1
+ /*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
2
+ /**
3
+ * Target resolution — turn a CLI target (path / URL / package / config) plus
4
+ * options into one or more normalized surfaces, choosing the safest acquisition
5
+ * path. Live process spawning from a client config is opt-in (`--run`).
6
+ */
7
+ import type { ServerSurface } from '../types.js';
8
+ import { type LiveOptions } from './live.js';
9
+ export interface ResolveOptions extends LiveOptions {
10
+ command?: string;
11
+ args?: string[];
12
+ url?: string;
13
+ online?: boolean;
14
+ run?: boolean;
15
+ registry?: 'npm' | 'pypi';
16
+ /** Environment variables to pass to a `--command` stdio server. */
17
+ envVars?: Record<string, string>;
18
+ }
19
+ export interface ResolvedTarget {
20
+ label: string;
21
+ surface: ServerSurface;
22
+ }
23
+ /** Resolve a target into scannable surfaces. */
24
+ export declare function resolveTargets(target: string | undefined, opts?: ResolveOptions): Promise<ResolvedTarget[]>;
@@ -0,0 +1,175 @@
1
+ /*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
2
+ /**
3
+ * Target resolution — turn a CLI target (path / URL / package / config) plus
4
+ * options into one or more normalized surfaces, choosing the safest acquisition
5
+ * path. Live process spawning from a client config is opt-in (`--run`).
6
+ */
7
+ import { existsSync, readFileSync, statSync } from 'node:fs';
8
+ import { surfaceFromManifest } from './manifest.js';
9
+ import { surfaceFromPackageDir } from './source.js';
10
+ import { acquireHttp, acquireStdio, ALLOWED_COMMANDS } from './live.js';
11
+ import { expandVars, isClientConfig, packageSpecFromStdio, parseClientConfig, redactSensitiveArgs, } from './clientConfig.js';
12
+ import { fetchNpmMeta, fetchPypiMeta } from './npm.js';
13
+ const NPM_NAME = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i;
14
+ const HTTP_RE = /^https?:\/\//i;
15
+ function commandBase(command) {
16
+ const cleaned = command.replace(/\\/g, '/');
17
+ return cleaned.slice(cleaned.lastIndexOf('/') + 1);
18
+ }
19
+ async function metaFor(name, opts) {
20
+ if (!opts.online)
21
+ return { registry: opts.registry ?? 'npm', name };
22
+ return opts.registry === 'pypi' ? fetchPypiMeta(name) : fetchNpmMeta(name);
23
+ }
24
+ function packageSurface(name, meta, origin) {
25
+ return {
26
+ id: name,
27
+ source: { kind: 'package', origin },
28
+ server: { name },
29
+ tools: [],
30
+ prompts: [],
31
+ resources: [],
32
+ packageMeta: meta,
33
+ };
34
+ }
35
+ /** Strip the query string from a stored URL so config-embedded tokens don't leak. */
36
+ function redactUrl(url) {
37
+ if (!url)
38
+ return url;
39
+ try {
40
+ const u = new URL(url);
41
+ if (u.search)
42
+ u.search = ''; // query-string tokens
43
+ u.username = ''; // userinfo credentials (user:pass@host)
44
+ u.password = '';
45
+ return u.href;
46
+ }
47
+ catch {
48
+ return url;
49
+ }
50
+ }
51
+ /** Build the static (no-spawn) surface for one client-config server entry. */
52
+ function staticConfigSurface(spec, origin) {
53
+ const transport = spec.transport === 'http'
54
+ ? { kind: 'http', url: redactUrl(spec.url) }
55
+ : {
56
+ kind: 'stdio',
57
+ command: spec.command,
58
+ args: redactSensitiveArgs(spec.args ?? []),
59
+ // A path-qualified command OR one outside the allowlist is untrusted.
60
+ userControlledCommand: spec.command
61
+ ? /[\\/]/.test(spec.command) || !ALLOWED_COMMANDS.has(commandBase(spec.command))
62
+ : false,
63
+ };
64
+ return {
65
+ id: spec.id,
66
+ source: { kind: 'client-config', origin },
67
+ server: { name: spec.id },
68
+ tools: [],
69
+ prompts: [],
70
+ resources: [],
71
+ transport,
72
+ };
73
+ }
74
+ /** Resolve a target into scannable surfaces. */
75
+ export async function resolveTargets(target, opts = {}) {
76
+ // 1) Explicit live specs win.
77
+ if (opts.command) {
78
+ const surface = await acquireStdio({ command: opts.command, args: opts.args, env: opts.envVars }, opts);
79
+ return [{ label: surface.id, surface }];
80
+ }
81
+ if (opts.url || (target && HTTP_RE.test(target))) {
82
+ const url = opts.url ?? target;
83
+ const surface = await acquireHttp(url, opts);
84
+ return [{ label: url, surface }];
85
+ }
86
+ if (!target)
87
+ throw new Error('No scan target provided.');
88
+ // A scoped npm/PyPI package (@scope/name) contains '/' but is NOT a path.
89
+ const isScopedPkg = /^@[a-z0-9-~][a-z0-9-._~]*\/[a-z0-9-~][a-z0-9-._~]*$/i.test(target);
90
+ const looksLikePath = !isScopedPkg && (target.includes('/') || target.includes('\\') || target.toLowerCase().endsWith('.json'));
91
+ // A path/manifest target that doesn't exist: say so plainly.
92
+ if (looksLikePath && !existsSync(target)) {
93
+ throw new Error(`No such file: ${target}`);
94
+ }
95
+ // A local package DIRECTORY → analyze its implementation source + metadata.
96
+ if (existsSync(target) && statSync(target).isDirectory()) {
97
+ return [{ label: target, surface: surfaceFromPackageDir(target) }];
98
+ }
99
+ // 2) A file on disk: manifest or client config.
100
+ if (existsSync(target) && target.toLowerCase().endsWith('.json')) {
101
+ let json;
102
+ try {
103
+ json = JSON.parse(readFileSync(target, 'utf8'));
104
+ }
105
+ catch (err) {
106
+ throw new Error(`Invalid JSON in ${target}: ${err.message}`);
107
+ }
108
+ if (isClientConfig(json)) {
109
+ const specs = parseClientConfig(json);
110
+ const results = [];
111
+ for (const spec of specs) {
112
+ if (opts.run) {
113
+ // URLs/commands come from an UNTRUSTED config. Expand ${VAR} ONLY from
114
+ // the entry's own env block — NEVER from the scanner's process.env,
115
+ // which would leak operator secrets into an outbound request.
116
+ const varEnv = { ...(spec.env ?? {}) };
117
+ try {
118
+ const surface = spec.transport === 'http'
119
+ ? await acquireHttp(expandVars(spec.url, varEnv), { ...opts, blockPrivateHosts: opts.blockPrivateHosts ?? true })
120
+ : await acquireStdio({
121
+ command: expandVars(spec.command, varEnv),
122
+ args: (spec.args ?? []).map((a) => expandVars(a, varEnv)),
123
+ env: spec.env,
124
+ }, opts);
125
+ // Use the config's server key as the stable id (not the raw command,
126
+ // which may embed a token), and scrub any secrets from stored args.
127
+ surface.id = spec.id;
128
+ surface.source = { kind: 'client-config', origin: target };
129
+ if (surface.transport?.args)
130
+ surface.transport.args = redactSensitiveArgs(surface.transport.args);
131
+ // The expanded URL can carry a ${TOKEN} in its query/userinfo — redact
132
+ // it before it is stored in the report (matches the static path).
133
+ if (surface.transport?.url)
134
+ surface.transport.url = redactUrl(surface.transport.url) ?? surface.transport.url;
135
+ results.push({ label: spec.id, surface });
136
+ }
137
+ catch {
138
+ // One dead/unreachable server must not abort the whole config scan.
139
+ results.push({ label: spec.id, surface: staticConfigSurface(spec, target) });
140
+ }
141
+ continue;
142
+ }
143
+ // Static: posture + supply-chain only, no process spawned.
144
+ const surface = staticConfigSurface(spec, target);
145
+ if (spec.transport === 'stdio' && spec.command) {
146
+ const pkgSpec = packageSpecFromStdio(spec.command, spec.args ?? []);
147
+ // Only fetch metadata for a well-formed package name (config values are untrusted).
148
+ if (pkgSpec && NPM_NAME.test(pkgSpec.name)) {
149
+ const meta = await metaFor(pkgSpec.name, opts);
150
+ meta.requestedSpec = pkgSpec.version;
151
+ meta.pinned = Boolean(pkgSpec.version && pkgSpec.version !== 'latest' && /^[0-9]/.test(pkgSpec.version));
152
+ surface.packageMeta = meta;
153
+ }
154
+ }
155
+ results.push({ label: spec.id, surface });
156
+ }
157
+ if (results.length === 0)
158
+ throw new Error(`No MCP servers found in config: ${target}`);
159
+ return results;
160
+ }
161
+ // Plain tools manifest — must be a JSON object, not an array/null/scalar.
162
+ if (!json || typeof json !== 'object' || Array.isArray(json)) {
163
+ throw new Error(`${target} is not a recognizable MCP manifest (expected a JSON object with a "tools" array or an "mcpServers" map).`);
164
+ }
165
+ const surface = surfaceFromManifest(json, target);
166
+ return [{ label: surface.id, surface }];
167
+ }
168
+ // 3) A bare package name → supply-chain / provenance scan.
169
+ if (NPM_NAME.test(target)) {
170
+ const meta = await metaFor(target, opts);
171
+ return [{ label: target, surface: packageSurface(target, meta, target) }];
172
+ }
173
+ throw new Error(`Could not resolve target "${target}". Provide a tools.json manifest, an http(s) URL, ` +
174
+ `a client config, a package name, or use --command for stdio.`);
175
+ }
@@ -0,0 +1,47 @@
1
+ /*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
2
+ /**
3
+ * Live acquisition over the wire using the official MCP SDK.
4
+ *
5
+ * SECURITY: scanning is itself an attack surface. stdio acquisition spawns a
6
+ * child process, so we:
7
+ * - allowlist the executable by BARE NAME only (a path like `/tmp/node`
8
+ * is rejected — a basename must not authorize an attacker-chosen path);
9
+ * - pass a scrubbed environment: the parent env is not inherited by us, and
10
+ * any caller/config-supplied env has execution-hijacking variables
11
+ * (NODE_OPTIONS, LD_PRELOAD, DYLD_*, PYTHON*, …) stripped, so an allowlisted
12
+ * runtime can't be redirected; (the SDK still injects a small sudo-style
13
+ * safe subset — HOME/PATH/SHELL/TERM/USER — via getDefaultEnvironment());
14
+ * - capture stderr and enforce aggressive timeouts.
15
+ * HTTP acquisition validates the URL scheme, and — for config-derived URLs —
16
+ * blocks private/loopback/link-local hosts to prevent SSRF.
17
+ *
18
+ * The SDK is imported dynamically so the pure engine and its tests never need
19
+ * it loaded; live scanning requires `@modelcontextprotocol/sdk` at runtime.
20
+ */
21
+ import type { ServerSurface } from '../types.js';
22
+ /** Executables permitted for stdio acquisition (the canonical safe set). */
23
+ export declare const ALLOWED_COMMANDS: Set<string>;
24
+ /** Drop execution-hijacking vars and bash function definitions from an env map. */
25
+ export declare function sanitizeEnv(env: Record<string, string>): {
26
+ clean: Record<string, string>;
27
+ dropped: string[];
28
+ };
29
+ export declare function isBlockedHost(hostname: string): boolean;
30
+ export interface StdioSpec {
31
+ command: string;
32
+ args?: string[];
33
+ env?: Record<string, string>;
34
+ cwd?: string;
35
+ }
36
+ export interface LiveOptions {
37
+ /** Allow spawning a command outside the allowlist (opt-in, dangerous). */
38
+ allowAnyCommand?: boolean;
39
+ /** Restrict HTTP acquisition to these hostnames. */
40
+ allowedHosts?: string[];
41
+ /** Block private/loopback/link-local hosts (set for untrusted, config-derived URLs). */
42
+ blockPrivateHosts?: boolean;
43
+ }
44
+ /** Acquire a surface by spawning a local stdio MCP server. */
45
+ export declare function acquireStdio(spec: StdioSpec, opts?: LiveOptions): Promise<ServerSurface>;
46
+ /** Acquire a surface from a Streamable-HTTP / SSE endpoint. */
47
+ export declare function acquireHttp(url: string, opts?: LiveOptions): Promise<ServerSurface>;
@@ -0,0 +1,357 @@
1
+ /*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
2
+ /**
3
+ * Live acquisition over the wire using the official MCP SDK.
4
+ *
5
+ * SECURITY: scanning is itself an attack surface. stdio acquisition spawns a
6
+ * child process, so we:
7
+ * - allowlist the executable by BARE NAME only (a path like `/tmp/node`
8
+ * is rejected — a basename must not authorize an attacker-chosen path);
9
+ * - pass a scrubbed environment: the parent env is not inherited by us, and
10
+ * any caller/config-supplied env has execution-hijacking variables
11
+ * (NODE_OPTIONS, LD_PRELOAD, DYLD_*, PYTHON*, …) stripped, so an allowlisted
12
+ * runtime can't be redirected; (the SDK still injects a small sudo-style
13
+ * safe subset — HOME/PATH/SHELL/TERM/USER — via getDefaultEnvironment());
14
+ * - capture stderr and enforce aggressive timeouts.
15
+ * HTTP acquisition validates the URL scheme, and — for config-derived URLs —
16
+ * blocks private/loopback/link-local hosts to prevent SSRF.
17
+ *
18
+ * The SDK is imported dynamically so the pure engine and its tests never need
19
+ * it loaded; live scanning requires `@modelcontextprotocol/sdk` at runtime.
20
+ */
21
+ import { surfaceFromManifest } from './manifest.js';
22
+ /** Executables permitted for stdio acquisition (the canonical safe set). */
23
+ export const ALLOWED_COMMANDS = new Set(['npx', 'uvx', 'python', 'python3', 'node', 'docker', 'deno']);
24
+ /**
25
+ * Environment variables that can hijack an otherwise-trusted runtime. Stripped
26
+ * from any caller/config-supplied env before spawning, so an allowlisted
27
+ * `node`/`python` can't be told to load attacker code at startup.
28
+ */
29
+ const DANGEROUS_ENV_EXACT = new Set([
30
+ 'PATH', // a config-supplied PATH would redirect an allowlisted `node`/`python` to an attacker binary
31
+ 'NODE_PATH', // hijacks bare-import resolution for `node`
32
+ 'NODE_OPTIONS',
33
+ 'NODE_REPL_EXTERNAL_MODULE',
34
+ 'PYTHONSTARTUP',
35
+ 'PYTHONPATH',
36
+ 'PYTHONHOME',
37
+ 'PERL5OPT',
38
+ 'RUBYOPT',
39
+ 'BASH_ENV',
40
+ 'ENV',
41
+ 'GIT_SSH_COMMAND',
42
+ 'GIT_EXTERNAL_DIFF',
43
+ // Package-index redirects — a config env could point `npx`/`uvx`/`pip` at an
44
+ // attacker-controlled registry (supply-chain redirect). Defense-in-depth.
45
+ 'NPM_CONFIG_REGISTRY',
46
+ 'PIP_INDEX_URL',
47
+ 'PIP_EXTRA_INDEX_URL',
48
+ 'UV_INDEX_URL',
49
+ 'UV_DEFAULT_INDEX',
50
+ ]);
51
+ const DANGEROUS_ENV_PREFIX = ['LD_', 'DYLD_', 'NPM_CONFIG_'];
52
+ /** Drop execution-hijacking vars and bash function definitions from an env map. */
53
+ export function sanitizeEnv(env) {
54
+ const clean = {};
55
+ const dropped = [];
56
+ for (const [k, v] of Object.entries(env)) {
57
+ const key = k.toUpperCase();
58
+ const bad = DANGEROUS_ENV_EXACT.has(key) ||
59
+ DANGEROUS_ENV_PREFIX.some((p) => key.startsWith(p)) ||
60
+ (typeof v === 'string' && v.startsWith('()'));
61
+ if (bad)
62
+ dropped.push(k);
63
+ else
64
+ clean[k] = v;
65
+ }
66
+ return { clean, dropped };
67
+ }
68
+ /** True for a private/loopback/link-local IPv4 dotted-quad. */
69
+ function isPrivateIpv4(h) {
70
+ const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
71
+ if (!m)
72
+ return false;
73
+ const a = Number(m[1]);
74
+ const b = Number(m[2]);
75
+ return (a === 127 || a === 10 || a === 0 ||
76
+ (a === 192 && b === 168) ||
77
+ (a === 172 && b >= 16 && b <= 31) ||
78
+ (a === 169 && b === 254) // link-local incl. cloud metadata 169.254.169.254
79
+ );
80
+ }
81
+ /**
82
+ * Block loopback / private / link-local hosts (SSRF guard for config-derived
83
+ * URLs). IPv6 checks apply only to actual IPv6 literals (so DNS names like
84
+ * "fc-api.com" aren't blocked), and IPv4-mapped IPv6 (::ffff:127.0.0.1) is
85
+ * unwrapped. NOTE: this is string-based; a public DNS name that *resolves* to a
86
+ * private IP (DNS rebinding) is not caught here — that is inherent to pre-connect
87
+ * static checks and is documented as out of scope.
88
+ */
89
+ /**
90
+ * Extract the embedded IPv4 of an IPv4-mapped IPv6 literal. Node's URL parser
91
+ * normalizes `::ffff:169.254.169.254` to the HEX form `::ffff:a9fe:a9fe`, so we
92
+ * must accept BOTH the dotted and the hextet forms — the previous dotted-only
93
+ * regex was dead code against a real `URL.hostname`, leaving cloud-metadata
94
+ * (169.254.169.254) reachable through the SSRF guard.
95
+ */
96
+ function mappedIpv4(h) {
97
+ const dotted = h.match(/:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
98
+ if (dotted)
99
+ return dotted[1];
100
+ const hex = h.match(/::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
101
+ if (hex) {
102
+ const hi = parseInt(hex[1], 16);
103
+ const lo = parseInt(hex[2], 16);
104
+ return `${(hi >> 8) & 255}.${hi & 255}.${(lo >> 8) & 255}.${lo & 255}`;
105
+ }
106
+ return undefined;
107
+ }
108
+ export function isBlockedHost(hostname) {
109
+ // Strip brackets and a trailing FQDN dot (`localhost.` resolves like `localhost`).
110
+ const h = hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
111
+ if (h === 'localhost' || h.endsWith('.localhost'))
112
+ return true;
113
+ if (isPrivateIpv4(h))
114
+ return true;
115
+ // IPv6 literal (contains a colon)
116
+ if (h.includes(':')) {
117
+ if (h === '::1' || h === '::')
118
+ return true;
119
+ if (h.startsWith('fe80:') || h.startsWith('fc') || h.startsWith('fd'))
120
+ return true; // link-local / ULA
121
+ const embedded = mappedIpv4(h);
122
+ if (embedded && isPrivateIpv4(embedded))
123
+ return true; // IPv4-mapped IPv6 (dotted OR hextet form)
124
+ }
125
+ return false;
126
+ }
127
+ const REQUEST_OPTS = { timeout: 15_000, maxTotalTimeout: 30_000, resetTimeoutOnProgress: false };
128
+ const CONNECT_TIMEOUT_MS = 20_000;
129
+ const MAX_ITEMS = 1000;
130
+ const MAX_PAGES = 50;
131
+ function withTimeout(p, ms, label) {
132
+ return new Promise((resolve, reject) => {
133
+ const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
134
+ p.then((v) => {
135
+ clearTimeout(timer);
136
+ resolve(v);
137
+ }, (e) => {
138
+ clearTimeout(timer);
139
+ reject(e);
140
+ });
141
+ });
142
+ }
143
+ /* eslint-disable @typescript-eslint/no-explicit-any */
144
+ async function loadSdk() {
145
+ try {
146
+ const [{ Client }, stdio, http, sse] = await Promise.all([
147
+ import('@modelcontextprotocol/sdk/client/index.js'),
148
+ import('@modelcontextprotocol/sdk/client/stdio.js'),
149
+ import('@modelcontextprotocol/sdk/client/streamableHttp.js'),
150
+ import('@modelcontextprotocol/sdk/client/sse.js'),
151
+ ]);
152
+ return {
153
+ Client,
154
+ StdioClientTransport: stdio.StdioClientTransport,
155
+ StreamableHTTPClientTransport: http.StreamableHTTPClientTransport,
156
+ SSEClientTransport: sse.SSEClientTransport,
157
+ };
158
+ }
159
+ catch (err) {
160
+ throw new Error('Live scanning requires the "@modelcontextprotocol/sdk" package. ' +
161
+ `Install it (npm i @modelcontextprotocol/sdk). Underlying error: ${err.message}`);
162
+ }
163
+ }
164
+ // Permissive result schemas so a single non-spec-compliant tool/prompt/resource
165
+ // object does not make the SDK reject the WHOLE list (real servers are often
166
+ // slightly non-compliant — scanning them is exactly the point). Built once from
167
+ // the zod that ships with the SDK; falls back to the strict high-level calls.
168
+ let looseSchemas;
169
+ async function getLooseSchemas() {
170
+ if (looseSchemas !== undefined)
171
+ return looseSchemas ?? null;
172
+ try {
173
+ const zod = await import('zod');
174
+ const z = zod.z ?? zod.default ?? zod;
175
+ const mk = (key) => z.object({ [key]: z.array(z.any()).optional(), nextCursor: z.string().optional() }).passthrough();
176
+ looseSchemas = { tools: mk('tools'), prompts: mk('prompts'), resources: mk('resources') };
177
+ }
178
+ catch {
179
+ looseSchemas = null;
180
+ }
181
+ return looseSchemas;
182
+ }
183
+ async function listLoose(client, method, key, cursor) {
184
+ const schemas = await getLooseSchemas();
185
+ const params = cursor ? { cursor } : {};
186
+ if (schemas && typeof client.request === 'function') {
187
+ const r = await client.request({ method, params }, schemas[key], REQUEST_OPTS);
188
+ return { items: Array.isArray(r?.[key]) ? r[key] : [], nextCursor: r?.nextCursor };
189
+ }
190
+ const fn = key === 'tools' ? 'listTools' : key === 'prompts' ? 'listPrompts' : 'listResources';
191
+ const r = await client[fn](params, REQUEST_OPTS);
192
+ return { items: Array.isArray(r?.[key]) ? r[key] : [], nextCursor: r?.nextCursor };
193
+ }
194
+ async function enumerate(client) {
195
+ const caps = client.getServerCapabilities?.() ?? {};
196
+ const info = client.getServerVersion?.() ?? {};
197
+ const instructions = client.getInstructions?.();
198
+ const tools = [];
199
+ let cursor;
200
+ let pages = 0;
201
+ do {
202
+ const r = await listLoose(client, 'tools/list', 'tools', cursor);
203
+ for (const t of r.items)
204
+ tools.push(t);
205
+ cursor = r.nextCursor;
206
+ // Bound BOTH item count and page count: a malicious server can return
207
+ // empty pages with a persistent cursor forever.
208
+ if (tools.length >= MAX_ITEMS || ++pages >= MAX_PAGES)
209
+ break;
210
+ } while (cursor);
211
+ const prompts = [];
212
+ if (caps.prompts) {
213
+ try {
214
+ const r = await listLoose(client, 'prompts/list', 'prompts');
215
+ for (const p of r.items)
216
+ prompts.push(p);
217
+ }
218
+ catch {
219
+ /* optional capability */
220
+ }
221
+ }
222
+ const resources = [];
223
+ if (caps.resources) {
224
+ try {
225
+ const r = await listLoose(client, 'resources/list', 'resources');
226
+ for (const res of r.items)
227
+ resources.push(res);
228
+ }
229
+ catch {
230
+ /* optional capability */
231
+ }
232
+ }
233
+ return {
234
+ tools,
235
+ prompts,
236
+ resources,
237
+ server: {
238
+ name: info.name,
239
+ version: info.version,
240
+ title: info.title,
241
+ instructions,
242
+ capabilities: caps,
243
+ },
244
+ };
245
+ }
246
+ function commandBase(command) {
247
+ const cleaned = command.replace(/\\/g, '/');
248
+ return cleaned.slice(cleaned.lastIndexOf('/') + 1);
249
+ }
250
+ /** Acquire a surface by spawning a local stdio MCP server. */
251
+ export async function acquireStdio(spec, opts = {}) {
252
+ const base = commandBase(spec.command);
253
+ // A path separator means the caller specified an explicit path; a basename
254
+ // match must NOT authorize an attacker-chosen path (e.g. /tmp/evil/node).
255
+ const hasPathSeparator = /[\\/]/.test(spec.command);
256
+ const allowed = ALLOWED_COMMANDS.has(base) && !hasPathSeparator;
257
+ if (!allowed && !opts.allowAnyCommand) {
258
+ const reason = hasPathSeparator
259
+ ? `path-qualified commands are not allowlisted (only bare names ${[...ALLOWED_COMMANDS].join(', ')})`
260
+ : `not in the executable allowlist (${[...ALLOWED_COMMANDS].join(', ')})`;
261
+ throw new Error(`Refusing to spawn "${spec.command}": ${reason}. Re-run with --allow-any-command only if you trust it.`);
262
+ }
263
+ const { clean: safeEnv, dropped } = sanitizeEnv(spec.env ?? {});
264
+ const sdk = await loadSdk();
265
+ const transport = new sdk.StdioClientTransport({
266
+ command: spec.command,
267
+ args: spec.args ?? [],
268
+ // PATH last so a (scrubbed, but defense-in-depth) config env can never win.
269
+ env: { ...safeEnv, PATH: process.env.PATH ?? '' },
270
+ cwd: spec.cwd ?? process.cwd(),
271
+ stderr: 'pipe',
272
+ });
273
+ const client = new sdk.Client({ name: 'mcptrustchecker', version: '1.0.0' }, { capabilities: {} });
274
+ try {
275
+ await withTimeout(client.connect(transport), CONNECT_TIMEOUT_MS, 'stdio connect');
276
+ const enumd = await enumerate(client);
277
+ const id = `${spec.command} ${(spec.args ?? []).join(' ')}`.trim();
278
+ const surface = surfaceFromManifest({ ...enumd }, id, id);
279
+ surface.source = { kind: 'stdio', origin: id };
280
+ surface.transport = {
281
+ kind: 'stdio',
282
+ command: spec.command,
283
+ args: spec.args ?? [],
284
+ userControlledCommand: !allowed,
285
+ ...(dropped.length ? { droppedEnv: dropped } : {}),
286
+ };
287
+ return surface;
288
+ }
289
+ finally {
290
+ await client.close?.().catch(() => { });
291
+ }
292
+ }
293
+ /** Acquire a surface from a Streamable-HTTP / SSE endpoint. */
294
+ export async function acquireHttp(url, opts = {}) {
295
+ let parsed;
296
+ try {
297
+ parsed = new URL(url);
298
+ }
299
+ catch {
300
+ throw new Error(`Invalid URL: ${url}`);
301
+ }
302
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
303
+ throw new Error(`Unsupported URL scheme "${parsed.protocol}" (only http/https).`);
304
+ }
305
+ if (opts.allowedHosts && !opts.allowedHosts.includes(parsed.hostname)) {
306
+ throw new Error(`Host ${parsed.hostname} is not in the allowed-hosts list.`);
307
+ }
308
+ if (opts.blockPrivateHosts && isBlockedHost(parsed.hostname)) {
309
+ throw new Error(`Refusing to connect to private/loopback host "${parsed.hostname}" from an untrusted config (SSRF guard). ` +
310
+ `Use --allowed-hosts to permit specific hosts.`);
311
+ }
312
+ const sdk = await loadSdk();
313
+ const client = new sdk.Client({ name: 'mcptrustchecker', version: '1.0.0' }, { capabilities: {} });
314
+ // Re-validate every redirect hop: the initial-URL SSRF check is useless if the
315
+ // server 302s to http://[::ffff:169.254.169.254]/. We follow redirects
316
+ // manually (capped) and run each hop's host back through the same guard.
317
+ const guardedFetch = async (input, init) => {
318
+ let current = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
319
+ for (let hop = 0; hop < 6; hop++) {
320
+ const res = await fetch(current, { ...init, redirect: 'manual' });
321
+ const loc = res.status >= 300 && res.status < 400 ? res.headers.get('location') : null;
322
+ if (!loc)
323
+ return res;
324
+ const next = new URL(loc, current);
325
+ if (next.protocol !== 'http:' && next.protocol !== 'https:')
326
+ throw new Error(`Blocked redirect to non-http scheme "${next.protocol}".`);
327
+ if (opts.allowedHosts && !opts.allowedHosts.includes(next.hostname))
328
+ throw new Error(`Blocked redirect to disallowed host "${next.hostname}".`);
329
+ if (opts.blockPrivateHosts && isBlockedHost(next.hostname))
330
+ throw new Error(`SSRF: blocked redirect to private/loopback host "${next.hostname}".`);
331
+ current = next.href;
332
+ }
333
+ throw new Error('Too many redirects.');
334
+ };
335
+ const connect = async () => {
336
+ try {
337
+ const t = new sdk.StreamableHTTPClientTransport(parsed, { fetch: guardedFetch });
338
+ await withTimeout(client.connect(t), CONNECT_TIMEOUT_MS, 'http connect');
339
+ }
340
+ catch {
341
+ const sseT = new sdk.SSEClientTransport(parsed, { fetch: guardedFetch });
342
+ await withTimeout(client.connect(sseT), CONNECT_TIMEOUT_MS, 'sse connect');
343
+ }
344
+ };
345
+ try {
346
+ await connect();
347
+ const enumd = await enumerate(client);
348
+ const surface = surfaceFromManifest({ ...enumd }, url, url);
349
+ surface.source = { kind: 'http', origin: url };
350
+ surface.transport = { kind: 'http', url };
351
+ return surface;
352
+ }
353
+ finally {
354
+ await client.close?.().catch(() => { });
355
+ }
356
+ }
357
+ /* eslint-enable @typescript-eslint/no-explicit-any */
@@ -0,0 +1,10 @@
1
+ /*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
2
+ /**
3
+ * Offline acquisition: build a normalized ServerSurface from a pre-generated
4
+ * JSON manifest. Accepts several shapes so `mcptrustchecker scan tools.json` "just
5
+ * works": a raw `tools/list` result, a full surface object, or a hand-written
6
+ * `{ server, tools, prompts, resources }`.
7
+ */
8
+ import type { ServerSurface } from '../types.js';
9
+ /** Build a surface from an arbitrary manifest object. */
10
+ export declare function surfaceFromManifest(input: unknown, origin: string, id?: string): ServerSurface;