mcptrustchecker 1.1.0 β†’ 1.3.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 CHANGED
@@ -9,10 +9,11 @@
9
9
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
10
10
  [![Node](https://img.shields.io/badge/node-%E2%89%A520-3c873a.svg)](package.json)
11
11
  [![Methodology](https://img.shields.io/badge/methodology-mcptrustchecker--1.0-6f42c1.svg)](docs/methodology.md)
12
- [![Tests](https://img.shields.io/badge/tests-211%20passing-brightgreen.svg)](test)
13
- [![Rules](https://img.shields.io/badge/rules-74-orange.svg)](docs/rules.md)
12
+ [![Tests](https://img.shields.io/badge/tests-304%20passing-brightgreen.svg)](test)
13
+ [![Rules](https://img.shields.io/badge/rules-78-orange.svg)](docs/rules.md)
14
14
  [![No account](https://img.shields.io/badge/account-not%20required-brightgreen.svg)](#why-this-is-different)
15
15
  [![Offline](https://img.shields.io/badge/runs-100%25%20offline-brightgreen.svg)](#why-this-is-different)
16
+ [![Free hosted API](https://img.shields.io/badge/hosted%20API-free-c4f542.svg)](https://mcptrustchecker.com/api)
16
17
 
17
18
  <br/>
18
19
 
@@ -24,7 +25,9 @@
24
25
  npx mcptrustchecker # πŸ” scan every MCP server you already have installed β€” zero config
25
26
  ```
26
27
 
27
- <sub>Β· offline Β· deterministic Β· no account Β· OAuth browser login for protected servers Β· <a href="#the-algorithm-the-capability-flow-trust-model">one novel core</a> Β·</sub>
28
+ **Zero install?** Run the same deterministic engine as a **free public web API β†’ [mcptrustchecker.com/api](https://mcptrustchecker.com/api)**
29
+
30
+ <sub>Β· offline Β· deterministic Β· no account Β· OAuth browser login for protected servers Β· reads the real published npm/PyPI source Β· <a href="#the-algorithm-the-capability-flow-trust-model">one novel core</a> Β·</sub>
28
31
 
29
32
  </div>
30
33
 
@@ -74,7 +77,9 @@ MCP Trust Checker's wedge is **accuracy + explainability + privacy**, with one g
74
77
  - πŸ” **Scans protected remote servers** β€” `--login` runs the full **OAuth 2.0 browser sign-in** (discovery β†’ dynamic client registration β†’ PKCE β†’ token), so it can audit auth-gated remote MCP endpoints, not just public ones β€” something most scanners can't do. (Or pass a static `--header "Authorization: Bearer …"`.) Tokens stay in memory for the scan only.
75
78
  - 🎯 **Deterministic** β€” same input β‡’ byte-identical score, on every run and every machine.
76
79
  - πŸ•ΈοΈ **Cross-tool toxic-flow graph** β€” proves the lethal trifecta statically, composed across tools, not just within one.
77
- - πŸ”¬ **Reads the code, not just the claim** β€” when the server's source is available (`scan ./path`), it grades what the implementation *does* (eval / shell-spawn / hardcoded egress / credential reads / obfuscated payloads), so a poisoned server can't hide behind honest-looking tool descriptions. Metadata **and** implementation, in one deterministic pass.
80
+ - πŸ”¬ **Reads the code, not just the claim** β€” it grades what the implementation *does* (eval / shell-spawn / hardcoded egress / credential reads / obfuscated payloads), so a poisoned server can't hide behind honest-looking tool descriptions. Metadata **and** implementation, in one deterministic pass.
81
+ - πŸ“₯ **Deep-scans the actual published package** β€” `scan <name> --online` fetches the npm/PyPI artifact, verifies it against the registry-declared hash, and runs the full source-analysis engine on the exact bytes `npx`/`pip` would install β€” **in memory, without installing or executing anything**. Also scans packed release artifacts directly: `scan ./server.tgz`, `.whl`, `.zip`.
82
+ - 🧬 **Byte-level rug-pull detection** β€” the verified artifact's SHA-256 is pinned in the lockfile; if the *same version* is ever republished with different bytes, the rescan raises a `critical`, `confirmed` finding (`MTC-TOFU-002`) β€” the attack an unchanged tool surface hides from every metadata-only check.
78
83
  - πŸ”Ž **Decodes, not strips** β€” hidden Unicode payloads (Tags block / variation-selector) are recovered and shown as evidence.
79
84
  - πŸ“Œ **Rug-pull integrity** β€” the full tool surface is hashed and pinned; any post-approval drift trips a `confirmed` finding with a per-tool diff.
80
85
  - 🧾 **Auditable Trust Score** β€” every point is a published, reproducible penalty vector.
@@ -128,7 +133,9 @@ mcptrustchecker scan --command "npx -y @some/mcp-server" # a local stdio serve
128
133
  mcptrustchecker scan https://mcp.example.com/mcp # a live HTTP/SSE endpoint
129
134
  mcptrustchecker scan https://mcp.example.com/mcp --login # an OAuth-protected endpoint (browser sign-in)
130
135
  mcptrustchecker scan https://mcp.example.com/mcp --header "Authorization: Bearer <token>" # static auth
131
- mcptrustchecker scan @modelcontextprotocol/server-filesystem --online # a package name (typosquat/CVE)
136
+ mcptrustchecker scan @modelcontextprotocol/server-filesystem --online # fetches + verifies + reads the PUBLISHED source
137
+ mcptrustchecker scan mcp-server-fetch --online --registry pypi # same for PyPI (sdist/wheel)
138
+ mcptrustchecker scan ./server-1.2.0.tgz # a packed release artifact (.tgz/.whl/.zip) β€” offline
132
139
  ```
133
140
 
134
141
  **Outputs & CI gates:**
@@ -144,6 +151,8 @@ mcptrustchecker scan ./tools.json --min-grade B # exit 1 below a gra
144
151
 
145
152
  The terminal report is **detailed by default** β€” every finding prints its full description (**what** the problem is and **why** it matters), the exact location, the offending **evidence**, a **fix**, and its OWASP mapping, grouped most-severe-first. Add `--details` for external references, or `--quiet` for just the grade line.
146
153
 
154
+ **No install? Use the free hosted API.** A hosted version of the exact same deterministic engine runs at **[mcptrustchecker.com/api](https://mcptrustchecker.com/api)** β€” POST a `tools.json` manifest or a published npm/PyPI package name over a single HTTPS request and get the same auditable A–F Trust Score back as JSON. It is **completely free** (grab a key in seconds, no card, no account), and it's the quickest way to try the scanner without installing anything. The CLI and library stay 100% offline β€” the hosted API is an optional convenience.
155
+
147
156
  ---
148
157
 
149
158
  ## The algorithm: the Capability-Flow Trust Model
@@ -238,20 +247,22 @@ Full list: **[docs/rules.md](docs/rules.md)** Β· run `mcptrustchecker rules`.
238
247
 
239
248
  ---
240
249
 
241
- ## Two axes: Trust (grade) and Capability (blast radius)
250
+ ## Three axes: Trust (grade), Capability (blast radius), Coverage (depth)
242
251
 
243
- A single number can't answer "should I use this server?" β€” because **"powerful" and "malicious" are different questions.** A web scraper legitimately needs to fetch untrusted pages and act on them; that's a large *blast radius*, not evidence it's a bad actor. So MCP Trust Checker reports two independent things:
252
+ A single number can't answer "should I use this server?" β€” because **"powerful" and "malicious" are different questions**, and a clean grade means nothing if the scan barely saw the server. So MCP Trust Checker reports three independent things:
244
253
 
245
254
  - **Trust β€” the A–F grade.** Driven by *threat* signals: prompt-injection with concealment, embedded secrets, Unicode smuggling, typosquatting, known CVEs, rug-pull drift, annotation lies, a single tool built as an exfiltration primitive. Answers **"any sign this server is malicious or negligent?"**
246
255
  - **Capability β€” a level (Minimal β†’ Critical).** Driven by what the server *can do*: code execution, filesystem writes, network egress, the cross-tool toxic-flow surface. Answers **"how much damage if the model driving it is manipulated?"** β€” a fact to size access against, **not** a mark against the server.
256
+ - **Coverage β€” a level (Live β†’ Source β†’ Manifest β†’ Metadata β†’ Empty).** How much the scan could actually inspect. A Grade A from a `metadata`-only scan (no tools enumerated, no code read) is **not** the assurance of a Grade A from a `live` scan that saw the real runtime tools. Coverage never changes the score β€” it states the score's reach, so a shallow scan is never mistaken for a clean bill of health. When coverage is partial, the report says exactly what it could not see.
247
257
 
248
258
  ```
249
- firecrawl Trust B (81/100) Capability CRITICAL β†’ trustworthy, but huge blast radius β€” grant carefully
250
- poisoned Trust F Capability HIGH β†’ actual malice signals β€” avoid
251
- memory Trust A (100) Capability MINIMAL β†’ safe and low-power
259
+ firecrawl Trust B (81/100) Capability CRITICAL Coverage LIVE β†’ trustworthy, but huge blast radius β€” grant carefully
260
+ poisoned Trust F Capability HIGH Coverage LIVE β†’ actual malice signals β€” avoid
261
+ memory Trust A (100) Capability MINIMAL Coverage LIVE β†’ safe and low-power
262
+ some-pkg Trust A (98) Capability MINIMAL Coverage METADATA β†’ clean *so far* β€” 0 tools seen; scan the live server
252
263
  ```
253
264
 
254
- This is why MCP Trust Checker doesn't collapse every capable server into "F" (which would make the grade useless). Popularity is never an input β€” popular packages get compromised β€” but a legitimate powerful server keeps a high Trust grade while its Capability is surfaced honestly.
265
+ This is why MCP Trust Checker doesn't collapse every capable server into "F" (which would make the grade useless). Popularity is never an input β€” popular packages get compromised β€” but a legitimate powerful server keeps a high Trust grade while its Capability and the scan's Coverage are surfaced honestly.
255
266
 
256
267
  ## The Trust grade is auditable by construction
257
268
 
@@ -364,7 +375,7 @@ Connecting to an MCP config can run arbitrary commands. MCP Trust Checker's acqu
364
375
 
365
376
  - **[docs/methodology.md](docs/methodology.md)** β€” every pipeline stage in depth
366
377
  - **[docs/scoring.md](docs/scoring.md)** β€” the scoring model & reproducibility contract
367
- - **[docs/rules.md](docs/rules.md)** β€” the complete 74-rule catalogue
378
+ - **[docs/rules.md](docs/rules.md)** β€” the complete 78-rule catalogue
368
379
  - **[docs/architecture.md](docs/architecture.md)** β€” code layout & how to extend
369
380
  - **[docs/ci-integration.md](docs/ci-integration.md)** β€” Action, SARIF, baselines
370
381
  - **[SECURITY.md](SECURITY.md)** Β· **[CONTRIBUTING.md](CONTRIBUTING.md)**
@@ -0,0 +1,55 @@
1
+ /*! MCP Trust Checker Β· https://mcptrustchecker.com Β· support@mcptrustchecker.com Β· Β© 2026 Illia Haidar Β· MIT */
2
+ /**
3
+ * Dependency-free, in-memory archive readers (ustar/pax tar, gzip, zip) so the
4
+ * scanner can read the *published bytes* of a package β€” npm tarballs, PyPI
5
+ * sdists and wheels, packed release artifacts β€” without ever writing them to
6
+ * disk or executing anything. Every reader is bounded (entry count, per-file
7
+ * size, total size, decompressed size) so a hostile archive (zip bomb, header
8
+ * spoofing, path traversal) can degrade a scan, not the machine running it.
9
+ */
10
+ export interface ArchiveEntry {
11
+ /** Normalized relative path inside the archive (always `/`-separated). */
12
+ path: string;
13
+ data: Buffer;
14
+ }
15
+ export interface ArchiveLimits {
16
+ maxFiles: number;
17
+ maxFileBytes: number;
18
+ maxTotalBytes: number;
19
+ }
20
+ /** Decompressed-archive safety cap: a .tgz may not inflate past this. */
21
+ export declare const MAX_UNPACKED_BYTES: number;
22
+ /**
23
+ * Normalize an archive member path and reject anything that could mislead a
24
+ * report or (defense-in-depth) escape a directory: `..` segments, drive
25
+ * letters, NUL bytes. Absolute paths are made relative β€” entries are only ever
26
+ * held in memory, the path just labels evidence.
27
+ */
28
+ export declare function safeEntryPath(raw: string): string | null;
29
+ /** Gunzip with a hard output cap so a gzip bomb can't exhaust memory. */
30
+ export declare function gunzipBounded(buf: Buffer, maxBytes?: number): Buffer;
31
+ /**
32
+ * Read regular files out of a (already-decompressed) ustar/pax tar buffer.
33
+ * Handles POSIX prefix fields, pax `path` overrides and GNU longnames; skips
34
+ * directories, links and device nodes; stops on the first malformed header
35
+ * rather than guessing at offsets.
36
+ */
37
+ export declare function extractTar(tar: Buffer, wanted: (path: string) => boolean, limits: ArchiveLimits): ArchiveEntry[];
38
+ /**
39
+ * Read regular files out of a zip buffer (PyPI wheels, .zip sdists, .mcpb-style
40
+ * bundles) via the central directory. Supports stored and deflate entries;
41
+ * skips encrypted entries and directories; zip64 archives are rejected (a
42
+ * package artifact has no business being >4 GB).
43
+ */
44
+ export declare function extractZip(zip: Buffer, wanted: (path: string) => boolean, limits: ArchiveLimits): ArchiveEntry[];
45
+ export type ArchiveKind = 'tar' | 'tgz' | 'zip';
46
+ /** Sniff the archive container by magic bytes, falling back to the file name. */
47
+ export declare function detectArchiveKind(buf: Buffer, nameHint?: string): ArchiveKind;
48
+ /** One entry point: sniff the container, decompress if needed, extract. */
49
+ export declare function extractArchive(buf: Buffer, nameHint: string, wanted: (path: string) => boolean, limits: ArchiveLimits): ArchiveEntry[];
50
+ /**
51
+ * Strip the single shared top-level directory when every entry lives under one
52
+ * (npm's `package/`, a PyPI sdist's `name-version/`) so paths read like the
53
+ * package root. Archives with multiple roots (wheels) are left untouched.
54
+ */
55
+ export declare function stripCommonRoot(entries: ArchiveEntry[]): ArchiveEntry[];
@@ -0,0 +1,318 @@
1
+ /*! MCP Trust Checker Β· https://mcptrustchecker.com Β· support@mcptrustchecker.com Β· Β© 2026 Illia Haidar Β· MIT */
2
+ /**
3
+ * Dependency-free, in-memory archive readers (ustar/pax tar, gzip, zip) so the
4
+ * scanner can read the *published bytes* of a package β€” npm tarballs, PyPI
5
+ * sdists and wheels, packed release artifacts β€” without ever writing them to
6
+ * disk or executing anything. Every reader is bounded (entry count, per-file
7
+ * size, total size, decompressed size) so a hostile archive (zip bomb, header
8
+ * spoofing, path traversal) can degrade a scan, not the machine running it.
9
+ */
10
+ import { gunzipSync, inflateRawSync } from 'node:zlib';
11
+ /** Decompressed-archive safety cap: a .tgz may not inflate past this. */
12
+ export const MAX_UNPACKED_BYTES = 200 * 1024 * 1024; // 200 MB
13
+ const TAR_BLOCK = 512;
14
+ /**
15
+ * Normalize an archive member path and reject anything that could mislead a
16
+ * report or (defense-in-depth) escape a directory: `..` segments, drive
17
+ * letters, NUL bytes. Absolute paths are made relative β€” entries are only ever
18
+ * held in memory, the path just labels evidence.
19
+ */
20
+ export function safeEntryPath(raw) {
21
+ const norm = raw.replace(/\\/g, '/');
22
+ if (!norm || norm.includes('\0'))
23
+ return null;
24
+ const parts = norm.split('/').filter((p) => p !== '' && p !== '.');
25
+ if (parts.length === 0)
26
+ return null;
27
+ if (parts.some((p) => p === '..'))
28
+ return null;
29
+ if (/^[a-zA-Z]:$/.test(parts[0]))
30
+ return null;
31
+ return parts.join('/');
32
+ }
33
+ /** Gunzip with a hard output cap so a gzip bomb can't exhaust memory. */
34
+ export function gunzipBounded(buf, maxBytes = MAX_UNPACKED_BYTES) {
35
+ try {
36
+ return gunzipSync(buf, { maxOutputLength: maxBytes });
37
+ }
38
+ catch (err) {
39
+ throw new Error(`Failed to decompress gzip data (corrupt, or larger than the ${Math.round(maxBytes / 1024 / 1024)} MB safety cap): ${err.message}`);
40
+ }
41
+ }
42
+ /** Octal (or GNU base-256) numeric field from a tar header. NaN on garbage. */
43
+ function tarNumber(header, off, len) {
44
+ if (header[off] & 0x80) {
45
+ // GNU base-256 for values that don't fit in octal.
46
+ let v = header[off] & 0x7f;
47
+ for (let i = 1; i < len; i++)
48
+ v = v * 256 + header[off + i];
49
+ return v;
50
+ }
51
+ const s = header.toString('ascii', off, off + len).replace(/\0[\s\S]*$/, '').trim();
52
+ if (!s)
53
+ return 0;
54
+ const v = parseInt(s, 8);
55
+ return Number.isFinite(v) ? v : NaN;
56
+ }
57
+ function tarString(header, off, len) {
58
+ return header.toString('utf8', off, off + len).replace(/\0[\s\S]*$/, '');
59
+ }
60
+ /**
61
+ * Only these pax keys can override an entry's path β€” the rest are irrelevant to
62
+ * source extraction, so we never build a map of attacker-chosen keys (a hostile
63
+ * header can otherwise carry millions of distinct keys; see MAX_PAX_RECORDS).
64
+ */
65
+ const PAX_WANTED_KEYS = new Set(['path', 'linkpath']);
66
+ /** Hard cap on records parsed from one pax header (defense-in-depth vs. key-flooding). */
67
+ const MAX_PAX_RECORDS = 4096;
68
+ /** Parse the pax keys we care about from a header body: `"<len> <key>=<value>\n"`. */
69
+ function parsePax(body) {
70
+ const out = {};
71
+ let off = 0;
72
+ let records = 0;
73
+ while (off < body.length && records < MAX_PAX_RECORDS) {
74
+ const sp = body.indexOf(0x20, off);
75
+ if (sp < 0)
76
+ break;
77
+ const len = parseInt(body.toString('ascii', off, sp), 10);
78
+ if (!Number.isFinite(len) || len <= 0 || off + len > body.length)
79
+ break;
80
+ records++;
81
+ const eq = body.indexOf(0x3d, sp + 1); // '='
82
+ if (eq > 0 && eq < off + len) {
83
+ const key = body.toString('utf8', sp + 1, eq);
84
+ // Only materialize values for keys that can affect extraction.
85
+ if (PAX_WANTED_KEYS.has(key))
86
+ out[key] = body.toString('utf8', eq + 1, off + len).replace(/\n$/, '');
87
+ }
88
+ off += len;
89
+ }
90
+ return out;
91
+ }
92
+ /**
93
+ * Read regular files out of a (already-decompressed) ustar/pax tar buffer.
94
+ * Handles POSIX prefix fields, pax `path` overrides and GNU longnames; skips
95
+ * directories, links and device nodes; stops on the first malformed header
96
+ * rather than guessing at offsets.
97
+ */
98
+ export function extractTar(tar, wanted, limits) {
99
+ const out = [];
100
+ let total = 0;
101
+ let off = 0;
102
+ let gnuLongName;
103
+ let paxPath;
104
+ while (off + TAR_BLOCK <= tar.length) {
105
+ const header = tar.subarray(off, off + TAR_BLOCK);
106
+ if (header.every((b) => b === 0))
107
+ break; // end-of-archive marker
108
+ const size = tarNumber(header, 124, 12);
109
+ if (!Number.isFinite(size) || size < 0 || off + TAR_BLOCK + size > tar.length)
110
+ break;
111
+ const dataStart = off + TAR_BLOCK;
112
+ const next = dataStart + Math.ceil(size / TAR_BLOCK) * TAR_BLOCK;
113
+ const type = String.fromCharCode(header[156]);
114
+ // A metadata block (GNU longname / pax header) precedes a real entry and is
115
+ // discarded after one use. Its `size` is bounded only by the archive length,
116
+ // so a hostile ~190 MB longname/pax body would be materialized OUTSIDE the
117
+ // per-file cap β€” cap it here (a legitimate path/header is far under 512 KB).
118
+ if (type === 'L' || type === 'K') {
119
+ // GNU longname/longlink: the data block holds the NEXT entry's path.
120
+ if (size <= limits.maxFileBytes && type === 'L') {
121
+ gnuLongName = tar.subarray(dataStart, dataStart + size).toString('utf8').replace(/\0[\s\S]*$/, '');
122
+ }
123
+ off = next;
124
+ continue;
125
+ }
126
+ if (type === 'x' || type === 'g') {
127
+ // pax extended header ('g' is global β€” applied conservatively to the next entry only).
128
+ if (size <= limits.maxFileBytes) {
129
+ const pax = parsePax(tar.subarray(dataStart, dataStart + size));
130
+ if (type === 'x' && pax.path)
131
+ paxPath = pax.path;
132
+ }
133
+ off = next;
134
+ continue;
135
+ }
136
+ let name = tarString(header, 0, 100);
137
+ const prefix = tarString(header, 345, 155);
138
+ if (prefix)
139
+ name = `${prefix}/${name}`;
140
+ if (gnuLongName)
141
+ name = gnuLongName;
142
+ if (paxPath)
143
+ name = paxPath; // pax path wins over both
144
+ gnuLongName = undefined;
145
+ paxPath = undefined;
146
+ // '0' and NUL are regular files; everything else (dirs, links, devices) is skipped.
147
+ if (type === '0' || type === '\0') {
148
+ const path = safeEntryPath(name);
149
+ if (path && size <= limits.maxFileBytes && wanted(path)) {
150
+ if (out.length >= limits.maxFiles || total + size > limits.maxTotalBytes)
151
+ return out;
152
+ out.push({ path, data: Buffer.from(tar.subarray(dataStart, dataStart + size)) });
153
+ total += size;
154
+ }
155
+ }
156
+ off = next;
157
+ }
158
+ return out;
159
+ }
160
+ const ZIP_EOCD_SIG = 0x06054b50;
161
+ const ZIP_CDIR_SIG = 0x02014b50;
162
+ const ZIP_LOCAL_SIG = 0x04034b50;
163
+ function findEocd(zip) {
164
+ const floor = Math.max(0, zip.length - 22 - 65535);
165
+ for (let i = zip.length - 22; i >= floor; i--) {
166
+ if (zip.readUInt32LE(i) === ZIP_EOCD_SIG)
167
+ return i;
168
+ }
169
+ return -1;
170
+ }
171
+ /**
172
+ * Read regular files out of a zip buffer (PyPI wheels, .zip sdists, .mcpb-style
173
+ * bundles) via the central directory. Supports stored and deflate entries;
174
+ * skips encrypted entries and directories; zip64 archives are rejected (a
175
+ * package artifact has no business being >4 GB).
176
+ */
177
+ export function extractZip(zip, wanted, limits) {
178
+ const eocd = findEocd(zip);
179
+ if (eocd < 0)
180
+ throw new Error('Not a zip archive (no end-of-central-directory record).');
181
+ const count = zip.readUInt16LE(eocd + 10);
182
+ const cdOffset = zip.readUInt32LE(eocd + 16);
183
+ if (count === 0xffff || cdOffset === 0xffffffff)
184
+ throw new Error('zip64 archives are not supported.');
185
+ const out = [];
186
+ // Three independent budgets bound a hostile zip's work:
187
+ // β€’ `total` β€” decompression OUTPUT (charged even for a discarded
188
+ // over-cap inflate), so an output-bomb can't bypass it;
189
+ // β€’ `inflatedBytes` β€” compressed INPUT actually fed to zlib, so a stream that
190
+ // inflates to ~0 output (empty stored blocks) but reads a
191
+ // huge compressed region still hits a ceiling β€” the input
192
+ // work amplified by many CD entries pointing at one blob;
193
+ // β€’ `seen` offsets β€” a physical stream is inflated AT MOST ONCE, so N central-
194
+ // directory entries aliasing one local header do NΓ— nothing.
195
+ // `processed` additionally bounds the entry count. Any single guard tripping
196
+ // stops extraction, so worst-case CPU is bounded regardless of archive shape.
197
+ let total = 0;
198
+ let inflatedBytes = 0;
199
+ let processed = 0;
200
+ const seen = new Set();
201
+ let p = cdOffset;
202
+ for (let i = 0; i < count && p + 46 <= zip.length; i++) {
203
+ if (zip.readUInt32LE(p) !== ZIP_CDIR_SIG)
204
+ break;
205
+ const flags = zip.readUInt16LE(p + 8);
206
+ const method = zip.readUInt16LE(p + 10);
207
+ const compSize = zip.readUInt32LE(p + 20);
208
+ const uncompSize = zip.readUInt32LE(p + 24);
209
+ const nameLen = zip.readUInt16LE(p + 28);
210
+ const extraLen = zip.readUInt16LE(p + 30);
211
+ const commentLen = zip.readUInt16LE(p + 32);
212
+ const localOff = zip.readUInt32LE(p + 42);
213
+ const rawName = zip.toString('utf8', p + 46, p + 46 + nameLen);
214
+ p += 46 + nameLen + extraLen + commentLen;
215
+ if (flags & 0x0041)
216
+ continue; // encrypted (bit 0) / strong encryption (bit 6)
217
+ if (rawName.endsWith('/'))
218
+ continue; // directory
219
+ const path = safeEntryPath(rawName);
220
+ // Cheap rejections (bad path, declared over-cap, unwanted, over-budget
221
+ // compressed size, or an already-inflated stream) cost no decompression.
222
+ if (!path || uncompSize > limits.maxFileBytes || !wanted(path))
223
+ continue;
224
+ if (compSize > limits.maxTotalBytes || seen.has(localOff))
225
+ continue;
226
+ // Stop before any more work once ANY budget is spent.
227
+ if (processed >= limits.maxFiles || total >= limits.maxTotalBytes || inflatedBytes >= limits.maxTotalBytes)
228
+ break;
229
+ // Local header re-read: its name/extra lengths can differ from the central directory's.
230
+ if (localOff + 30 > zip.length || zip.readUInt32LE(localOff) !== ZIP_LOCAL_SIG)
231
+ continue;
232
+ const lNameLen = zip.readUInt16LE(localOff + 26);
233
+ const lExtraLen = zip.readUInt16LE(localOff + 28);
234
+ const dataOff = localOff + 30 + lNameLen + lExtraLen;
235
+ if (dataOff + compSize > zip.length)
236
+ continue;
237
+ const comp = zip.subarray(dataOff, dataOff + compSize);
238
+ // Bound each inflate by the smaller of the per-file cap and the remaining
239
+ // output budget; charge both the output produced and the input consumed.
240
+ seen.add(localOff);
241
+ processed++;
242
+ inflatedBytes += compSize;
243
+ const budget = Math.min(limits.maxFileBytes, limits.maxTotalBytes - total);
244
+ let data;
245
+ if (method === 0) {
246
+ if (compSize !== uncompSize || uncompSize > budget) {
247
+ total += Math.min(compSize, budget);
248
+ continue;
249
+ }
250
+ data = Buffer.from(comp);
251
+ }
252
+ else if (method === 8) {
253
+ try {
254
+ data = inflateRawSync(comp, { maxOutputLength: budget });
255
+ }
256
+ catch {
257
+ total += budget; // over-cap or corrupt: charge the bounded work we just did
258
+ continue;
259
+ }
260
+ }
261
+ else {
262
+ continue; // unsupported compression method β€” no work done
263
+ }
264
+ if (data.length > limits.maxFileBytes) {
265
+ total += data.length;
266
+ continue;
267
+ }
268
+ out.push({ path, data });
269
+ total += data.length;
270
+ }
271
+ return out;
272
+ }
273
+ /** Sniff the archive container by magic bytes, falling back to the file name. */
274
+ export function detectArchiveKind(buf, nameHint = '') {
275
+ if (buf.length >= 2 && buf[0] === 0x1f && buf[1] === 0x8b)
276
+ return 'tgz';
277
+ if (buf.length >= 4 && buf.readUInt32LE(0) === ZIP_LOCAL_SIG)
278
+ return 'zip';
279
+ if (buf.length >= 22 && findEocd(buf) >= 0)
280
+ return 'zip';
281
+ if (buf.length >= 265 && buf.toString('ascii', 257, 262) === 'ustar')
282
+ return 'tar';
283
+ const lower = nameHint.toLowerCase();
284
+ if (lower.endsWith('.zip') || lower.endsWith('.whl'))
285
+ return 'zip';
286
+ if (lower.endsWith('.tar'))
287
+ return 'tar';
288
+ return 'tgz';
289
+ }
290
+ /** One entry point: sniff the container, decompress if needed, extract. */
291
+ export function extractArchive(buf, nameHint, wanted, limits) {
292
+ const kind = detectArchiveKind(buf, nameHint);
293
+ if (kind === 'zip')
294
+ return extractZip(buf, wanted, limits);
295
+ const tar = kind === 'tgz' ? gunzipBounded(buf) : buf;
296
+ return extractTar(tar, wanted, limits);
297
+ }
298
+ /**
299
+ * Strip the single shared top-level directory when every entry lives under one
300
+ * (npm's `package/`, a PyPI sdist's `name-version/`) so paths read like the
301
+ * package root. Archives with multiple roots (wheels) are left untouched.
302
+ */
303
+ export function stripCommonRoot(entries) {
304
+ if (entries.length === 0)
305
+ return entries;
306
+ let root;
307
+ for (const e of entries) {
308
+ const slash = e.path.indexOf('/');
309
+ if (slash <= 0)
310
+ return entries; // a top-level file β€” nothing to strip
311
+ const first = e.path.slice(0, slash);
312
+ if (root === undefined)
313
+ root = first;
314
+ else if (root !== first)
315
+ return entries; // multiple roots β€” leave as-is
316
+ }
317
+ return entries.map((e) => ({ path: e.path.slice(root.length + 1), data: e.data }));
318
+ }
@@ -15,6 +15,8 @@ export interface ResolveOptions extends LiveOptions {
15
15
  registry?: 'npm' | 'pypi';
16
16
  /** Environment variables to pass to a `--command` stdio server. */
17
17
  envVars?: Record<string, string>;
18
+ /** With `--online`: skip downloading the published artifact (metadata checks only). */
19
+ metadataOnly?: boolean;
18
20
  }
19
21
  export interface ResolvedTarget {
20
22
  label: string;
@@ -10,16 +10,37 @@ import { surfaceFromPackageDir } from './source.js';
10
10
  import { acquireHttp, acquireStdio, ALLOWED_COMMANDS } from './live.js';
11
11
  import { expandVars, isClientConfig, packageSpecFromStdio, parseClientConfig, redactSensitiveArgs, } from './clientConfig.js';
12
12
  import { fetchNpmMeta, fetchPypiMeta } from './npm.js';
13
+ import { fetchPackageSource, surfaceFromArchiveFile, PackageSourceError } from './packageSource.js';
13
14
  const NPM_NAME = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i;
14
15
  const HTTP_RE = /^https?:\/\//i;
16
+ /** Split a `name` / `name@version` / `@scope/name@version` target. */
17
+ function parsePackageTarget(target) {
18
+ const scoped = target.startsWith('@');
19
+ const at = target.indexOf('@', scoped ? 1 : 0);
20
+ if (at <= 0)
21
+ return { name: target };
22
+ return { name: target.slice(0, at), version: target.slice(at + 1) || undefined };
23
+ }
15
24
  function commandBase(command) {
16
25
  const cleaned = command.replace(/\\/g, '/');
17
26
  return cleaned.slice(cleaned.lastIndexOf('/') + 1);
18
27
  }
19
- async function metaFor(name, opts) {
28
+ /** Map a stdio runner command to the registry that runner installs from. */
29
+ function registryForRunner(command) {
30
+ if (!command)
31
+ return undefined;
32
+ const base = command.replace(/\\/g, '/');
33
+ const runner = base.slice(base.lastIndexOf('/') + 1).replace(/\.(cmd|exe)$/, '');
34
+ if (runner === 'uvx' || runner === 'pipx' || runner === 'pip' || runner === 'pip3' || runner.startsWith('python'))
35
+ return 'pypi';
36
+ if (runner === 'npx' || runner === 'bunx' || runner === 'pnpm' || runner === 'yarn' || runner === 'node')
37
+ return 'npm';
38
+ return undefined;
39
+ }
40
+ async function metaFor(name, opts, registry, version) {
20
41
  if (!opts.online)
21
- return { registry: opts.registry ?? 'npm', name };
22
- return opts.registry === 'pypi' ? fetchPypiMeta(name) : fetchNpmMeta(name);
42
+ return { registry, name };
43
+ return registry === 'pypi' ? fetchPypiMeta(name, version) : fetchNpmMeta(name, version);
23
44
  }
24
45
  function packageSurface(name, meta, origin) {
25
46
  return {
@@ -32,6 +53,38 @@ function packageSurface(name, meta, origin) {
32
53
  packageMeta: meta,
33
54
  };
34
55
  }
56
+ /**
57
+ * Fetch + verify the package's published artifact and attach its real source to
58
+ * the surface (the deep half of an `--online` package scan).
59
+ *
60
+ * Failure handling is fail-CLOSED for tampering and fail-OPEN for outages, but
61
+ * NEVER silent: a hash mismatch or an off-allowlist redirect (hard tamper
62
+ * evidence) is recorded on the surface so a detector raises a finding β€” it must
63
+ * not degrade to a clean grade. A transient network failure is recorded too, so
64
+ * a machine consumer can see the byte-level check did not run rather than
65
+ * mistaking an unverified scan for a verified one.
66
+ */
67
+ async function attachPublishedSource(surface, opts) {
68
+ const meta = surface.packageMeta;
69
+ if (!opts.online || opts.metadataOnly || !meta?.tarballUrl)
70
+ return;
71
+ try {
72
+ const src = await fetchPackageSource(meta);
73
+ if (!src)
74
+ return;
75
+ meta.tarballSha256 = src.tarballSha256;
76
+ surface.sourceFiles = src.sourceFiles;
77
+ }
78
+ catch (err) {
79
+ const kind = err instanceof PackageSourceError ? err.kind : 'other';
80
+ const detail = err.message;
81
+ meta.artifactError = { kind, detail };
82
+ const label = kind === 'integrity' || kind === 'untrusted-redirect'
83
+ ? 'artifact FAILED verification'
84
+ : 'artifact byte-check skipped';
85
+ process.stderr.write(`! ${surface.id}: ${label} (${detail}).\n`);
86
+ }
87
+ }
35
88
  /** Strip the query string from a stored URL so config-embedded tokens don't leak. */
36
89
  function redactUrl(url) {
37
90
  if (!url)
@@ -85,9 +138,14 @@ export async function resolveTargets(target, opts = {}) {
85
138
  }
86
139
  if (!target)
87
140
  throw new Error('No scan target provided.');
88
- // A scoped npm/PyPI package (@scope/name) contains '/' but is NOT a path.
89
- const isScopedPkg = /^@[a-z0-9-~][a-z0-9-._~]*\/[a-z0-9-~][a-z0-9-._~]*$/i.test(target);
90
- const looksLikePath = !isScopedPkg && (target.includes('/') || target.includes('\\') || target.toLowerCase().endsWith('.json'));
141
+ // A scoped npm/PyPI package (@scope/name, optionally @version) contains '/'
142
+ // but is NOT a path β€” e.g. `@modelcontextprotocol/server-filesystem@1.0.0`.
143
+ // A scoped-SHAPED string ending in an archive/.json extension is a FILE path
144
+ // (e.g. `@myorg/server-1.0.0.tgz`), so it must not shadow the not-found guard.
145
+ const isArchiveFile = /\.(tgz|tar\.gz|tar|zip|whl)$/i.test(target);
146
+ const hasFileExt = isArchiveFile || target.toLowerCase().endsWith('.json');
147
+ const isScopedPkg = !hasFileExt && /^@[a-z0-9-~][a-z0-9-._~]*\/[a-z0-9-~][a-z0-9-._~]*(@[^/\\]+)?$/i.test(target);
148
+ const looksLikePath = !isScopedPkg && (target.includes('/') || target.includes('\\') || target.toLowerCase().endsWith('.json') || isArchiveFile);
91
149
  // A path/manifest target that doesn't exist: say so plainly.
92
150
  if (looksLikePath && !existsSync(target)) {
93
151
  throw new Error(`No such file: ${target}`);
@@ -96,6 +154,11 @@ export async function resolveTargets(target, opts = {}) {
96
154
  if (existsSync(target) && statSync(target).isDirectory()) {
97
155
  return [{ label: target, surface: surfaceFromPackageDir(target) }];
98
156
  }
157
+ // A packed release artifact (.tgz/.tar.gz/.tar/.zip/.whl) β†’ read the real
158
+ // shipped source in memory, offline, without installing or executing it.
159
+ if (isArchiveFile && existsSync(target) && statSync(target).isFile()) {
160
+ return [{ label: target, surface: surfaceFromArchiveFile(target) }];
161
+ }
99
162
  // 2) A file on disk: manifest or client config.
100
163
  if (existsSync(target) && target.toLowerCase().endsWith('.json')) {
101
164
  let json;
@@ -146,10 +209,17 @@ export async function resolveTargets(target, opts = {}) {
146
209
  const pkgSpec = packageSpecFromStdio(spec.command, spec.args ?? []);
147
210
  // Only fetch metadata for a well-formed package name (config values are untrusted).
148
211
  if (pkgSpec && NPM_NAME.test(pkgSpec.name)) {
149
- const meta = await metaFor(pkgSpec.name, opts);
212
+ // Pick the registry from the RUNNER (uvx/pipx β†’ PyPI, npx β†’ npm), not
213
+ // the global --registry default, so a Python server isn't looked up
214
+ // (and its same-named npm squat isn't downloaded) as an npm package.
215
+ const registry = registryForRunner(spec.command) ?? opts.registry ?? 'npm';
216
+ const meta = await metaFor(pkgSpec.name, opts, registry, pkgSpec.version);
150
217
  meta.requestedSpec = pkgSpec.version;
151
218
  meta.pinned = Boolean(pkgSpec.version && pkgSpec.version !== 'latest' && /^[0-9]/.test(pkgSpec.version));
152
219
  surface.packageMeta = meta;
220
+ // The config names the package the runner would install β€” read its
221
+ // actual published source (the pinned version), not just its metadata.
222
+ await attachPublishedSource(surface, opts);
153
223
  }
154
224
  }
155
225
  results.push({ label: spec.id, surface });
@@ -165,10 +235,18 @@ export async function resolveTargets(target, opts = {}) {
165
235
  const surface = surfaceFromManifest(json, target);
166
236
  return [{ label: surface.id, surface }];
167
237
  }
168
- // 3) A bare package name β†’ supply-chain / provenance scan.
169
- if (NPM_NAME.test(target)) {
170
- const meta = await metaFor(target, opts);
171
- return [{ label: target, surface: packageSurface(target, meta, target) }];
238
+ // 3) A bare package name (optionally `name@version`) β†’ supply-chain /
239
+ // provenance scan; with `--online` the published artifact of the requested
240
+ // version is fetched, verified and read as real source.
241
+ const bare = parsePackageTarget(target);
242
+ if (bare && NPM_NAME.test(bare.name)) {
243
+ const registry = opts.registry ?? 'npm';
244
+ const meta = await metaFor(bare.name, opts, registry, bare.version);
245
+ meta.requestedSpec = bare.version;
246
+ meta.pinned = Boolean(bare.version && /^\d/.test(bare.version));
247
+ const surface = packageSurface(bare.name, meta, target);
248
+ await attachPublishedSource(surface, opts);
249
+ return [{ label: target, surface }];
172
250
  }
173
251
  throw new Error(`Could not resolve target "${target}". Provide a tools.json manifest, an http(s) URL, ` +
174
252
  `a client config, a package name, or use --command for stdio.`);
@@ -4,7 +4,15 @@
4
4
  * access is opt-in (`--online`); everything degrades gracefully offline.
5
5
  */
6
6
  import type { PackageMeta } from '../types.js';
7
- /** Fetch npm registry metadata for a package (latest dist-tag). */
8
- export declare function fetchNpmMeta(name: string): Promise<PackageMeta>;
9
- /** Fetch PyPI metadata for a package. */
10
- export declare function fetchPypiMeta(name: string): Promise<PackageMeta>;
7
+ /**
8
+ * Fetch npm registry metadata for a package. Resolves `requested` when it names
9
+ * an exact published version (so a pinned `pkg@1.0.0` is analyzed, not `latest`);
10
+ * otherwise falls back to the `latest` dist-tag.
11
+ */
12
+ export declare function fetchNpmMeta(name: string, requested?: string): Promise<PackageMeta>;
13
+ /**
14
+ * Fetch PyPI metadata for a package. When `requested` is an exact version, the
15
+ * versioned JSON endpoint (`/pypi/<name>/<version>/json`) is used so a pinned
16
+ * spec is analyzed rather than the newest release.
17
+ */
18
+ export declare function fetchPypiMeta(name: string, requested?: string): Promise<PackageMeta>;