mcptrustchecker 1.1.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.
@@ -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 {
package/dist/lockfile.js CHANGED
@@ -14,6 +14,15 @@ import { METHODOLOGY_VERSION } from './version.js';
14
14
  function hashString(s) {
15
15
  return createHash('sha256').update(s).digest('hex');
16
16
  }
17
+ /** The artifact's file name (last path segment, query/fragment stripped). */
18
+ function artifactBasename(url) {
19
+ try {
20
+ return new URL(url).pathname.split('/').pop() ?? url;
21
+ }
22
+ catch {
23
+ return url.split(/[?#]/)[0].split('/').pop() ?? url;
24
+ }
25
+ }
17
26
  export function emptyLockfile() {
18
27
  return { lockfileVersion: 1, methodologyVersion: METHODOLOGY_VERSION, servers: {} };
19
28
  }
@@ -35,19 +44,37 @@ export function entryFor(surface, pinnedAt) {
35
44
  const tools = {};
36
45
  for (const t of surface.tools)
37
46
  tools[t.name] = toolDigest(t);
47
+ const meta = surface.packageMeta;
38
48
  return {
39
49
  digest: surfaceDigest(surface),
40
50
  tools,
41
51
  instructionsDigest: hashString(surface.server.instructions ?? ''),
52
+ ...(typeof meta?.version === 'string' && meta.tarballSha256
53
+ ? { packageVersion: meta.version, tarballUrl: meta.tarballUrl ?? undefined, tarballSha256: meta.tarballSha256 }
54
+ : {}),
42
55
  ...(pinnedAt ? { pinnedAt } : {}),
43
56
  };
44
57
  }
45
58
  /** Add/update this surface's pin in the lockfile (returns a new object). */
46
59
  export function pinSurface(lock, surface, pinnedAt) {
60
+ const entry = entryFor(surface, pinnedAt);
61
+ // Never DROP an existing byte-level pin unless this scan positively verified a
62
+ // DIFFERENT version. An offline / metadata-only / failed-download re-pin has no
63
+ // fresh hash (and often no observed version at all) — carrying the previous pin
64
+ // forward keeps rug-pull protection alive rather than silently un-pinning it on
65
+ // a networkless CI re-pin or a transient outage.
66
+ const prev = lock.servers[surface.id];
67
+ const observedVersion = surface.packageMeta?.version;
68
+ const versionChanged = Boolean(observedVersion && prev?.packageVersion && observedVersion !== prev.packageVersion);
69
+ if (!entry.tarballSha256 && prev?.tarballSha256 && !versionChanged) {
70
+ entry.packageVersion = prev.packageVersion;
71
+ entry.tarballUrl = prev.tarballUrl;
72
+ entry.tarballSha256 = prev.tarballSha256;
73
+ }
47
74
  return {
48
75
  ...lock,
49
76
  methodologyVersion: METHODOLOGY_VERSION,
50
- servers: { ...lock.servers, [surface.id]: entryFor(surface, pinnedAt) },
77
+ servers: { ...lock.servers, [surface.id]: entry },
51
78
  };
52
79
  }
53
80
  /** Compare the current surface against its pinned entry. */
@@ -56,7 +83,40 @@ export function checkIntegrity(surface, lock) {
56
83
  const entry = lock?.servers[surface.id];
57
84
  if (!entry)
58
85
  return { status: 'first-seen', currentDigest };
59
- if (entry.digest === currentDigest)
86
+ // Byte-level rug pull: the SAME pinned version was republished with different
87
+ // content. The tool surface (and therefore the digest) may be identical —
88
+ // that is exactly what makes this attack invisible to a metadata-only check.
89
+ //
90
+ // Artifact identity is registry-aware, because the tarball URL comes from the
91
+ // (attacker-controllable) registry response and MUST NOT be trusted to gate
92
+ // the comparison:
93
+ // • npm — a version maps to exactly ONE canonical tarball, so we compare by
94
+ // VERSION only and ignore the URL entirely. That defeats a forged registry
95
+ // that appends `?rev=2` (or any different path) to dodge the check.
96
+ // • PyPI — a version legitimately has several immutable, differently-named
97
+ // files (sdist + wheels), so we compare by the artifact FILE NAME (query
98
+ // stripped) to avoid a false republish when a sibling file is added, while
99
+ // still catching a same-file byte change (PyPI enforces file immutability,
100
+ // so a same-name/different-bytes swap is itself the anomaly).
101
+ const meta = surface.packageMeta;
102
+ // Require a STRING version on both sides: a non-primitive `meta.version`
103
+ // (a hostile registry can make it an array/object) would never compare equal
104
+ // after the lockfile's JSON round-trip, silently suppressing the byte check.
105
+ const sameVersion = Boolean(typeof meta?.version === 'string' && meta.version === entry.packageVersion);
106
+ const sameArtifact = sameVersion &&
107
+ (meta?.registry !== 'pypi' ||
108
+ !entry.tarballUrl ||
109
+ !meta?.tarballUrl ||
110
+ artifactBasename(entry.tarballUrl) === artifactBasename(meta.tarballUrl));
111
+ const tarballChange = entry.tarballSha256 && entry.packageVersion && meta?.tarballSha256 && sameArtifact && meta.tarballSha256 !== entry.tarballSha256
112
+ ? {
113
+ kind: 'package-changed',
114
+ name: meta.name,
115
+ detail: `Version ${meta.version} was republished with different content: the verified artifact hash changed ` +
116
+ `from ${entry.tarballSha256.slice(0, 12)}… to ${meta.tarballSha256.slice(0, 12)}… since the pin (same version, different bytes).`,
117
+ }
118
+ : undefined;
119
+ if (entry.digest === currentDigest && !tarballChange)
60
120
  return { status: 'unchanged', currentDigest, previousDigest: entry.digest };
61
121
  const changes = [];
62
122
  const currentTools = {};
@@ -83,5 +143,7 @@ export function checkIntegrity(surface, lock) {
83
143
  if (entry.instructionsDigest !== hashString(surface.server.instructions ?? '')) {
84
144
  changes.push({ kind: 'instructions-changed', detail: 'Server instructions changed since the pin.' });
85
145
  }
146
+ if (tarballChange)
147
+ changes.push(tarballChange);
86
148
  return { status: 'drift', currentDigest, previousDigest: entry.digest, changes };
87
149
  }
package/dist/types.d.ts CHANGED
@@ -102,6 +102,28 @@ export interface PackageMeta {
102
102
  pinned?: boolean;
103
103
  /** The raw version token from the install spec, if any (e.g. "latest", "^1.2.0"). */
104
104
  requestedSpec?: string;
105
+ /**
106
+ * True when an EXACT pinned version was requested but the registry does not
107
+ * list it (unpublished, yanked, or hidden by a hostile registry response). The
108
+ * artifact is deliberately left unresolved rather than silently substituting
109
+ * `latest`, and a finding is raised.
110
+ */
111
+ requestedVersionMissing?: boolean;
112
+ /** URL of the published artifact (npm dist.tarball / PyPI sdist or wheel). */
113
+ tarballUrl?: string | null;
114
+ /** Registry-declared artifact hash: SRI (`sha512-<b64>`) or `<algo>:<hex>`. */
115
+ tarballIntegrity?: string | null;
116
+ /** SHA-256 (hex) of the verified artifact the scan actually read — the byte-level pin. */
117
+ tarballSha256?: string;
118
+ /**
119
+ * Set when an `--online` artifact read was attempted but did not complete.
120
+ * `integrity`/`untrusted-redirect` are tamper evidence (a detector raises a
121
+ * finding); `network`/`other` mean the byte check simply could not run.
122
+ */
123
+ artifactError?: {
124
+ kind: 'integrity' | 'untrusted-redirect' | 'network' | 'other';
125
+ detail: string;
126
+ };
105
127
  }
106
128
  /** The single normalized object every detector operates on. */
107
129
  export interface ServerSurface {
@@ -237,7 +259,7 @@ export interface Score {
237
259
  }
238
260
  export type IntegrityStatus = 'first-seen' | 'unchanged' | 'drift';
239
261
  export interface SurfaceChange {
240
- kind: 'tool-added' | 'tool-removed' | 'tool-changed' | 'instructions-changed';
262
+ kind: 'tool-added' | 'tool-removed' | 'tool-changed' | 'instructions-changed' | 'package-changed';
241
263
  name?: string;
242
264
  detail: string;
243
265
  }
package/dist/version.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
2
2
  /** Package version. Keep in sync with package.json. */
3
- export declare const TOOL_VERSION = "1.1.0";
3
+ export declare const TOOL_VERSION = "1.2.0";
4
4
  /**
5
5
  * Methodology version. Bump this whenever scoring weights, gates, rule
6
6
  * severities, or bundled threat data change in a way that could move a score.
package/dist/version.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
2
2
  /** Package version. Keep in sync with package.json. */
3
- export const TOOL_VERSION = '1.1.0';
3
+ export const TOOL_VERSION = '1.2.0';
4
4
  /**
5
5
  * Methodology version. Bump this whenever scoring weights, gates, rule
6
6
  * severities, or bundled threat data change in a way that could move a score.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcptrustchecker",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Local-first, deterministic security scanner for Model Context Protocol (MCP) servers. Cross-tool toxic-flow analysis, Unicode-smuggling decode, prompt-injection & supply-chain detection, with an auditable 0–100 Trust Score.",
5
5
  "type": "module",
6
6
  "license": "MIT",