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.
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
  }
@@ -0,0 +1,16 @@
1
+ /*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
2
+ /**
3
+ * Parse repeated `--header "Name: Value"` CLI arguments into a header map.
4
+ * Used to authenticate against protected remote MCP endpoints (e.g. a Bearer
5
+ * token). Kept pure and separate from the CLI entrypoint so it stays testable.
6
+ */
7
+ export declare function parseHeaderArgs(items?: string[]): Record<string, string> | undefined;
8
+ /**
9
+ * Contain credentials to the target origin. On the target origin, keep the
10
+ * request's own headers and add any static `--header`s. On any OTHER origin
11
+ * (cross-host redirect, https→http downgrade, different port), strip the
12
+ * credential headers the caller/SDK attached (incl. the OAuth bearer) and drop
13
+ * the static headers — mirroring how a browser drops `Authorization` across
14
+ * origins, so a redirect can't exfiltrate a token.
15
+ */
16
+ export declare function applyCredentialGate(rawHeaders: ConstructorParameters<typeof Headers>[0], sameOrigin: boolean, staticHeaders?: Record<string, string>): Headers;
@@ -0,0 +1,49 @@
1
+ /*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
2
+ /**
3
+ * Parse repeated `--header "Name: Value"` CLI arguments into a header map.
4
+ * Used to authenticate against protected remote MCP endpoints (e.g. a Bearer
5
+ * token). Kept pure and separate from the CLI entrypoint so it stays testable.
6
+ */
7
+ export function parseHeaderArgs(items) {
8
+ if (!items || items.length === 0)
9
+ return undefined;
10
+ const out = {};
11
+ for (const raw of items) {
12
+ const item = raw.trim();
13
+ const idx = item.indexOf(':');
14
+ if (idx <= 0)
15
+ throw new Error(`Invalid --header "${raw}" — expected "Name: Value".`);
16
+ const name = item.slice(0, idx).trim();
17
+ const value = item.slice(idx + 1).trim();
18
+ if (!name)
19
+ throw new Error(`Invalid --header "${raw}" — empty header name.`);
20
+ out[name] = value;
21
+ }
22
+ return out;
23
+ }
24
+ /** Credential headers that must never cross an origin boundary on a redirect. */
25
+ const CREDENTIAL_HEADERS = ['authorization', 'cookie', 'proxy-authorization', 'mcp-session-id'];
26
+ /**
27
+ * Contain credentials to the target origin. On the target origin, keep the
28
+ * request's own headers and add any static `--header`s. On any OTHER origin
29
+ * (cross-host redirect, https→http downgrade, different port), strip the
30
+ * credential headers the caller/SDK attached (incl. the OAuth bearer) and drop
31
+ * the static headers — mirroring how a browser drops `Authorization` across
32
+ * origins, so a redirect can't exfiltrate a token.
33
+ */
34
+ export function applyCredentialGate(rawHeaders, sameOrigin, staticHeaders) {
35
+ const headers = new Headers(rawHeaders);
36
+ if (sameOrigin) {
37
+ if (staticHeaders)
38
+ for (const [k, v] of Object.entries(staticHeaders))
39
+ headers.set(k, v);
40
+ }
41
+ else {
42
+ for (const h of CREDENTIAL_HEADERS)
43
+ headers.delete(h);
44
+ if (staticHeaders)
45
+ for (const k of Object.keys(staticHeaders))
46
+ headers.delete(k);
47
+ }
48
+ return headers;
49
+ }
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.0.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.0.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.0.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",
@@ -51,7 +51,7 @@
51
51
  "scripts": {
52
52
  "build": "tsc -p tsconfig.json",
53
53
  "typecheck": "tsc -p tsconfig.json --noEmit",
54
- "test": "node --import tsx --test \"test/**/*.test.ts\"",
54
+ "test": "node scripts/run-tests.mjs",
55
55
  "clean": "rm -rf dist",
56
56
  "prepublishOnly": "npm run clean && npm run build",
57
57
  "scan:self": "tsx src/cli/index.ts scan test/fixtures/clean-server.json",