mcptrustchecker 1.0.0 → 1.2.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.
@@ -0,0 +1,258 @@
1
+ /*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
2
+ /**
3
+ * Read the *published bytes* of a registry package — fetch the npm/PyPI
4
+ * artifact, verify it against the registry-declared hash, and extract its
5
+ * implementation source in memory. This is what turns a bare-package scan from
6
+ * a metadata check into a real source-level analysis: the same MTC-SRC engine
7
+ * that runs on a local directory runs on the exact tarball users install.
8
+ *
9
+ * Security posture:
10
+ * - network only in `--online` mode, and only to the registry's own artifact
11
+ * host (allowlisted per registry) over https;
12
+ * - the download is size-capped and integrity-verified BEFORE it is parsed —
13
+ * a mismatched hash aborts the source read;
14
+ * - nothing is written to disk and nothing is executed;
15
+ * - the verified artifact's SHA-256 is recorded so the lockfile can pin the
16
+ * actual bytes (same version + different bytes on rescan = rug-pull signal).
17
+ */
18
+ import { createHash } from 'node:crypto';
19
+ import { readFileSync } from 'node:fs';
20
+ import { SOURCE_EXTENSIONS } from '../data/sourcePatterns.js';
21
+ import { extractArchive, stripCommonRoot } from './archive.js';
22
+ import { packageMetaFromJson, SIDECAR_MANIFESTS, SOURCE_LIMITS } from './source.js';
23
+ import { surfaceFromManifest } from './manifest.js';
24
+ import { isBlockedHost } from './live.js';
25
+ /** Compressed-artifact download cap. */
26
+ const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024; // 50 MB
27
+ const DOWNLOAD_TIMEOUT_MS = 30_000;
28
+ /**
29
+ * Hosts a registry is allowed to serve artifacts from. The tarball URL comes
30
+ * from the registry response, so we pin it to the registry's own artifact CDN —
31
+ * a compromised or spoofed metadata response can't point the scanner at an
32
+ * arbitrary server.
33
+ */
34
+ const TARBALL_HOSTS = {
35
+ npm: ['registry.npmjs.org'],
36
+ pypi: ['files.pythonhosted.org'],
37
+ };
38
+ export class PackageSourceError extends Error {
39
+ kind;
40
+ constructor(kind, message) {
41
+ super(message);
42
+ this.name = 'PackageSourceError';
43
+ this.kind = kind;
44
+ }
45
+ }
46
+ /** Validate that an artifact URL is https on the registry's own artifact host. */
47
+ export function assertTrustedTarballUrl(url, registry) {
48
+ let parsed;
49
+ try {
50
+ parsed = new URL(url);
51
+ }
52
+ catch {
53
+ throw new PackageSourceError('untrusted-redirect', `invalid artifact URL "${url}"`);
54
+ }
55
+ if (parsed.protocol !== 'https:')
56
+ throw new PackageSourceError('untrusted-redirect', `artifact URL must be https (got ${parsed.protocol}//)`);
57
+ if (!TARBALL_HOSTS[registry].includes(parsed.hostname) || isBlockedHost(parsed.hostname)) {
58
+ throw new PackageSourceError('untrusted-redirect', `artifact host "${parsed.hostname}" is not the ${registry} registry's artifact host (${TARBALL_HOSTS[registry].join(', ')}) — refusing to fetch`);
59
+ }
60
+ return parsed;
61
+ }
62
+ /**
63
+ * Verify a downloaded artifact against the registry-declared hash. Accepts SRI
64
+ * (`sha512-<base64>`, `sha1-<base64>`) and `<algo>:<hex>` forms. Fails closed:
65
+ * a declared hash that can't be parsed or doesn't match rejects the artifact.
66
+ * `null`/absent means the registry declared none — the artifact is still used
67
+ * (its own SHA-256 becomes the lockfile pin), which is the TOFU trade-off.
68
+ */
69
+ export function verifyTarballIntegrity(buf, expected) {
70
+ if (!expected)
71
+ return;
72
+ let algo;
73
+ let digest;
74
+ let encoding;
75
+ const sri = expected.match(/^([a-z0-9]+)-([A-Za-z0-9+/=]+)$/);
76
+ const hexForm = expected.match(/^([a-z0-9]+):([0-9a-f]+)$/i);
77
+ if (sri) {
78
+ algo = sri[1];
79
+ digest = sri[2];
80
+ encoding = 'base64';
81
+ }
82
+ else if (hexForm) {
83
+ algo = hexForm[1].toLowerCase();
84
+ digest = hexForm[2].toLowerCase();
85
+ encoding = 'hex';
86
+ }
87
+ else {
88
+ throw new PackageSourceError('integrity', `unparseable integrity value "${expected.slice(0, 40)}" — refusing the artifact`);
89
+ }
90
+ let actual;
91
+ try {
92
+ actual = createHash(algo).update(buf).digest(encoding);
93
+ }
94
+ catch {
95
+ throw new PackageSourceError('integrity', `unsupported integrity algorithm "${algo}" — refusing the artifact`);
96
+ }
97
+ const normActual = encoding === 'hex' ? actual.toLowerCase() : actual;
98
+ if (normActual !== digest) {
99
+ throw new PackageSourceError('integrity', `artifact integrity mismatch: registry declared ${algo} ${digest.slice(0, 16)}… but the download hashed to ${normActual.slice(0, 16)}…`);
100
+ }
101
+ }
102
+ /**
103
+ * Download with a hard byte cap enforced while streaming, and a timeout.
104
+ * Redirects are followed MANUALLY (cap 5) and every hop is re-validated through
105
+ * the same host allowlist — otherwise a 3xx from the registry could point the
106
+ * scanner at an arbitrary host (SSRF), and its bytes would be scanned and pinned
107
+ * as if they were the published source. Mirrors live.ts's guardedFetch posture.
108
+ */
109
+ export async function downloadCapped(url, registry, fetchImpl = fetch) {
110
+ const ctrl = new AbortController();
111
+ const timer = setTimeout(() => ctrl.abort(), DOWNLOAD_TIMEOUT_MS);
112
+ try {
113
+ let current = assertTrustedTarballUrl(url.href, registry);
114
+ let res;
115
+ for (let hop = 0; hop < 6; hop++) {
116
+ res = await fetchImpl(current, {
117
+ signal: ctrl.signal,
118
+ redirect: 'manual',
119
+ headers: { 'User-Agent': 'mcptrustchecker' },
120
+ });
121
+ if (res.status >= 300 && res.status < 400) {
122
+ const loc = res.headers.get('location');
123
+ if (!loc)
124
+ throw new PackageSourceError('network', `artifact redirect (${res.status}) without a Location header`);
125
+ // Re-run the redirect target through the full host allowlist.
126
+ current = assertTrustedTarballUrl(new URL(loc, current).href, registry);
127
+ continue;
128
+ }
129
+ break;
130
+ }
131
+ if (!res)
132
+ throw new PackageSourceError('network', 'artifact download produced no response');
133
+ if (res.status >= 300 && res.status < 400)
134
+ throw new PackageSourceError('network', 'too many artifact redirects');
135
+ if (!res.ok)
136
+ throw new PackageSourceError('network', `artifact download failed: HTTP ${res.status}`);
137
+ const declared = Number(res.headers.get('content-length') ?? 0);
138
+ if (declared > MAX_DOWNLOAD_BYTES) {
139
+ throw new PackageSourceError('network', `artifact is ${Math.round(declared / 1024 / 1024)} MB — over the ${MAX_DOWNLOAD_BYTES / 1024 / 1024} MB safety cap`);
140
+ }
141
+ if (!res.body)
142
+ throw new PackageSourceError('network', 'artifact download returned no body');
143
+ const reader = res.body.getReader();
144
+ const chunks = [];
145
+ let total = 0;
146
+ for (;;) {
147
+ const { done, value } = await reader.read();
148
+ if (done)
149
+ break;
150
+ total += value.byteLength;
151
+ if (total > MAX_DOWNLOAD_BYTES) {
152
+ ctrl.abort();
153
+ throw new PackageSourceError('network', `artifact exceeded the ${MAX_DOWNLOAD_BYTES / 1024 / 1024} MB download cap`);
154
+ }
155
+ chunks.push(Buffer.from(value));
156
+ }
157
+ return Buffer.concat(chunks);
158
+ }
159
+ finally {
160
+ clearTimeout(timer);
161
+ }
162
+ }
163
+ const SIDECAR_NAMES = new Set(['package.json', ...SIDECAR_MANIFESTS]);
164
+ /**
165
+ * Directories skipped inside a published artifact. Deliberately NARROWER than
166
+ * the local-directory skip list: a repo checkout is scanned via its authored
167
+ * source (dist/ is a build product to skip), but a published tarball often
168
+ * ships ONLY dist/ — those compiled bytes ARE what `npx` runs, so they must be
169
+ * read, not skipped.
170
+ */
171
+ const ARCHIVE_SKIP_DIRS = new Set(['node_modules', '.git', 'coverage', '__pycache__', 'venv', '.venv', 'vendor']);
172
+ function isSourcePath(path) {
173
+ const parts = path.split('/');
174
+ if (parts.some((p) => ARCHIVE_SKIP_DIRS.has(p)))
175
+ return false;
176
+ const base = parts[parts.length - 1];
177
+ const dot = base.lastIndexOf('.');
178
+ const ext = dot >= 0 ? base.slice(dot).toLowerCase() : '';
179
+ return SOURCE_EXTENSIONS.has(ext);
180
+ }
181
+ /**
182
+ * Split extracted entries into implementation source and root-level sidecars.
183
+ * `stripCommonRoot` unwraps a SINGLE shared top directory (npm's `package/`, a
184
+ * PyPI sdist's `name-version/`, a GitHub source-zip's `repo-main/`) and leaves
185
+ * multi-root archives (wheels: `pkg/` + `pkg.dist-info/`) untouched — so a
186
+ * root-level `package.json`/`tools.json` is found in every real layout. Output
187
+ * is sorted by path so the scan is byte-identical regardless of archive order.
188
+ */
189
+ function classifyEntries(entries) {
190
+ const sourceFiles = [];
191
+ const sidecars = {};
192
+ for (const e of stripCommonRoot(entries)) {
193
+ if (!e.path.includes('/') && SIDECAR_NAMES.has(e.path))
194
+ sidecars[e.path] = e.data.toString('utf8');
195
+ if (isSourcePath(e.path))
196
+ sourceFiles.push({ path: e.path, content: e.data.toString('utf8') });
197
+ }
198
+ // Code-unit sort (never localeCompare) so order is reproducible on every machine.
199
+ sourceFiles.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
200
+ return { sourceFiles, sidecars };
201
+ }
202
+ /** The extraction predicate: implementation source + shallow sidecar files. */
203
+ function wantedEntry(path) {
204
+ const parts = path.split('/');
205
+ // Sidecars only at the archive root or one level down (the pre-strip root dir).
206
+ if (parts.length <= 2 && SIDECAR_NAMES.has(parts[parts.length - 1]))
207
+ return true;
208
+ return isSourcePath(path);
209
+ }
210
+ /**
211
+ * Fetch, verify and extract the published source of a registry package.
212
+ * Throws a descriptive error on any trust failure (bad host, over-cap, hash
213
+ * mismatch); returns `null` when the metadata has no artifact URL to read.
214
+ */
215
+ export async function fetchPackageSource(meta, fetchImpl = fetch) {
216
+ if (!meta.tarballUrl)
217
+ return null;
218
+ const registry = meta.registry === 'pypi' ? 'pypi' : 'npm';
219
+ const url = assertTrustedTarballUrl(meta.tarballUrl, registry);
220
+ const buf = await downloadCapped(url, registry, fetchImpl);
221
+ verifyTarballIntegrity(buf, meta.tarballIntegrity);
222
+ const tarballSha256 = createHash('sha256').update(buf).digest('hex');
223
+ const entries = extractArchive(buf, url.pathname, wantedEntry, SOURCE_LIMITS);
224
+ return { ...classifyEntries(entries), tarballSha256 };
225
+ }
226
+ /**
227
+ * Build a scan surface from a local packed artifact (.tgz / .tar.gz / .tar /
228
+ * .zip / .whl) — the file a release pipeline actually ships. Reads the
229
+ * implementation source and any package.json / sidecar manifest it contains;
230
+ * never writes to disk, never executes anything.
231
+ */
232
+ export function surfaceFromArchiveFile(path) {
233
+ const buf = readFileSync(path);
234
+ const entries = extractArchive(buf, path, wantedEntry, SOURCE_LIMITS);
235
+ const { sourceFiles, sidecars } = classifyEntries(entries);
236
+ let manifest = {};
237
+ for (const name of SIDECAR_MANIFESTS) {
238
+ if (sidecars[name]) {
239
+ try {
240
+ manifest = JSON.parse(sidecars[name]);
241
+ }
242
+ catch {
243
+ /* ignore an unparseable sidecar manifest */
244
+ }
245
+ break;
246
+ }
247
+ }
248
+ const surface = surfaceFromManifest(manifest, path, path);
249
+ surface.source = { kind: 'package', origin: path };
250
+ const fromPkg = sidecars['package.json'] ? packageMetaFromJson(sidecars['package.json']) : undefined;
251
+ surface.packageMeta = {
252
+ ...(fromPkg ?? { registry: 'unknown' }),
253
+ ...(surface.packageMeta ?? {}),
254
+ tarballSha256: createHash('sha256').update(buf).digest('hex'),
255
+ };
256
+ surface.sourceFiles = sourceFiles;
257
+ return surface;
258
+ }
@@ -6,11 +6,21 @@
6
6
  * scan, and never executes any of the code it reads. Offline.
7
7
  */
8
8
  import type { ServerSurface, SourceFile } from '../types.js';
9
+ export declare const SKIP_DIRS: Set<string>;
10
+ export declare const SOURCE_LIMITS: {
11
+ readonly maxFiles: 400;
12
+ readonly maxFileBytes: number;
13
+ readonly maxTotalBytes: number;
14
+ };
9
15
  /** Recursively collect scannable source files from a directory (bounded). */
10
16
  export declare function readSourceFiles(dir: string): SourceFile[];
17
+ /** Parse a package.json's text into the surface's package metadata. */
18
+ export declare function packageMetaFromJson(text: string): ServerSurface['packageMeta'];
11
19
  /**
12
20
  * Build a scan surface from a local package directory: its implementation source
13
21
  * (for MTC-SRC-* analysis), its package metadata (for supply-chain checks), and
14
22
  * a tools manifest if the directory ships one (tools.json / mcp-tools.json).
15
23
  */
24
+ /** Sidecar manifest file names a package may ship, in priority order. */
25
+ export declare const SIDECAR_MANIFESTS: readonly ["tools.json", "mcp-tools.json", "mcp.json"];
16
26
  export declare function surfaceFromPackageDir(dir: string): ServerSurface;
@@ -9,10 +9,15 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
9
9
  import { join, relative, extname } from 'node:path';
10
10
  import { SOURCE_EXTENSIONS } from '../data/sourcePatterns.js';
11
11
  import { surfaceFromManifest } from './manifest.js';
12
- const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next', '__pycache__', 'venv', '.venv', 'vendor']);
13
- const MAX_FILES = 400;
14
- const MAX_FILE_BYTES = 512 * 1024; // 512 KB per file
15
- const MAX_TOTAL_BYTES = 12 * 1024 * 1024; // 12 MB total
12
+ export const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next', '__pycache__', 'venv', '.venv', 'vendor']);
13
+ export const SOURCE_LIMITS = {
14
+ maxFiles: 400,
15
+ maxFileBytes: 512 * 1024, // 512 KB per file
16
+ maxTotalBytes: 12 * 1024 * 1024, // 12 MB total
17
+ };
18
+ const MAX_FILES = SOURCE_LIMITS.maxFiles;
19
+ const MAX_FILE_BYTES = SOURCE_LIMITS.maxFileBytes;
20
+ const MAX_TOTAL_BYTES = SOURCE_LIMITS.maxTotalBytes;
16
21
  /** Recursively collect scannable source files from a directory (bounded). */
17
22
  export function readSourceFiles(dir) {
18
23
  const out = [];
@@ -57,15 +62,19 @@ export function readSourceFiles(dir) {
57
62
  }
58
63
  };
59
64
  walk(dir);
65
+ // `readdirSync` returns filesystem-defined order (APFS vs ext4 differ), and the
66
+ // source detector caps findings per rule — so WITHOUT a stable sort the same
67
+ // package could yield different findings on two machines. Code-unit sort keeps
68
+ // the scan byte-reproducible everywhere (matches util/hash.ts's ordering rule).
69
+ out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
60
70
  return out;
61
71
  }
62
- /** Read a package.json (if present) into the surface's package metadata. */
63
- function readPackageMeta(dir) {
64
- const pkgPath = join(dir, 'package.json');
65
- if (!existsSync(pkgPath))
66
- return undefined;
72
+ /** Parse a package.json's text into the surface's package metadata. */
73
+ export function packageMetaFromJson(text) {
67
74
  try {
68
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
75
+ const pkg = JSON.parse(text);
76
+ if (!pkg || typeof pkg !== 'object' || Array.isArray(pkg))
77
+ return undefined;
69
78
  return {
70
79
  registry: 'npm',
71
80
  name: typeof pkg.name === 'string' ? pkg.name : undefined,
@@ -82,14 +91,28 @@ function readPackageMeta(dir) {
82
91
  return undefined;
83
92
  }
84
93
  }
94
+ /** Read a package.json (if present) into the surface's package metadata. */
95
+ function readPackageMeta(dir) {
96
+ const pkgPath = join(dir, 'package.json');
97
+ if (!existsSync(pkgPath))
98
+ return undefined;
99
+ try {
100
+ return packageMetaFromJson(readFileSync(pkgPath, 'utf8'));
101
+ }
102
+ catch {
103
+ return undefined;
104
+ }
105
+ }
85
106
  /**
86
107
  * Build a scan surface from a local package directory: its implementation source
87
108
  * (for MTC-SRC-* analysis), its package metadata (for supply-chain checks), and
88
109
  * a tools manifest if the directory ships one (tools.json / mcp-tools.json).
89
110
  */
111
+ /** Sidecar manifest file names a package may ship, in priority order. */
112
+ export const SIDECAR_MANIFESTS = ['tools.json', 'mcp-tools.json', 'mcp.json'];
90
113
  export function surfaceFromPackageDir(dir) {
91
114
  let manifest = {};
92
- for (const name of ['tools.json', 'mcp-tools.json', 'mcp.json']) {
115
+ for (const name of SIDECAR_MANIFESTS) {
93
116
  const p = join(dir, name);
94
117
  if (existsSync(p)) {
95
118
  try {
package/dist/cli/index.js CHANGED
@@ -30,6 +30,7 @@ import { GRADE_RANK } from '../scoring/model.js';
30
30
  import { RULE_CATALOG, findRule } from '../data/ruleCatalog.js';
31
31
  import { METHODOLOGY_VERSION, TOOL_VERSION } from '../version.js';
32
32
  import { c } from '../util/ansi.js';
33
+ import { parseHeaderArgs } from '../util/headers.js';
33
34
  const KNOWN_COMMANDS = new Set(['scan', 'pin', 'diff', 'rules', 'explain', 'version', 'help']);
34
35
  function fail(msg, code = 2) {
35
36
  process.stderr.write(`${c.red('mcptrustchecker: error')} ${msg}\n`);
@@ -49,8 +50,12 @@ ${c.bold('Usage')}
49
50
  ${c.bold('Targets')}
50
51
  path/to/tools.json a pre-generated manifest (offline)
51
52
  https://host/mcp a live Streamable-HTTP / SSE endpoint
53
+ --login OAuth browser sign-in for a protected endpoint (opens your browser)
54
+ --scope <scope> OAuth scope to request with --login (e.g. mcp:tools)
55
+ --header "Authorization: Bearer …" static auth header instead of --login (repeatable)
52
56
  claude_desktop_config.json a client config (scans each server)
53
- @scope/package supply-chain / typosquat check (add --online for metadata)
57
+ @scope/package package scan (--online fetches metadata + reads the published source)
58
+ path/to/server.tgz|.whl|.zip a packed release artifact — reads the shipped source (offline)
54
59
  --command "npx -y my-server" spawn a local stdio server (sandboxed)
55
60
  --env KEY=VALUE env var for --command (repeatable)
56
61
 
@@ -71,7 +76,8 @@ ${c.bold('Options')}
71
76
  --no-lock skip the integrity (rug-pull) check
72
77
  --pin re-pin the lockfile after scanning
73
78
  --include-builtins assume client built-in tools in toxic-flow analysis
74
- --online allow network lookups for package metadata
79
+ --online allow network lookups: registry metadata + the verified published source
80
+ --metadata-only with --online: skip downloading the package artifact
75
81
  --run allow spawning servers found in a client config
76
82
  --allow-any-command permit stdio commands outside the allowlist (dangerous)
77
83
  --allowed-hosts <a,b> restrict live HTTP acquisition to these hosts
@@ -104,8 +110,12 @@ async function main() {
104
110
  command: { type: 'string' },
105
111
  args: { type: 'string' },
106
112
  env: { type: 'string', multiple: true },
113
+ header: { type: 'string', multiple: true },
114
+ login: { type: 'boolean' },
115
+ scope: { type: 'string' },
107
116
  url: { type: 'string' },
108
117
  online: { type: 'boolean' },
118
+ 'metadata-only': { type: 'boolean' },
109
119
  run: { type: 'boolean' },
110
120
  'allow-any-command': { type: 'boolean' },
111
121
  'allowed-hosts': { type: 'string' },
@@ -176,12 +186,23 @@ async function main() {
176
186
  args: buildArgs(values.command, values.args),
177
187
  url: values.url,
178
188
  online: values.online,
189
+ metadataOnly: values['metadata-only'],
179
190
  run: values.run,
180
191
  envVars: parseEnvFlags(values.env),
181
192
  allowAnyCommand: values['allow-any-command'],
182
193
  allowedHosts: values['allowed-hosts'] ? values['allowed-hosts'].split(',').map((h) => h.trim()) : undefined,
194
+ headers: parseHeaderArgs(values.header),
195
+ login: values.login,
196
+ scope: values.scope,
183
197
  registry: values.registry === 'pypi' ? 'pypi' : 'npm',
184
198
  };
199
+ // --login and a static Authorization header are alternative auth methods;
200
+ // combining them would silently clobber the freshly-minted OAuth token.
201
+ if (values.login &&
202
+ resolveOpts.headers &&
203
+ Object.keys(resolveOpts.headers).some((h) => h.toLowerCase() === 'authorization')) {
204
+ fail('--login cannot be combined with a static --header "Authorization: …" — choose one auth method.');
205
+ }
185
206
  let resolved = [];
186
207
  if (autoDiscover) {
187
208
  // Zero-config: find and scan every installed MCP client config.
@@ -68,6 +68,7 @@ export const RULE_CATALOG = [
68
68
  { id: 'MTC-SUP-012', title: 'No license', category: 'hygiene', severity: 'low', summary: 'Package declares no license.' },
69
69
  { id: 'MTC-SUP-013', title: 'Package not version-pinned', category: 'supply-chain', severity: 'low', summary: 'Installed with @latest/floating spec — the rug-pull enabler; pinning is the recommended control.' },
70
70
  { id: 'MTC-SUP-014', title: 'Dependency squat / advisory match', category: 'supply-chain', severity: 'medium', summary: 'A declared dependency resembles a protected package or matches a known advisory by name.' },
71
+ { id: 'MTC-SUP-015', title: 'Pinned version is not published in the registry', category: 'supply-chain', severity: 'medium', summary: 'An exact pinned version is not listed by the registry; the scanner refuses to substitute latest and flags the gap.' },
71
72
  // Stage 6 — Posture / CVE
72
73
  { id: 'MTC-NET-001', title: 'Known-vulnerable version', category: 'supply-chain', severity: 'high', summary: 'Installed version is in a known-CVE range.' },
73
74
  { id: 'MTC-NET-002', title: 'User-controlled stdio command', category: 'network', severity: 'critical', summary: 'stdio command from untrusted config without an allowlist — stdio RCE class.' },
@@ -77,6 +78,9 @@ export const RULE_CATALOG = [
77
78
  { id: 'MTC-NET-006', title: 'Local HTTP/SSE — verify Origin (DNS rebinding)', category: 'network', severity: 'low', summary: 'A browser-reachable local MCP server is exposed to DNS rebinding unless it validates Host/Origin.' },
78
79
  // Stage 7 — Integrity
79
80
  { id: 'MTC-TOFU-001', title: 'Surface drift since pin (rug pull)', category: 'supply-chain', severity: 'high', summary: 'Canonical fingerprint no longer matches the pinned value.' },
81
+ { id: 'MTC-TOFU-002', title: 'Package republished with different content at the same version', category: 'supply-chain', severity: 'critical', summary: 'The verified artifact hash for the pinned version changed — same version, different published bytes (byte-level rug pull).' },
82
+ { id: 'MTC-TOFU-003', title: 'Published artifact failed integrity verification', category: 'supply-chain', severity: 'critical', summary: 'The downloaded artifact did not match the registry-declared hash, or was redirected off the registry host — tamper evidence; the bytes were not trusted.' },
83
+ { id: 'MTC-TOFU-004', title: 'Published-source byte check did not run', category: 'supply-chain', severity: 'info', summary: 'An online scan could not download the artifact; results reflect registry metadata only and are not source-verified.' },
80
84
  // Meta
81
85
  { id: 'MTC-META-001', title: 'Empty surface — nothing to analyze', category: 'hygiene', severity: 'info', summary: 'No tools/prompts/resources found; an empty surface is not a clean bill of health.' },
82
86
  // Stage 4b — Implementation-level source analysis (what the code DOES, not what the tool claims)
@@ -23,9 +23,18 @@ export const sourceDetector = {
23
23
  stage: 4,
24
24
  title: 'Implementation-level source analysis',
25
25
  run(ctx) {
26
- const files = Array.isArray(ctx.surface.sourceFiles) ? ctx.surface.sourceFiles : [];
27
- if (!files.length)
26
+ const raw = Array.isArray(ctx.surface.sourceFiles) ? ctx.surface.sourceFiles : [];
27
+ if (!raw.length)
28
28
  return [];
29
+ // Process in a stable path order (code-unit, never locale) so the per-rule
30
+ // finding cap keeps the SAME findings regardless of how the caller ordered
31
+ // the files — the "same input ⇒ byte-identical report" promise must not
32
+ // depend on filesystem or archive iteration order.
33
+ const files = [...raw].sort((a, b) => {
34
+ const x = typeof a?.path === 'string' ? a.path : '';
35
+ const y = typeof b?.path === 'string' ? b.path : '';
36
+ return x < y ? -1 : x > y ? 1 : 0;
37
+ });
29
38
  const findings = [];
30
39
  const perRule = new Map();
31
40
  for (const f of files) {
@@ -14,4 +14,6 @@ export declare function analyzeTyposquat(name: string, meta: PackageMeta | undef
14
14
  export declare function analyzeProvenance(meta: PackageMeta): Finding[];
15
15
  /** Check a package's declared dependencies for squats / known-vuln names. */
16
16
  export declare function analyzeDependencies(meta: PackageMeta, config: ResolvedConfig): Finding[];
17
+ /** Turn a failed published-artifact read into an honest finding (never silent). */
18
+ export declare function analyzeArtifactError(meta: PackageMeta): Finding[];
17
19
  export declare const supplyChainDetector: Detector;
@@ -224,6 +224,67 @@ export function analyzeDependencies(meta, config) {
224
224
  }
225
225
  return findings;
226
226
  }
227
+ /** Turn a failed published-artifact read into an honest finding (never silent). */
228
+ export function analyzeArtifactError(meta) {
229
+ if (meta.requestedVersionMissing) {
230
+ return [
231
+ {
232
+ ruleId: 'MTC-SUP-015',
233
+ title: 'Pinned version is not published in the registry',
234
+ category: 'supply-chain',
235
+ severity: 'medium',
236
+ confidence: 'strong',
237
+ description: `The exact version you pinned (${meta.version ?? meta.requestedSpec ?? 'requested'}) is not listed by the ` +
238
+ `registry — it may have been unpublished or yanked, or a hostile registry response may be hiding it to serve ` +
239
+ `a different "latest". The scanner did NOT silently substitute another version: no source was read and no ` +
240
+ `byte pin was recorded for a version that isn't there.`,
241
+ remediation: 'Confirm the version still exists and is the one you intend to install; if it was yanked, pin a known-good ' +
242
+ 'version and re-pin. Treat an unexpectedly-missing pinned version as a supply-chain signal.',
243
+ location: { kind: 'package', name: meta.name },
244
+ owasp: 'LLM03:2025 Supply Chain',
245
+ evidence: `requested ${meta.requestedSpec ?? meta.version ?? ''}`.trim(),
246
+ },
247
+ ];
248
+ }
249
+ const err = meta.artifactError;
250
+ if (!err)
251
+ return [];
252
+ if (err.kind === 'integrity' || err.kind === 'untrusted-redirect') {
253
+ return [
254
+ {
255
+ ruleId: 'MTC-TOFU-003',
256
+ title: 'Published artifact failed integrity verification',
257
+ category: 'supply-chain',
258
+ severity: 'critical',
259
+ confidence: 'confirmed',
260
+ description: `The published artifact for this package could not be verified against the registry's own declared hash ` +
261
+ `(or was served from a host outside the registry's allowlist). Its bytes were NOT trusted, scanned, or ` +
262
+ `pinned. This is exactly the signal a CDN/MITM tamper or a spoofed registry response would produce.\n • ${err.detail}`,
263
+ remediation: 'Do not install this package until resolved: re-fetch from a trusted network, confirm the registry ' +
264
+ 'metadata, and compare the artifact hash against a known-good source.',
265
+ location: { kind: 'package', name: meta.name },
266
+ owasp: 'LLM03:2025 Supply Chain',
267
+ evidence: err.detail,
268
+ },
269
+ ];
270
+ }
271
+ // Transient/other: not an attack claim, but the scan is NOT verified — say so.
272
+ return [
273
+ {
274
+ ruleId: 'MTC-TOFU-004',
275
+ title: 'Published-source byte check did not run',
276
+ category: 'supply-chain',
277
+ severity: 'info',
278
+ confidence: 'heuristic',
279
+ description: `An online scan was requested but the published artifact could not be downloaded, so the implementation ` +
280
+ `source was NOT read and the byte-level integrity pin was NOT recorded. This scan reflects registry ` +
281
+ `metadata only — treat it as incomplete, not as a clean bill of health.\n • ${err.detail}`,
282
+ remediation: 'Re-run the scan when the registry/network is reachable to complete the source-level analysis.',
283
+ location: { kind: 'package', name: meta.name },
284
+ evidence: err.detail,
285
+ },
286
+ ];
287
+ }
227
288
  export const supplyChainDetector = {
228
289
  id: 'supply-chain',
229
290
  stage: 5,
@@ -237,6 +298,7 @@ export const supplyChainDetector = {
237
298
  if (meta) {
238
299
  findings.push(...analyzeProvenance(meta));
239
300
  findings.push(...analyzeDependencies(meta, ctx.config));
301
+ findings.push(...analyzeArtifactError(meta));
240
302
  }
241
303
  return findings;
242
304
  },
package/dist/engine.js CHANGED
@@ -62,22 +62,46 @@ export async function scanSurface(rawSurface, options = {}) {
62
62
  if (options.lockfile !== undefined) {
63
63
  integrity = checkIntegrity(surface, options.lockfile);
64
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
- });
65
+ const allChanges = integrity.changes ?? [];
66
+ const packageChanges = allChanges.filter((ch) => ch.kind === 'package-changed');
67
+ const surfaceChanges = allChanges.filter((ch) => ch.kind !== 'package-changed');
68
+ if (surfaceChanges.length > 0 || integrity.currentDigest !== integrity.previousDigest) {
69
+ raw.push({
70
+ ruleId: 'MTC-TOFU-001',
71
+ title: 'Server surface changed since it was pinned (possible rug pull)',
72
+ category: 'supply-chain',
73
+ severity: 'high',
74
+ confidence: 'confirmed',
75
+ description: `The canonical fingerprint of this server no longer matches its pinned value in the lockfile. ` +
76
+ `Tool definitions can change silently after you approve them (a rug pull); review every change before ` +
77
+ `trusting it again.\n` +
78
+ surfaceChanges.map((c) => ` • ${c.detail}`).join('\n'),
79
+ remediation: 'Review the diff; re-pin only after confirming the changes are legitimate (`mcptrustchecker pin`).',
80
+ location: { kind: 'server' },
81
+ owasp: 'LLM03:2025 Supply Chain',
82
+ evidence: `${surfaceChanges.length} change(s) since pin`,
83
+ data: { changes: surfaceChanges },
84
+ });
85
+ }
86
+ if (packageChanges.length > 0) {
87
+ raw.push({
88
+ ruleId: 'MTC-TOFU-002',
89
+ title: 'Package republished with different content at the same version',
90
+ category: 'supply-chain',
91
+ severity: 'critical',
92
+ confidence: 'confirmed',
93
+ description: `The registry artifact for the pinned version no longer contains the bytes that were verified at pin ` +
94
+ `time. The tool surface can look completely unchanged while the implementation behind it was swapped — ` +
95
+ `the byte-level rug pull that metadata-only checks cannot see.\n` +
96
+ packageChanges.map((c) => ` • ${c.detail}`).join('\n'),
97
+ remediation: 'Treat this as a potential supply-chain compromise: diff the published code against the version you ' +
98
+ 'approved before trusting it again, and re-pin (`mcptrustchecker pin`) only after review.',
99
+ location: { kind: 'package', name: packageChanges[0].name },
100
+ owasp: 'LLM03:2025 Supply Chain',
101
+ evidence: packageChanges[0].detail,
102
+ data: { changes: packageChanges },
103
+ });
104
+ }
81
105
  }
82
106
  }
83
107
  // Filter: disabled rules, whole-rule allowlist waivers, and location-scoped
@@ -12,6 +12,17 @@ export interface LockEntry {
12
12
  digest: string;
13
13
  tools: Record<string, string>;
14
14
  instructionsDigest: string;
15
+ /** Package version whose published artifact was verified at pin time. */
16
+ packageVersion?: string;
17
+ /**
18
+ * Identity of the pinned artifact (its registry URL). A registry can publish
19
+ * several artifacts per version (a PyPI sdist AND wheels); the byte pin is
20
+ * only comparable to the SAME artifact, so a later-added sibling artifact
21
+ * isn't mistaken for a same-version republish.
22
+ */
23
+ tarballUrl?: string;
24
+ /** SHA-256 of that verified artifact — pins the actual published bytes. */
25
+ tarballSha256?: string;
15
26
  pinnedAt?: string;
16
27
  }
17
28
  export interface Lockfile {