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.
@@ -19,14 +19,47 @@ async function getJson(url, timeoutMs = 8000) {
19
19
  clearTimeout(timer);
20
20
  }
21
21
  }
22
- /** Fetch npm registry metadata for a package (latest dist-tag). */
23
- export async function fetchNpmMeta(name) {
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
- const latest = packument['dist-tags']?.latest;
28
- const version = latest ?? Object.keys(packument.versions ?? {}).pop();
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
- /** Fetch PyPI metadata for a package. */
46
- export async function fetchPypiMeta(name) {
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 data = (await getJson(`https://pypi.org/pypi/${encodeURIComponent(name)}/json`));
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
- meta.version = data.info.version;
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
+ }
@@ -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
@@ -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 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)
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 for package metadata
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'],
@@ -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;