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.
- package/README.md +10 -6
- 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/npm.d.ts +12 -4
- package/dist/acquire/npm.js +80 -8
- 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 +6 -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/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/acquire/npm.js
CHANGED
|
@@ -19,14 +19,47 @@ async function getJson(url, timeoutMs = 8000) {
|
|
|
19
19
|
clearTimeout(timer);
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
|
-
/**
|
|
23
|
-
|
|
22
|
+
/** Is `v` an exact version (not a range/tag) we can resolve to one artifact? */
|
|
23
|
+
function isExactVersion(v) {
|
|
24
|
+
return typeof v === 'string' && /^\d+\.\d+\.\d+/.test(v);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Fetch npm registry metadata for a package. Resolves `requested` when it names
|
|
28
|
+
* an exact published version (so a pinned `pkg@1.0.0` is analyzed, not `latest`);
|
|
29
|
+
* otherwise falls back to the `latest` dist-tag.
|
|
30
|
+
*/
|
|
31
|
+
export async function fetchNpmMeta(name, requested) {
|
|
24
32
|
const meta = { registry: 'npm', name };
|
|
25
33
|
const packument = (await getJson(`https://registry.npmjs.org/${encodeURIComponent(name).replace('%40', '@')}`));
|
|
26
34
|
if (packument && typeof packument === 'object') {
|
|
27
|
-
|
|
28
|
-
|
|
35
|
+
// `dist-tags.latest` is attacker-controlled: accept it ONLY as a string, so a
|
|
36
|
+
// non-primitive (array/object) can't become `meta.version`. A non-string
|
|
37
|
+
// version survives to the lockfile as `["1.0.0"]`, and after the JSON
|
|
38
|
+
// round-trip the `meta.version === entry.packageVersion` check can never be
|
|
39
|
+
// true — silently suppressing the MTC-TOFU-002 byte-level rug-pull finding.
|
|
40
|
+
const latest = typeof packument['dist-tags']?.latest === 'string' ? packument['dist-tags'].latest : undefined;
|
|
41
|
+
// `versions` is attacker-controlled; guard the `in` check so a non-object
|
|
42
|
+
// (string/number) can't throw a raw TypeError. On the client-config path an
|
|
43
|
+
// unguarded throw would unwind resolveTargets and make the CLI skip the whole
|
|
44
|
+
// config — hiding every sibling server. Fail closed instead.
|
|
45
|
+
const versions = packument.versions && typeof packument.versions === 'object' && !Array.isArray(packument.versions)
|
|
46
|
+
? packument.versions // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
47
|
+
: {};
|
|
48
|
+
// A pinned exact version that the registry does NOT list must NEVER silently
|
|
49
|
+
// fall back to `latest` — an attacker who controls the registry response
|
|
50
|
+
// could drop the pinned version and serve a malicious `latest`, defeating the
|
|
51
|
+
// byte pin (the requested version's artifact simply isn't resolved, and the
|
|
52
|
+
// substitution is flagged so it can't pass as a clean scan of the pin).
|
|
53
|
+
const wantExact = isExactVersion(requested);
|
|
54
|
+
const requestedMissing = wantExact && !Object.prototype.hasOwnProperty.call(versions, requested);
|
|
55
|
+
const version = wantExact ? requested : (latest ?? Object.keys(versions).pop());
|
|
29
56
|
meta.version = version;
|
|
57
|
+
if (requestedMissing) {
|
|
58
|
+
meta.requestedVersionMissing = true;
|
|
59
|
+
// Leave scripts/deps/artifact unresolved: there is no such published
|
|
60
|
+
// version to read. tarballUrl stays undefined → no source fetch / byte pin.
|
|
61
|
+
return meta;
|
|
62
|
+
}
|
|
30
63
|
const v = version ? packument.versions?.[version] : undefined;
|
|
31
64
|
if (v) {
|
|
32
65
|
meta.scripts = v.scripts ?? {};
|
|
@@ -34,6 +67,14 @@ export async function fetchNpmMeta(name) {
|
|
|
34
67
|
meta.license = typeof v.license === 'string' ? v.license : v.license == null ? null : String(v.license);
|
|
35
68
|
const repo = v.repository;
|
|
36
69
|
meta.repositoryUrl = typeof repo === 'string' ? repo : repo?.url ?? null;
|
|
70
|
+
// The published artifact: URL + registry-declared hash (SRI, or legacy sha1 hex).
|
|
71
|
+
meta.tarballUrl = typeof v.dist?.tarball === 'string' ? v.dist.tarball : null;
|
|
72
|
+
meta.tarballIntegrity =
|
|
73
|
+
typeof v.dist?.integrity === 'string'
|
|
74
|
+
? v.dist.integrity
|
|
75
|
+
: typeof v.dist?.shasum === 'string'
|
|
76
|
+
? `sha1:${v.dist.shasum}`
|
|
77
|
+
: null;
|
|
37
78
|
}
|
|
38
79
|
meta.publishedAt = version ? packument.time?.[version] ?? null : null;
|
|
39
80
|
}
|
|
@@ -42,14 +83,45 @@ export async function fetchNpmMeta(name) {
|
|
|
42
83
|
meta.weeklyDownloads = downloads?.downloads ?? null;
|
|
43
84
|
return meta;
|
|
44
85
|
}
|
|
45
|
-
/**
|
|
46
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Fetch PyPI metadata for a package. When `requested` is an exact version, the
|
|
88
|
+
* versioned JSON endpoint (`/pypi/<name>/<version>/json`) is used so a pinned
|
|
89
|
+
* spec is analyzed rather than the newest release.
|
|
90
|
+
*/
|
|
91
|
+
export async function fetchPypiMeta(name, requested) {
|
|
47
92
|
const meta = { registry: 'pypi', name };
|
|
48
|
-
const
|
|
93
|
+
const url = isExactVersion(requested)
|
|
94
|
+
? `https://pypi.org/pypi/${encodeURIComponent(name)}/${encodeURIComponent(requested)}/json`
|
|
95
|
+
: `https://pypi.org/pypi/${encodeURIComponent(name)}/json`;
|
|
96
|
+
const data = (await getJson(url));
|
|
49
97
|
if (data?.info) {
|
|
50
|
-
|
|
98
|
+
// For an exact pin the version-specific endpoint is authoritative for the
|
|
99
|
+
// version, so force the label to the REQUESTED version — never let an
|
|
100
|
+
// attacker-controlled `info.version` relabel the pin to a different release
|
|
101
|
+
// (the same silent-substitution hole the npm path refuses).
|
|
102
|
+
meta.version = isExactVersion(requested)
|
|
103
|
+
? requested
|
|
104
|
+
: typeof data.info.version === 'string'
|
|
105
|
+
? data.info.version
|
|
106
|
+
: undefined;
|
|
51
107
|
meta.license = data.info.license || null;
|
|
52
108
|
meta.repositoryUrl = data.info.project_urls?.Source ?? data.info.home_page ?? null;
|
|
109
|
+
// The published artifact: prefer the sdist (real source layout) over a wheel.
|
|
110
|
+
const files = Array.isArray(data.urls) ? data.urls : [];
|
|
111
|
+
const artifact = files.find((f) => f?.packagetype === 'sdist' && typeof f.url === 'string') ??
|
|
112
|
+
files.find((f) => f?.packagetype === 'bdist_wheel' && typeof f.url === 'string');
|
|
113
|
+
if (artifact) {
|
|
114
|
+
meta.tarballUrl = artifact.url;
|
|
115
|
+
meta.tarballIntegrity =
|
|
116
|
+
typeof artifact.digests?.sha256 === 'string' ? `sha256:${artifact.digests.sha256}` : null;
|
|
117
|
+
meta.publishedAt = typeof artifact.upload_time_iso_8601 === 'string' ? artifact.upload_time_iso_8601 : null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
else if (isExactVersion(requested)) {
|
|
121
|
+
// The exact version endpoint returned nothing: the pinned version is not
|
|
122
|
+
// resolvable (unpublished/yanked/hidden). Flag it rather than falling back.
|
|
123
|
+
meta.version = requested;
|
|
124
|
+
meta.requestedVersionMissing = true;
|
|
53
125
|
}
|
|
54
126
|
return meta;
|
|
55
127
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
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 type { PackageMeta, ServerSurface, SourceFile } from '../types.js';
|
|
19
|
+
export interface PackageSourceResult {
|
|
20
|
+
sourceFiles: SourceFile[];
|
|
21
|
+
/** Root-level sidecar files (package.json, tools.json, …) by base name. */
|
|
22
|
+
sidecars: Record<string, string>;
|
|
23
|
+
/** SHA-256 (hex) of the verified compressed artifact. */
|
|
24
|
+
tarballSha256: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Error kinds for a failed source read, so the caller can tell a SECURITY
|
|
28
|
+
* failure (bytes don't match the registry's declared hash, or the download was
|
|
29
|
+
* redirected off the allowlisted host — hard evidence of tampering) apart from
|
|
30
|
+
* a transient network failure (which must not be reported as an attack, but
|
|
31
|
+
* also must not silently pass off as "verified").
|
|
32
|
+
*/
|
|
33
|
+
export type PackageSourceErrorKind = 'integrity' | 'untrusted-redirect' | 'network' | 'other';
|
|
34
|
+
export declare class PackageSourceError extends Error {
|
|
35
|
+
readonly kind: PackageSourceErrorKind;
|
|
36
|
+
constructor(kind: PackageSourceErrorKind, message: string);
|
|
37
|
+
}
|
|
38
|
+
/** Validate that an artifact URL is https on the registry's own artifact host. */
|
|
39
|
+
export declare function assertTrustedTarballUrl(url: string, registry: 'npm' | 'pypi'): URL;
|
|
40
|
+
/**
|
|
41
|
+
* Verify a downloaded artifact against the registry-declared hash. Accepts SRI
|
|
42
|
+
* (`sha512-<base64>`, `sha1-<base64>`) and `<algo>:<hex>` forms. Fails closed:
|
|
43
|
+
* a declared hash that can't be parsed or doesn't match rejects the artifact.
|
|
44
|
+
* `null`/absent means the registry declared none — the artifact is still used
|
|
45
|
+
* (its own SHA-256 becomes the lockfile pin), which is the TOFU trade-off.
|
|
46
|
+
*/
|
|
47
|
+
export declare function verifyTarballIntegrity(buf: Buffer, expected: string | null | undefined): void;
|
|
48
|
+
/**
|
|
49
|
+
* Download with a hard byte cap enforced while streaming, and a timeout.
|
|
50
|
+
* Redirects are followed MANUALLY (cap 5) and every hop is re-validated through
|
|
51
|
+
* the same host allowlist — otherwise a 3xx from the registry could point the
|
|
52
|
+
* scanner at an arbitrary host (SSRF), and its bytes would be scanned and pinned
|
|
53
|
+
* as if they were the published source. Mirrors live.ts's guardedFetch posture.
|
|
54
|
+
*/
|
|
55
|
+
export declare function downloadCapped(url: URL, registry: 'npm' | 'pypi', fetchImpl?: typeof fetch): Promise<Buffer>;
|
|
56
|
+
/**
|
|
57
|
+
* Fetch, verify and extract the published source of a registry package.
|
|
58
|
+
* Throws a descriptive error on any trust failure (bad host, over-cap, hash
|
|
59
|
+
* mismatch); returns `null` when the metadata has no artifact URL to read.
|
|
60
|
+
*/
|
|
61
|
+
export declare function fetchPackageSource(meta: PackageMeta, fetchImpl?: typeof fetch): Promise<PackageSourceResult | null>;
|
|
62
|
+
/**
|
|
63
|
+
* Build a scan surface from a local packed artifact (.tgz / .tar.gz / .tar /
|
|
64
|
+
* .zip / .whl) — the file a release pipeline actually ships. Reads the
|
|
65
|
+
* implementation source and any package.json / sidecar manifest it contains;
|
|
66
|
+
* never writes to disk, never executes anything.
|
|
67
|
+
*/
|
|
68
|
+
export declare function surfaceFromArchiveFile(path: string): ServerSurface;
|
|
@@ -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
|
+
}
|
package/dist/acquire/source.d.ts
CHANGED
|
@@ -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;
|
package/dist/acquire/source.js
CHANGED
|
@@ -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
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
/**
|
|
63
|
-
function
|
|
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(
|
|
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
|
|
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
|
@@ -54,7 +54,8 @@ ${c.bold('Targets')}
|
|
|
54
54
|
--scope <scope> OAuth scope to request with --login (e.g. mcp:tools)
|
|
55
55
|
--header "Authorization: Bearer …" static auth header instead of --login (repeatable)
|
|
56
56
|
claude_desktop_config.json a client config (scans each server)
|
|
57
|
-
@scope/package
|
|
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)
|
|
58
59
|
--command "npx -y my-server" spawn a local stdio server (sandboxed)
|
|
59
60
|
--env KEY=VALUE env var for --command (repeatable)
|
|
60
61
|
|
|
@@ -75,7 +76,8 @@ ${c.bold('Options')}
|
|
|
75
76
|
--no-lock skip the integrity (rug-pull) check
|
|
76
77
|
--pin re-pin the lockfile after scanning
|
|
77
78
|
--include-builtins assume client built-in tools in toxic-flow analysis
|
|
78
|
-
--online allow network lookups
|
|
79
|
+
--online allow network lookups: registry metadata + the verified published source
|
|
80
|
+
--metadata-only with --online: skip downloading the package artifact
|
|
79
81
|
--run allow spawning servers found in a client config
|
|
80
82
|
--allow-any-command permit stdio commands outside the allowlist (dangerous)
|
|
81
83
|
--allowed-hosts <a,b> restrict live HTTP acquisition to these hosts
|
|
@@ -113,6 +115,7 @@ async function main() {
|
|
|
113
115
|
scope: { type: 'string' },
|
|
114
116
|
url: { type: 'string' },
|
|
115
117
|
online: { type: 'boolean' },
|
|
118
|
+
'metadata-only': { type: 'boolean' },
|
|
116
119
|
run: { type: 'boolean' },
|
|
117
120
|
'allow-any-command': { type: 'boolean' },
|
|
118
121
|
'allowed-hosts': { type: 'string' },
|
|
@@ -183,6 +186,7 @@ async function main() {
|
|
|
183
186
|
args: buildArgs(values.command, values.args),
|
|
184
187
|
url: values.url,
|
|
185
188
|
online: values.online,
|
|
189
|
+
metadataOnly: values['metadata-only'],
|
|
186
190
|
run: values.run,
|
|
187
191
|
envVars: parseEnvFlags(values.env),
|
|
188
192
|
allowAnyCommand: values['allow-any-command'],
|
package/dist/data/ruleCatalog.js
CHANGED
|
@@ -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)
|
package/dist/detectors/source.js
CHANGED
|
@@ -23,9 +23,18 @@ export const sourceDetector = {
|
|
|
23
23
|
stage: 4,
|
|
24
24
|
title: 'Implementation-level source analysis',
|
|
25
25
|
run(ctx) {
|
|
26
|
-
const
|
|
27
|
-
if (!
|
|
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;
|