mcptrustchecker 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -7
- package/dist/acquire/archive.d.ts +55 -0
- package/dist/acquire/archive.js +318 -0
- package/dist/acquire/index.d.ts +2 -0
- package/dist/acquire/index.js +89 -11
- package/dist/acquire/live.d.ts +14 -0
- package/dist/acquire/live.js +84 -13
- package/dist/acquire/npm.d.ts +12 -4
- package/dist/acquire/npm.js +80 -8
- package/dist/acquire/oauth.d.ts +49 -0
- package/dist/acquire/oauth.js +190 -0
- package/dist/acquire/packageSource.d.ts +68 -0
- package/dist/acquire/packageSource.js +258 -0
- package/dist/acquire/source.d.ts +10 -0
- package/dist/acquire/source.js +34 -11
- package/dist/cli/index.js +23 -2
- package/dist/data/ruleCatalog.js +4 -0
- package/dist/detectors/source.js +11 -2
- package/dist/detectors/supplyChain.d.ts +2 -0
- package/dist/detectors/supplyChain.js +62 -0
- package/dist/engine.js +40 -16
- package/dist/lockfile.d.ts +11 -0
- package/dist/lockfile.js +64 -2
- package/dist/types.d.ts +23 -1
- package/dist/util/headers.d.ts +16 -0
- package/dist/util/headers.js +49 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/dist/acquire/live.js
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
* it loaded; live scanning requires `@modelcontextprotocol/sdk` at runtime.
|
|
20
20
|
*/
|
|
21
21
|
import { surfaceFromManifest } from './manifest.js';
|
|
22
|
+
import { TOOL_VERSION } from '../version.js';
|
|
23
|
+
import { applyCredentialGate } from '../util/headers.js';
|
|
22
24
|
/** Executables permitted for stdio acquisition (the canonical safe set). */
|
|
23
25
|
export const ALLOWED_COMMANDS = new Set(['npx', 'uvx', 'python', 'python3', 'node', 'docker', 'deno']);
|
|
24
26
|
/**
|
|
@@ -143,17 +145,19 @@ function withTimeout(p, ms, label) {
|
|
|
143
145
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
144
146
|
async function loadSdk() {
|
|
145
147
|
try {
|
|
146
|
-
const [{ Client }, stdio, http, sse] = await Promise.all([
|
|
148
|
+
const [{ Client }, stdio, http, sse, auth] = await Promise.all([
|
|
147
149
|
import('@modelcontextprotocol/sdk/client/index.js'),
|
|
148
150
|
import('@modelcontextprotocol/sdk/client/stdio.js'),
|
|
149
151
|
import('@modelcontextprotocol/sdk/client/streamableHttp.js'),
|
|
150
152
|
import('@modelcontextprotocol/sdk/client/sse.js'),
|
|
153
|
+
import('@modelcontextprotocol/sdk/client/auth.js'),
|
|
151
154
|
]);
|
|
152
155
|
return {
|
|
153
156
|
Client,
|
|
154
157
|
StdioClientTransport: stdio.StdioClientTransport,
|
|
155
158
|
StreamableHTTPClientTransport: http.StreamableHTTPClientTransport,
|
|
156
159
|
SSEClientTransport: sse.SSEClientTransport,
|
|
160
|
+
UnauthorizedError: auth.UnauthorizedError,
|
|
157
161
|
};
|
|
158
162
|
}
|
|
159
163
|
catch (err) {
|
|
@@ -270,7 +274,7 @@ export async function acquireStdio(spec, opts = {}) {
|
|
|
270
274
|
cwd: spec.cwd ?? process.cwd(),
|
|
271
275
|
stderr: 'pipe',
|
|
272
276
|
});
|
|
273
|
-
const client = new sdk.Client({ name: 'mcptrustchecker', version:
|
|
277
|
+
const client = new sdk.Client({ name: 'mcptrustchecker', version: TOOL_VERSION }, { capabilities: {} });
|
|
274
278
|
try {
|
|
275
279
|
await withTimeout(client.connect(transport), CONNECT_TIMEOUT_MS, 'stdio connect');
|
|
276
280
|
const enumd = await enumerate(client);
|
|
@@ -310,25 +314,42 @@ export async function acquireHttp(url, opts = {}) {
|
|
|
310
314
|
`Use --allowed-hosts to permit specific hosts.`);
|
|
311
315
|
}
|
|
312
316
|
const sdk = await loadSdk();
|
|
313
|
-
const client = new sdk.Client({ name: 'mcptrustchecker', version:
|
|
317
|
+
const client = new sdk.Client({ name: 'mcptrustchecker', version: TOOL_VERSION }, { capabilities: {} });
|
|
314
318
|
// Re-validate every redirect hop: the initial-URL SSRF check is useless if the
|
|
315
319
|
// server 302s to http://[::ffff:169.254.169.254]/. We follow redirects
|
|
316
320
|
// manually (capped) and run each hop's host back through the same guard.
|
|
321
|
+
// Re-validate every hop AND contain credentials. The SDK routes ALL its
|
|
322
|
+
// requests — including OAuth discovery / registration / token endpoints, whose
|
|
323
|
+
// hosts come from the *server's* metadata — through this fetch, so every hop
|
|
324
|
+
// (not just 3xx targets) must pass the SSRF guard, and credentials must never
|
|
325
|
+
// cross to a different origin.
|
|
317
326
|
const guardedFetch = async (input, init) => {
|
|
318
327
|
let current = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
319
328
|
for (let hop = 0; hop < 6; hop++) {
|
|
320
|
-
|
|
329
|
+
let cur;
|
|
330
|
+
try {
|
|
331
|
+
cur = new URL(current);
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
throw new Error(`Invalid request URL "${current}".`);
|
|
335
|
+
}
|
|
336
|
+
// SSRF guard on THIS hop's host — covers the initial request too, not just
|
|
337
|
+
// redirect targets (an OAuth authorization-server host is server-chosen).
|
|
338
|
+
if (cur.protocol !== 'http:' && cur.protocol !== 'https:')
|
|
339
|
+
throw new Error(`Blocked request to non-http scheme "${cur.protocol}".`);
|
|
340
|
+
if (opts.allowedHosts && !opts.allowedHosts.includes(cur.hostname))
|
|
341
|
+
throw new Error(`Blocked request to disallowed host "${cur.hostname}".`);
|
|
342
|
+
if (opts.blockPrivateHosts && isBlockedHost(cur.hostname))
|
|
343
|
+
throw new Error(`SSRF: blocked request to private/loopback host "${cur.hostname}".`);
|
|
344
|
+
// Credentials (the SDK-injected OAuth bearer in init.headers, and any
|
|
345
|
+
// static --header) go ONLY to the exact target ORIGIN; stripped on any
|
|
346
|
+
// cross-origin hop (redirect, downgrade, different port).
|
|
347
|
+
const headers = applyCredentialGate(init?.headers, cur.origin === parsed.origin, opts.headers);
|
|
348
|
+
const res = await fetch(current, { ...init, headers, redirect: 'manual' });
|
|
321
349
|
const loc = res.status >= 300 && res.status < 400 ? res.headers.get('location') : null;
|
|
322
350
|
if (!loc)
|
|
323
351
|
return res;
|
|
324
|
-
|
|
325
|
-
if (next.protocol !== 'http:' && next.protocol !== 'https:')
|
|
326
|
-
throw new Error(`Blocked redirect to non-http scheme "${next.protocol}".`);
|
|
327
|
-
if (opts.allowedHosts && !opts.allowedHosts.includes(next.hostname))
|
|
328
|
-
throw new Error(`Blocked redirect to disallowed host "${next.hostname}".`);
|
|
329
|
-
if (opts.blockPrivateHosts && isBlockedHost(next.hostname))
|
|
330
|
-
throw new Error(`SSRF: blocked redirect to private/loopback host "${next.hostname}".`);
|
|
331
|
-
current = next.href;
|
|
352
|
+
current = new URL(loc, current).href;
|
|
332
353
|
}
|
|
333
354
|
throw new Error('Too many redirects.');
|
|
334
355
|
};
|
|
@@ -342,8 +363,58 @@ export async function acquireHttp(url, opts = {}) {
|
|
|
342
363
|
await withTimeout(client.connect(sseT), CONNECT_TIMEOUT_MS, 'sse connect');
|
|
343
364
|
}
|
|
344
365
|
};
|
|
366
|
+
// Interactive OAuth: register a client, open the browser, catch the redirect,
|
|
367
|
+
// exchange the code for a token, then reconnect authenticated.
|
|
368
|
+
const connectWithOAuth = async () => {
|
|
369
|
+
const { CliOAuthProvider, startCallbackServer, openBrowser } = await import('./oauth.js');
|
|
370
|
+
const cb = await startCallbackServer();
|
|
371
|
+
// Flips true the instant the browser sign-in is triggered. After that, a
|
|
372
|
+
// failure (denied consent, callback timeout, token-exchange error) is NOT a
|
|
373
|
+
// transport mismatch — we must not restart the flow on SSE (a second browser
|
|
374
|
+
// window + reuse of the already-settled single-shot callback promise).
|
|
375
|
+
let authStarted = false;
|
|
376
|
+
const provider = new CliOAuthProvider(cb.redirectUrl, opts.scope, (authUrl) => {
|
|
377
|
+
authStarted = true;
|
|
378
|
+
process.stderr.write(`\n${'→'} This MCP server requires sign-in. Opening your browser…\n` +
|
|
379
|
+
` If it doesn't open, paste this URL:\n ${authUrl.href}\n\n`);
|
|
380
|
+
openBrowser(authUrl.href);
|
|
381
|
+
}, cb.state);
|
|
382
|
+
const attempt = async (TransportCtor) => {
|
|
383
|
+
let transport = new TransportCtor(parsed, { authProvider: provider, fetch: guardedFetch });
|
|
384
|
+
try {
|
|
385
|
+
await withTimeout(client.connect(transport), CONNECT_TIMEOUT_MS, 'oauth connect');
|
|
386
|
+
}
|
|
387
|
+
catch (err) {
|
|
388
|
+
if (sdk.UnauthorizedError && err instanceof sdk.UnauthorizedError) {
|
|
389
|
+
const code = await cb.waitForCode();
|
|
390
|
+
await transport.finishAuth(code);
|
|
391
|
+
transport = new TransportCtor(parsed, { authProvider: provider, fetch: guardedFetch });
|
|
392
|
+
await withTimeout(client.connect(transport), CONNECT_TIMEOUT_MS, 'oauth reconnect');
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
throw err;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
try {
|
|
400
|
+
try {
|
|
401
|
+
await attempt(sdk.StreamableHTTPClientTransport);
|
|
402
|
+
}
|
|
403
|
+
catch (primary) {
|
|
404
|
+
// Fall back to SSE ONLY for a pre-auth transport/protocol mismatch; once
|
|
405
|
+
// the browser has opened, surface the real error instead of re-running
|
|
406
|
+
// the whole sign-in on a second transport.
|
|
407
|
+
if (authStarted || (sdk.UnauthorizedError && primary instanceof sdk.UnauthorizedError))
|
|
408
|
+
throw primary;
|
|
409
|
+
await attempt(sdk.SSEClientTransport);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
finally {
|
|
413
|
+
cb.close();
|
|
414
|
+
}
|
|
415
|
+
};
|
|
345
416
|
try {
|
|
346
|
-
await connect();
|
|
417
|
+
await (opts.login ? connectWithOAuth() : connect());
|
|
347
418
|
const enumd = await enumerate(client);
|
|
348
419
|
const surface = surfaceFromManifest({ ...enumd }, url, url);
|
|
349
420
|
surface.source = { kind: 'http', origin: url };
|
package/dist/acquire/npm.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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>;
|
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,49 @@
|
|
|
1
|
+
import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
|
|
2
|
+
import type { OAuthClientMetadata, OAuthTokens, OAuthClientInformationFull } from '@modelcontextprotocol/sdk/shared/auth.js';
|
|
3
|
+
/** How long to wait for the user to finish signing in in their browser. */
|
|
4
|
+
export declare const OAUTH_CALLBACK_TIMEOUT_MS = 180000;
|
|
5
|
+
/** Open a URL in the user's default browser (best-effort, cross-platform). */
|
|
6
|
+
export declare function openBrowser(url: string): void;
|
|
7
|
+
/** Parse an OAuth redirect request URL into a code (+ state), or a descriptive error. */
|
|
8
|
+
export declare function parseCallback(reqUrl: string | undefined): {
|
|
9
|
+
code?: string;
|
|
10
|
+
state?: string;
|
|
11
|
+
error?: Error;
|
|
12
|
+
};
|
|
13
|
+
export interface CallbackServer {
|
|
14
|
+
/** The loopback redirect URI to register and redirect back to. */
|
|
15
|
+
redirectUrl: string;
|
|
16
|
+
/** The CSRF `state` value the authorization request must echo back. */
|
|
17
|
+
state: string;
|
|
18
|
+
/** Resolves with the authorization code once the browser redirects back. */
|
|
19
|
+
waitForCode(): Promise<string>;
|
|
20
|
+
/** Shut the loopback server down. */
|
|
21
|
+
close(): void;
|
|
22
|
+
}
|
|
23
|
+
/** Start a loopback HTTP server on a free port to receive the OAuth redirect. */
|
|
24
|
+
export declare function startCallbackServer(): Promise<CallbackServer>;
|
|
25
|
+
/**
|
|
26
|
+
* In-memory `OAuthClientProvider`. Holds the dynamically-registered client,
|
|
27
|
+
* tokens and PKCE verifier for a single scan; opens the browser on redirect.
|
|
28
|
+
*/
|
|
29
|
+
export declare class CliOAuthProvider implements OAuthClientProvider {
|
|
30
|
+
private readonly _redirectUrl;
|
|
31
|
+
private readonly _scope;
|
|
32
|
+
private readonly _onAuthorize;
|
|
33
|
+
private readonly _state;
|
|
34
|
+
private _clientInformation?;
|
|
35
|
+
private _tokens?;
|
|
36
|
+
private _codeVerifier?;
|
|
37
|
+
constructor(_redirectUrl: string, _scope: string | undefined, _onAuthorize: (url: URL) => void, _state: string);
|
|
38
|
+
get redirectUrl(): string;
|
|
39
|
+
/** CSRF nonce echoed through the authorization request (OAuth 2.1 hardening). */
|
|
40
|
+
state(): string;
|
|
41
|
+
get clientMetadata(): OAuthClientMetadata;
|
|
42
|
+
clientInformation(): OAuthClientInformationFull | undefined;
|
|
43
|
+
saveClientInformation(info: OAuthClientInformationFull): void;
|
|
44
|
+
tokens(): OAuthTokens | undefined;
|
|
45
|
+
saveTokens(tokens: OAuthTokens): void;
|
|
46
|
+
redirectToAuthorization(authorizationUrl: URL): void;
|
|
47
|
+
saveCodeVerifier(codeVerifier: string): void;
|
|
48
|
+
codeVerifier(): string;
|
|
49
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
|
|
2
|
+
/**
|
|
3
|
+
* Interactive OAuth 2.0 login for scanning protected remote MCP servers.
|
|
4
|
+
*
|
|
5
|
+
* Modern remote MCP servers gate their `/mcp` endpoint behind OAuth (the MCP
|
|
6
|
+
* authorization spec: discovery → dynamic client registration → authorization-
|
|
7
|
+
* code + PKCE). A static header can't authenticate there. This implements the
|
|
8
|
+
* client half of that flow for the CLI: a loopback callback server catches the
|
|
9
|
+
* redirect, the browser is opened for the user's sign-in, and the SDK exchanges
|
|
10
|
+
* the code for an access token — which it then attaches to the MCP handshake.
|
|
11
|
+
*
|
|
12
|
+
* Tokens are held in memory for the duration of one scan only — nothing is
|
|
13
|
+
* written to disk, consistent with the tool's local-first, no-account stance.
|
|
14
|
+
*/
|
|
15
|
+
import { createServer } from 'node:http';
|
|
16
|
+
import { spawn } from 'node:child_process';
|
|
17
|
+
import { randomUUID } from 'node:crypto';
|
|
18
|
+
const CLIENT_NAME = 'MCP Trust Checker';
|
|
19
|
+
/** How long to wait for the user to finish signing in in their browser. */
|
|
20
|
+
export const OAUTH_CALLBACK_TIMEOUT_MS = 180_000;
|
|
21
|
+
/** Open a URL in the user's default browser (best-effort, cross-platform). */
|
|
22
|
+
export function openBrowser(url) {
|
|
23
|
+
const [cmd, args] = process.platform === 'darwin'
|
|
24
|
+
? ['open', [url]]
|
|
25
|
+
: process.platform === 'win32'
|
|
26
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
27
|
+
: ['xdg-open', [url]];
|
|
28
|
+
try {
|
|
29
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
30
|
+
child.on('error', () => {
|
|
31
|
+
/* the URL is also printed, so the user can open it manually */
|
|
32
|
+
});
|
|
33
|
+
child.unref();
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
/* ignore — non-fatal, the URL is printed for manual use */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Parse an OAuth redirect request URL into a code (+ state), or a descriptive error. */
|
|
40
|
+
export function parseCallback(reqUrl) {
|
|
41
|
+
const parsed = new URL(reqUrl || '', 'http://127.0.0.1');
|
|
42
|
+
const code = parsed.searchParams.get('code');
|
|
43
|
+
const state = parsed.searchParams.get('state') || undefined;
|
|
44
|
+
if (code)
|
|
45
|
+
return { code, state };
|
|
46
|
+
const error = parsed.searchParams.get('error');
|
|
47
|
+
if (error) {
|
|
48
|
+
const desc = parsed.searchParams.get('error_description');
|
|
49
|
+
return { error: new Error(`OAuth authorization failed: ${error}${desc ? ` — ${desc}` : ''}`) };
|
|
50
|
+
}
|
|
51
|
+
return { error: new Error('OAuth callback carried no authorization code.') };
|
|
52
|
+
}
|
|
53
|
+
const OK_PAGE = '<!doctype html><meta charset="utf-8"><title>Authorized</title>' +
|
|
54
|
+
'<body style="font-family:system-ui,sans-serif;background:#07080b;color:#eef1f6;text-align:center;padding:64px 24px">' +
|
|
55
|
+
'<h1 style="color:#c4f542">✓ Authorized</h1>' +
|
|
56
|
+
'<p>You can close this tab and return to the terminal.</p>' +
|
|
57
|
+
'<script>setTimeout(function(){window.close()},1500)</script></body>';
|
|
58
|
+
/** Start a loopback HTTP server on a free port to receive the OAuth redirect. */
|
|
59
|
+
export async function startCallbackServer() {
|
|
60
|
+
let resolveCode;
|
|
61
|
+
let rejectCode;
|
|
62
|
+
const codePromise = new Promise((res, rej) => {
|
|
63
|
+
resolveCode = res;
|
|
64
|
+
rejectCode = rej;
|
|
65
|
+
});
|
|
66
|
+
const expectedState = randomUUID();
|
|
67
|
+
const server = createServer((req, res) => {
|
|
68
|
+
const parsed = new URL(req.url || '', 'http://127.0.0.1');
|
|
69
|
+
if (parsed.pathname !== '/callback') {
|
|
70
|
+
res.writeHead(404);
|
|
71
|
+
res.end();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const { code, state, error } = parseCallback(req.url);
|
|
75
|
+
// Require the CSRF state to match before settling — a stray/forged local
|
|
76
|
+
// request (or a page spraying the ephemeral port) can neither complete NOR
|
|
77
|
+
// abort the sign-in.
|
|
78
|
+
if (state !== expectedState) {
|
|
79
|
+
res.writeHead(400, { 'content-type': 'text/html' });
|
|
80
|
+
res.end('<!doctype html><meta charset="utf-8"><body>Invalid OAuth state.</body>');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (code) {
|
|
84
|
+
res.writeHead(200, { 'content-type': 'text/html' });
|
|
85
|
+
res.end(OK_PAGE);
|
|
86
|
+
resolveCode(code);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
res.writeHead(400, { 'content-type': 'text/html' });
|
|
90
|
+
res.end(`<!doctype html><meta charset="utf-8"><body>${error.message}</body>`);
|
|
91
|
+
rejectCode(error);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
await new Promise((resolve, reject) => {
|
|
95
|
+
server.once('error', reject);
|
|
96
|
+
server.listen(0, '127.0.0.1', () => {
|
|
97
|
+
server.off('error', reject);
|
|
98
|
+
resolve();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
const addr = server.address();
|
|
102
|
+
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
103
|
+
const redirectUrl = `http://127.0.0.1:${port}/callback`;
|
|
104
|
+
// Race the callback against a timeout, but ALWAYS clear the timer once the
|
|
105
|
+
// code arrives — a lingering setTimeout would keep the event loop (and the
|
|
106
|
+
// whole CLI, after the scan finishes) alive until it fires.
|
|
107
|
+
const waitForCode = () => new Promise((resolve, reject) => {
|
|
108
|
+
const timer = setTimeout(() => reject(new Error('Timed out waiting for the browser sign-in (3 min). Re-run to try again.')), OAUTH_CALLBACK_TIMEOUT_MS);
|
|
109
|
+
timer.unref?.();
|
|
110
|
+
codePromise.then((code) => {
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
resolve(code);
|
|
113
|
+
}, (err) => {
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
reject(err);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
return {
|
|
119
|
+
redirectUrl,
|
|
120
|
+
state: expectedState,
|
|
121
|
+
waitForCode,
|
|
122
|
+
close: () => {
|
|
123
|
+
try {
|
|
124
|
+
server.close();
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
/* already closed */
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* In-memory `OAuthClientProvider`. Holds the dynamically-registered client,
|
|
134
|
+
* tokens and PKCE verifier for a single scan; opens the browser on redirect.
|
|
135
|
+
*/
|
|
136
|
+
export class CliOAuthProvider {
|
|
137
|
+
_redirectUrl;
|
|
138
|
+
_scope;
|
|
139
|
+
_onAuthorize;
|
|
140
|
+
_state;
|
|
141
|
+
_clientInformation;
|
|
142
|
+
_tokens;
|
|
143
|
+
_codeVerifier;
|
|
144
|
+
constructor(_redirectUrl, _scope, _onAuthorize, _state) {
|
|
145
|
+
this._redirectUrl = _redirectUrl;
|
|
146
|
+
this._scope = _scope;
|
|
147
|
+
this._onAuthorize = _onAuthorize;
|
|
148
|
+
this._state = _state;
|
|
149
|
+
}
|
|
150
|
+
get redirectUrl() {
|
|
151
|
+
return this._redirectUrl;
|
|
152
|
+
}
|
|
153
|
+
/** CSRF nonce echoed through the authorization request (OAuth 2.1 hardening). */
|
|
154
|
+
state() {
|
|
155
|
+
return this._state;
|
|
156
|
+
}
|
|
157
|
+
get clientMetadata() {
|
|
158
|
+
return {
|
|
159
|
+
client_name: CLIENT_NAME,
|
|
160
|
+
redirect_uris: [this._redirectUrl],
|
|
161
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
162
|
+
response_types: ['code'],
|
|
163
|
+
token_endpoint_auth_method: 'client_secret_post',
|
|
164
|
+
...(this._scope ? { scope: this._scope } : {}),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
clientInformation() {
|
|
168
|
+
return this._clientInformation;
|
|
169
|
+
}
|
|
170
|
+
saveClientInformation(info) {
|
|
171
|
+
this._clientInformation = info;
|
|
172
|
+
}
|
|
173
|
+
tokens() {
|
|
174
|
+
return this._tokens;
|
|
175
|
+
}
|
|
176
|
+
saveTokens(tokens) {
|
|
177
|
+
this._tokens = tokens;
|
|
178
|
+
}
|
|
179
|
+
redirectToAuthorization(authorizationUrl) {
|
|
180
|
+
this._onAuthorize(authorizationUrl);
|
|
181
|
+
}
|
|
182
|
+
saveCodeVerifier(codeVerifier) {
|
|
183
|
+
this._codeVerifier = codeVerifier;
|
|
184
|
+
}
|
|
185
|
+
codeVerifier() {
|
|
186
|
+
if (!this._codeVerifier)
|
|
187
|
+
throw new Error('No PKCE code verifier saved.');
|
|
188
|
+
return this._codeVerifier;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -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;
|