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/README.md +14 -7
- package/dist/acquire/archive.d.ts +55 -0
- package/dist/acquire/archive.js +318 -0
- package/dist/acquire/index.d.ts +2 -0
- package/dist/acquire/index.js +89 -11
- package/dist/acquire/live.d.ts +14 -0
- package/dist/acquire/live.js +84 -13
- package/dist/acquire/npm.d.ts +12 -4
- package/dist/acquire/npm.js +80 -8
- package/dist/acquire/oauth.d.ts +49 -0
- package/dist/acquire/oauth.js +190 -0
- package/dist/acquire/packageSource.d.ts +68 -0
- package/dist/acquire/packageSource.js +258 -0
- package/dist/acquire/source.d.ts +10 -0
- package/dist/acquire/source.js +34 -11
- package/dist/cli/index.js +23 -2
- package/dist/data/ruleCatalog.js +4 -0
- package/dist/detectors/source.js +11 -2
- package/dist/detectors/supplyChain.d.ts +2 -0
- package/dist/detectors/supplyChain.js +62 -0
- package/dist/engine.js +40 -16
- package/dist/lockfile.d.ts +11 -0
- package/dist/lockfile.js +64 -2
- package/dist/types.d.ts +23 -1
- package/dist/util/headers.d.ts +16 -0
- package/dist/util/headers.js +49 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
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]:
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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",
|