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 +23 -12
- package/dist/acquire/archive.d.ts +55 -0
- package/dist/acquire/archive.js +318 -0
- package/dist/acquire/index.d.ts +2 -0
- package/dist/acquire/index.js +89 -11
- package/dist/acquire/npm.d.ts +12 -4
- package/dist/acquire/npm.js +80 -8
- package/dist/acquire/packageSource.d.ts +68 -0
- package/dist/acquire/packageSource.js +258 -0
- package/dist/acquire/source.d.ts +10 -0
- package/dist/acquire/source.js +34 -11
- package/dist/cli/index.js +6 -2
- package/dist/data/ruleCatalog.js +4 -0
- package/dist/detectors/source.js +11 -2
- package/dist/detectors/supplyChain.d.ts +2 -0
- package/dist/detectors/supplyChain.js +62 -0
- package/dist/engine.js +42 -16
- package/dist/lockfile.d.ts +11 -0
- package/dist/lockfile.js +64 -2
- package/dist/report/markdown.js +12 -0
- package/dist/report/sarif.js +2 -0
- package/dist/report/terminal.js +23 -0
- package/dist/scoring/coverage.d.ts +14 -0
- package/dist/scoring/coverage.js +62 -0
- package/dist/types.d.ts +48 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -224,6 +224,67 @@ export function analyzeDependencies(meta, config) {
|
|
|
224
224
|
}
|
|
225
225
|
return findings;
|
|
226
226
|
}
|
|
227
|
+
/** Turn a failed published-artifact read into an honest finding (never silent). */
|
|
228
|
+
export function analyzeArtifactError(meta) {
|
|
229
|
+
if (meta.requestedVersionMissing) {
|
|
230
|
+
return [
|
|
231
|
+
{
|
|
232
|
+
ruleId: 'MTC-SUP-015',
|
|
233
|
+
title: 'Pinned version is not published in the registry',
|
|
234
|
+
category: 'supply-chain',
|
|
235
|
+
severity: 'medium',
|
|
236
|
+
confidence: 'strong',
|
|
237
|
+
description: `The exact version you pinned (${meta.version ?? meta.requestedSpec ?? 'requested'}) is not listed by the ` +
|
|
238
|
+
`registry — it may have been unpublished or yanked, or a hostile registry response may be hiding it to serve ` +
|
|
239
|
+
`a different "latest". The scanner did NOT silently substitute another version: no source was read and no ` +
|
|
240
|
+
`byte pin was recorded for a version that isn't there.`,
|
|
241
|
+
remediation: 'Confirm the version still exists and is the one you intend to install; if it was yanked, pin a known-good ' +
|
|
242
|
+
'version and re-pin. Treat an unexpectedly-missing pinned version as a supply-chain signal.',
|
|
243
|
+
location: { kind: 'package', name: meta.name },
|
|
244
|
+
owasp: 'LLM03:2025 Supply Chain',
|
|
245
|
+
evidence: `requested ${meta.requestedSpec ?? meta.version ?? ''}`.trim(),
|
|
246
|
+
},
|
|
247
|
+
];
|
|
248
|
+
}
|
|
249
|
+
const err = meta.artifactError;
|
|
250
|
+
if (!err)
|
|
251
|
+
return [];
|
|
252
|
+
if (err.kind === 'integrity' || err.kind === 'untrusted-redirect') {
|
|
253
|
+
return [
|
|
254
|
+
{
|
|
255
|
+
ruleId: 'MTC-TOFU-003',
|
|
256
|
+
title: 'Published artifact failed integrity verification',
|
|
257
|
+
category: 'supply-chain',
|
|
258
|
+
severity: 'critical',
|
|
259
|
+
confidence: 'confirmed',
|
|
260
|
+
description: `The published artifact for this package could not be verified against the registry's own declared hash ` +
|
|
261
|
+
`(or was served from a host outside the registry's allowlist). Its bytes were NOT trusted, scanned, or ` +
|
|
262
|
+
`pinned. This is exactly the signal a CDN/MITM tamper or a spoofed registry response would produce.\n • ${err.detail}`,
|
|
263
|
+
remediation: 'Do not install this package until resolved: re-fetch from a trusted network, confirm the registry ' +
|
|
264
|
+
'metadata, and compare the artifact hash against a known-good source.',
|
|
265
|
+
location: { kind: 'package', name: meta.name },
|
|
266
|
+
owasp: 'LLM03:2025 Supply Chain',
|
|
267
|
+
evidence: err.detail,
|
|
268
|
+
},
|
|
269
|
+
];
|
|
270
|
+
}
|
|
271
|
+
// Transient/other: not an attack claim, but the scan is NOT verified — say so.
|
|
272
|
+
return [
|
|
273
|
+
{
|
|
274
|
+
ruleId: 'MTC-TOFU-004',
|
|
275
|
+
title: 'Published-source byte check did not run',
|
|
276
|
+
category: 'supply-chain',
|
|
277
|
+
severity: 'info',
|
|
278
|
+
confidence: 'heuristic',
|
|
279
|
+
description: `An online scan was requested but the published artifact could not be downloaded, so the implementation ` +
|
|
280
|
+
`source was NOT read and the byte-level integrity pin was NOT recorded. This scan reflects registry ` +
|
|
281
|
+
`metadata only — treat it as incomplete, not as a clean bill of health.\n • ${err.detail}`,
|
|
282
|
+
remediation: 'Re-run the scan when the registry/network is reachable to complete the source-level analysis.',
|
|
283
|
+
location: { kind: 'package', name: meta.name },
|
|
284
|
+
evidence: err.detail,
|
|
285
|
+
},
|
|
286
|
+
];
|
|
287
|
+
}
|
|
227
288
|
export const supplyChainDetector = {
|
|
228
289
|
id: 'supply-chain',
|
|
229
290
|
stage: 5,
|
|
@@ -237,6 +298,7 @@ export const supplyChainDetector = {
|
|
|
237
298
|
if (meta) {
|
|
238
299
|
findings.push(...analyzeProvenance(meta));
|
|
239
300
|
findings.push(...analyzeDependencies(meta, ctx.config));
|
|
301
|
+
findings.push(...analyzeArtifactError(meta));
|
|
240
302
|
}
|
|
241
303
|
return findings;
|
|
242
304
|
},
|
package/dist/engine.js
CHANGED
|
@@ -14,6 +14,7 @@ import { checkIntegrity } from './lockfile.js';
|
|
|
14
14
|
import { surfaceDigest } from './util/hash.js';
|
|
15
15
|
import { computeScore } from './scoring/index.js';
|
|
16
16
|
import { computeCapabilityProfile } from './scoring/capability.js';
|
|
17
|
+
import { computeCoverage } from './scoring/coverage.js';
|
|
17
18
|
import { METHODOLOGY_VERSION, TOOL_NAME, TOOL_VERSION } from './version.js';
|
|
18
19
|
const SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
|
|
19
20
|
const SEVERITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
@@ -62,22 +63,46 @@ export async function scanSurface(rawSurface, options = {}) {
|
|
|
62
63
|
if (options.lockfile !== undefined) {
|
|
63
64
|
integrity = checkIntegrity(surface, options.lockfile);
|
|
64
65
|
if (integrity.status === 'drift') {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
66
|
+
const allChanges = integrity.changes ?? [];
|
|
67
|
+
const packageChanges = allChanges.filter((ch) => ch.kind === 'package-changed');
|
|
68
|
+
const surfaceChanges = allChanges.filter((ch) => ch.kind !== 'package-changed');
|
|
69
|
+
if (surfaceChanges.length > 0 || integrity.currentDigest !== integrity.previousDigest) {
|
|
70
|
+
raw.push({
|
|
71
|
+
ruleId: 'MTC-TOFU-001',
|
|
72
|
+
title: 'Server surface changed since it was pinned (possible rug pull)',
|
|
73
|
+
category: 'supply-chain',
|
|
74
|
+
severity: 'high',
|
|
75
|
+
confidence: 'confirmed',
|
|
76
|
+
description: `The canonical fingerprint of this server no longer matches its pinned value in the lockfile. ` +
|
|
77
|
+
`Tool definitions can change silently after you approve them (a rug pull); review every change before ` +
|
|
78
|
+
`trusting it again.\n` +
|
|
79
|
+
surfaceChanges.map((c) => ` • ${c.detail}`).join('\n'),
|
|
80
|
+
remediation: 'Review the diff; re-pin only after confirming the changes are legitimate (`mcptrustchecker pin`).',
|
|
81
|
+
location: { kind: 'server' },
|
|
82
|
+
owasp: 'LLM03:2025 Supply Chain',
|
|
83
|
+
evidence: `${surfaceChanges.length} change(s) since pin`,
|
|
84
|
+
data: { changes: surfaceChanges },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
if (packageChanges.length > 0) {
|
|
88
|
+
raw.push({
|
|
89
|
+
ruleId: 'MTC-TOFU-002',
|
|
90
|
+
title: 'Package republished with different content at the same version',
|
|
91
|
+
category: 'supply-chain',
|
|
92
|
+
severity: 'critical',
|
|
93
|
+
confidence: 'confirmed',
|
|
94
|
+
description: `The registry artifact for the pinned version no longer contains the bytes that were verified at pin ` +
|
|
95
|
+
`time. The tool surface can look completely unchanged while the implementation behind it was swapped — ` +
|
|
96
|
+
`the byte-level rug pull that metadata-only checks cannot see.\n` +
|
|
97
|
+
packageChanges.map((c) => ` • ${c.detail}`).join('\n'),
|
|
98
|
+
remediation: 'Treat this as a potential supply-chain compromise: diff the published code against the version you ' +
|
|
99
|
+
'approved before trusting it again, and re-pin (`mcptrustchecker pin`) only after review.',
|
|
100
|
+
location: { kind: 'package', name: packageChanges[0].name },
|
|
101
|
+
owasp: 'LLM03:2025 Supply Chain',
|
|
102
|
+
evidence: packageChanges[0].detail,
|
|
103
|
+
data: { changes: packageChanges },
|
|
104
|
+
});
|
|
105
|
+
}
|
|
81
106
|
}
|
|
82
107
|
}
|
|
83
108
|
// Filter: disabled rules, whole-rule allowlist waivers, and location-scoped
|
|
@@ -122,6 +147,7 @@ export async function scanSurface(rawSurface, options = {}) {
|
|
|
122
147
|
score,
|
|
123
148
|
capabilities,
|
|
124
149
|
capabilityProfile: computeCapabilityProfile(capabilities, flows),
|
|
150
|
+
coverage: computeCoverage(surface),
|
|
125
151
|
toxicFlows: flows,
|
|
126
152
|
integrity,
|
|
127
153
|
surfaceDigest: surfaceDigest(surface),
|
package/dist/lockfile.d.ts
CHANGED
|
@@ -12,6 +12,17 @@ export interface LockEntry {
|
|
|
12
12
|
digest: string;
|
|
13
13
|
tools: Record<string, string>;
|
|
14
14
|
instructionsDigest: string;
|
|
15
|
+
/** Package version whose published artifact was verified at pin time. */
|
|
16
|
+
packageVersion?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Identity of the pinned artifact (its registry URL). A registry can publish
|
|
19
|
+
* several artifacts per version (a PyPI sdist AND wheels); the byte pin is
|
|
20
|
+
* only comparable to the SAME artifact, so a later-added sibling artifact
|
|
21
|
+
* isn't mistaken for a same-version republish.
|
|
22
|
+
*/
|
|
23
|
+
tarballUrl?: string;
|
|
24
|
+
/** SHA-256 of that verified artifact — pins the actual published bytes. */
|
|
25
|
+
tarballSha256?: string;
|
|
15
26
|
pinnedAt?: string;
|
|
16
27
|
}
|
|
17
28
|
export interface Lockfile {
|
package/dist/lockfile.js
CHANGED
|
@@ -14,6 +14,15 @@ import { METHODOLOGY_VERSION } from './version.js';
|
|
|
14
14
|
function hashString(s) {
|
|
15
15
|
return createHash('sha256').update(s).digest('hex');
|
|
16
16
|
}
|
|
17
|
+
/** The artifact's file name (last path segment, query/fragment stripped). */
|
|
18
|
+
function artifactBasename(url) {
|
|
19
|
+
try {
|
|
20
|
+
return new URL(url).pathname.split('/').pop() ?? url;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return url.split(/[?#]/)[0].split('/').pop() ?? url;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
17
26
|
export function emptyLockfile() {
|
|
18
27
|
return { lockfileVersion: 1, methodologyVersion: METHODOLOGY_VERSION, servers: {} };
|
|
19
28
|
}
|
|
@@ -35,19 +44,37 @@ export function entryFor(surface, pinnedAt) {
|
|
|
35
44
|
const tools = {};
|
|
36
45
|
for (const t of surface.tools)
|
|
37
46
|
tools[t.name] = toolDigest(t);
|
|
47
|
+
const meta = surface.packageMeta;
|
|
38
48
|
return {
|
|
39
49
|
digest: surfaceDigest(surface),
|
|
40
50
|
tools,
|
|
41
51
|
instructionsDigest: hashString(surface.server.instructions ?? ''),
|
|
52
|
+
...(typeof meta?.version === 'string' && meta.tarballSha256
|
|
53
|
+
? { packageVersion: meta.version, tarballUrl: meta.tarballUrl ?? undefined, tarballSha256: meta.tarballSha256 }
|
|
54
|
+
: {}),
|
|
42
55
|
...(pinnedAt ? { pinnedAt } : {}),
|
|
43
56
|
};
|
|
44
57
|
}
|
|
45
58
|
/** Add/update this surface's pin in the lockfile (returns a new object). */
|
|
46
59
|
export function pinSurface(lock, surface, pinnedAt) {
|
|
60
|
+
const entry = entryFor(surface, pinnedAt);
|
|
61
|
+
// Never DROP an existing byte-level pin unless this scan positively verified a
|
|
62
|
+
// DIFFERENT version. An offline / metadata-only / failed-download re-pin has no
|
|
63
|
+
// fresh hash (and often no observed version at all) — carrying the previous pin
|
|
64
|
+
// forward keeps rug-pull protection alive rather than silently un-pinning it on
|
|
65
|
+
// a networkless CI re-pin or a transient outage.
|
|
66
|
+
const prev = lock.servers[surface.id];
|
|
67
|
+
const observedVersion = surface.packageMeta?.version;
|
|
68
|
+
const versionChanged = Boolean(observedVersion && prev?.packageVersion && observedVersion !== prev.packageVersion);
|
|
69
|
+
if (!entry.tarballSha256 && prev?.tarballSha256 && !versionChanged) {
|
|
70
|
+
entry.packageVersion = prev.packageVersion;
|
|
71
|
+
entry.tarballUrl = prev.tarballUrl;
|
|
72
|
+
entry.tarballSha256 = prev.tarballSha256;
|
|
73
|
+
}
|
|
47
74
|
return {
|
|
48
75
|
...lock,
|
|
49
76
|
methodologyVersion: METHODOLOGY_VERSION,
|
|
50
|
-
servers: { ...lock.servers, [surface.id]:
|
|
77
|
+
servers: { ...lock.servers, [surface.id]: entry },
|
|
51
78
|
};
|
|
52
79
|
}
|
|
53
80
|
/** Compare the current surface against its pinned entry. */
|
|
@@ -56,7 +83,40 @@ export function checkIntegrity(surface, lock) {
|
|
|
56
83
|
const entry = lock?.servers[surface.id];
|
|
57
84
|
if (!entry)
|
|
58
85
|
return { status: 'first-seen', currentDigest };
|
|
59
|
-
|
|
86
|
+
// Byte-level rug pull: the SAME pinned version was republished with different
|
|
87
|
+
// content. The tool surface (and therefore the digest) may be identical —
|
|
88
|
+
// that is exactly what makes this attack invisible to a metadata-only check.
|
|
89
|
+
//
|
|
90
|
+
// Artifact identity is registry-aware, because the tarball URL comes from the
|
|
91
|
+
// (attacker-controllable) registry response and MUST NOT be trusted to gate
|
|
92
|
+
// the comparison:
|
|
93
|
+
// • npm — a version maps to exactly ONE canonical tarball, so we compare by
|
|
94
|
+
// VERSION only and ignore the URL entirely. That defeats a forged registry
|
|
95
|
+
// that appends `?rev=2` (or any different path) to dodge the check.
|
|
96
|
+
// • PyPI — a version legitimately has several immutable, differently-named
|
|
97
|
+
// files (sdist + wheels), so we compare by the artifact FILE NAME (query
|
|
98
|
+
// stripped) to avoid a false republish when a sibling file is added, while
|
|
99
|
+
// still catching a same-file byte change (PyPI enforces file immutability,
|
|
100
|
+
// so a same-name/different-bytes swap is itself the anomaly).
|
|
101
|
+
const meta = surface.packageMeta;
|
|
102
|
+
// Require a STRING version on both sides: a non-primitive `meta.version`
|
|
103
|
+
// (a hostile registry can make it an array/object) would never compare equal
|
|
104
|
+
// after the lockfile's JSON round-trip, silently suppressing the byte check.
|
|
105
|
+
const sameVersion = Boolean(typeof meta?.version === 'string' && meta.version === entry.packageVersion);
|
|
106
|
+
const sameArtifact = sameVersion &&
|
|
107
|
+
(meta?.registry !== 'pypi' ||
|
|
108
|
+
!entry.tarballUrl ||
|
|
109
|
+
!meta?.tarballUrl ||
|
|
110
|
+
artifactBasename(entry.tarballUrl) === artifactBasename(meta.tarballUrl));
|
|
111
|
+
const tarballChange = entry.tarballSha256 && entry.packageVersion && meta?.tarballSha256 && sameArtifact && meta.tarballSha256 !== entry.tarballSha256
|
|
112
|
+
? {
|
|
113
|
+
kind: 'package-changed',
|
|
114
|
+
name: meta.name,
|
|
115
|
+
detail: `Version ${meta.version} was republished with different content: the verified artifact hash changed ` +
|
|
116
|
+
`from ${entry.tarballSha256.slice(0, 12)}… to ${meta.tarballSha256.slice(0, 12)}… since the pin (same version, different bytes).`,
|
|
117
|
+
}
|
|
118
|
+
: undefined;
|
|
119
|
+
if (entry.digest === currentDigest && !tarballChange)
|
|
60
120
|
return { status: 'unchanged', currentDigest, previousDigest: entry.digest };
|
|
61
121
|
const changes = [];
|
|
62
122
|
const currentTools = {};
|
|
@@ -83,5 +143,7 @@ export function checkIntegrity(surface, lock) {
|
|
|
83
143
|
if (entry.instructionsDigest !== hashString(surface.server.instructions ?? '')) {
|
|
84
144
|
changes.push({ kind: 'instructions-changed', detail: 'Server instructions changed since the pin.' });
|
|
85
145
|
}
|
|
146
|
+
if (tarballChange)
|
|
147
|
+
changes.push(tarballChange);
|
|
86
148
|
return { status: 'drift', currentDigest, previousDigest: entry.digest, changes };
|
|
87
149
|
}
|
package/dist/report/markdown.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* Markdown report — for PR comments, job summaries, and README embeds.
|
|
4
4
|
*/
|
|
5
5
|
import { ALL_CATEGORIES } from '../scoring/model.js';
|
|
6
|
+
import { coverageLabel } from '../scoring/coverage.js';
|
|
7
|
+
const COVERAGE_EMOJI = { live: '🟢', source: '🟢', manifest: '🟡', metadata: '🟡', empty: '🔴' };
|
|
6
8
|
const SEV_EMOJI = {
|
|
7
9
|
critical: '🟥',
|
|
8
10
|
high: '🟧',
|
|
@@ -29,6 +31,16 @@ export function renderMarkdown(report) {
|
|
|
29
31
|
`**Surface:** ${report.stats.tools} tools, ${report.stats.prompts} prompts, ${report.stats.resources} resources · ` +
|
|
30
32
|
`**Methodology:** \`${s.methodologyVersion}\``);
|
|
31
33
|
md.push('');
|
|
34
|
+
const cov = report.coverage;
|
|
35
|
+
md.push(`**Coverage:** ${COVERAGE_EMOJI[cov.level] ?? '⚪'} ${cov.level.toUpperCase()} — ${coverageLabel(cov.level)}`);
|
|
36
|
+
if (cov.caveats.length) {
|
|
37
|
+
md.push('');
|
|
38
|
+
md.push('> [!NOTE]');
|
|
39
|
+
md.push('> This grade reflects only what was inspected:');
|
|
40
|
+
for (const note of cov.caveats)
|
|
41
|
+
md.push(`> - ${mdInline(note)}`);
|
|
42
|
+
}
|
|
43
|
+
md.push('');
|
|
32
44
|
const bs = report.stats.findingsBySeverity;
|
|
33
45
|
md.push(`**Findings:** ${bs.critical} critical · ${bs.high} high · ${bs.medium} medium · ${bs.low} low · ${bs.info} info`);
|
|
34
46
|
md.push('');
|
package/dist/report/sarif.js
CHANGED
|
@@ -95,6 +95,8 @@ export function renderSarif(report) {
|
|
|
95
95
|
trustScore: report.score.score,
|
|
96
96
|
grade: report.score.grade,
|
|
97
97
|
capabilityLevel: report.capabilityProfile.level,
|
|
98
|
+
coverageLevel: report.coverage.level,
|
|
99
|
+
coverageCaveats: report.coverage.caveats,
|
|
98
100
|
methodologyVersion: report.score.methodologyVersion,
|
|
99
101
|
surfaceDigest: report.surfaceDigest,
|
|
100
102
|
},
|
package/dist/report/terminal.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Human-facing terminal report. Zero-dependency ANSI, degrades to plain text
|
|
4
4
|
* when piped or under NO_COLOR.
|
|
5
5
|
*/
|
|
6
|
+
import { coverageLabel } from '../scoring/coverage.js';
|
|
6
7
|
import { c, padVisible } from '../util/ansi.js';
|
|
7
8
|
import { ALL_CATEGORIES, isCapabilityRule } from '../scoring/model.js';
|
|
8
9
|
const SEV_LABEL = {
|
|
@@ -45,6 +46,14 @@ function capColor(level, s) {
|
|
|
45
46
|
return c.bold(c.red(s));
|
|
46
47
|
}
|
|
47
48
|
}
|
|
49
|
+
/** A deep scan (live/source) reads normal; a shallow one (manifest/metadata/empty) is a caution. */
|
|
50
|
+
function coverageColor(level, s) {
|
|
51
|
+
if (level === 'live' || level === 'source')
|
|
52
|
+
return c.green(s);
|
|
53
|
+
if (level === 'empty')
|
|
54
|
+
return c.red(s);
|
|
55
|
+
return c.yellow(s);
|
|
56
|
+
}
|
|
48
57
|
/** Usable terminal width (clamped), for wrapping detailed descriptions. */
|
|
49
58
|
const TERM = Math.max(64, Math.min(process.stdout.columns || 100, 118));
|
|
50
59
|
function line(char = '─', width = Math.min(TERM, 72)) {
|
|
@@ -97,6 +106,10 @@ export function renderTerminal(report, opts = {}) {
|
|
|
97
106
|
out.push(` ${gradeColor(score.grade, `╭${'─'.repeat(badge.length)}╮`)}`);
|
|
98
107
|
out.push(` ${gradeColor(score.grade, `│${badge}│`)} ${c.bold(`Trust Score ${score.score}/100`)} ${c.gray('(malice/negligence signals)')}`);
|
|
99
108
|
out.push(` ${gradeColor(score.grade, `╰${'─'.repeat(badge.length)}╯`)} ${capColor(cap.level, `Capability ${cap.level.toUpperCase()}`)} ${c.gray('(blast radius)')}`);
|
|
109
|
+
const cov = report.coverage;
|
|
110
|
+
// Align under "Capability": 3 leading spaces + the 2 box borders + badge width + 3-space gap.
|
|
111
|
+
const pad = ' '.repeat(badge.length + 8);
|
|
112
|
+
out.push(`${pad}${coverageColor(cov.level, `Coverage ${cov.level.toUpperCase()}`)} ${c.gray('(' + coverageLabel(cov.level) + ')')}`);
|
|
100
113
|
if (cap.reasons.length) {
|
|
101
114
|
out.push(` ${c.gray('methodology ' + score.methodologyVersion)} · ${c.gray(cap.reasons.slice(0, 2).join('; '))}`);
|
|
102
115
|
}
|
|
@@ -114,6 +127,16 @@ export function renderTerminal(report, opts = {}) {
|
|
|
114
127
|
.join(c.gray(' · '));
|
|
115
128
|
out.push(`${c.gray('Threats ')} ${counts || c.green('none')}`);
|
|
116
129
|
out.push(`${c.gray('Capability')} ${capColor(cap.level, cap.level)} ${c.gray(`(${capabilityFindings.length} observation${capabilityFindings.length === 1 ? '' : 's'})`)}`);
|
|
130
|
+
// Coverage caveats — what the scan could NOT see, so a clean grade on a shallow
|
|
131
|
+
// scan is never read as a thorough all-clear.
|
|
132
|
+
if (cov.caveats.length) {
|
|
133
|
+
out.push('');
|
|
134
|
+
out.push(`${coverageColor(cov.level, '⚑')} ${c.gray('Coverage — this grade reflects only what was inspected:')}`);
|
|
135
|
+
for (const note of cov.caveats) {
|
|
136
|
+
for (const wl of wrap(note, TERM - 4))
|
|
137
|
+
out.push(` ${c.gray(wl)}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
117
140
|
// Category penalties.
|
|
118
141
|
const cats = ALL_CATEGORIES.filter((cat) => score.categorySubtotals[cat] > 0);
|
|
119
142
|
if (cats.length) {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
|
|
2
|
+
/**
|
|
3
|
+
* Coverage — a third, honest axis alongside Trust (grade) and Capability (blast
|
|
4
|
+
* radius). It states how much of the target the scan could actually inspect, so
|
|
5
|
+
* a clean grade from a shallow scan is never mistaken for a thorough one.
|
|
6
|
+
*
|
|
7
|
+
* Purely descriptive and deterministic: derived only from what the acquired
|
|
8
|
+
* surface contains. It never changes the score — it explains the score's reach.
|
|
9
|
+
*/
|
|
10
|
+
import type { Coverage, CoverageLevel, ServerSurface } from '../types.js';
|
|
11
|
+
/** Derive the coverage descriptor from an acquired surface. */
|
|
12
|
+
export declare function computeCoverage(surface: ServerSurface): Coverage;
|
|
13
|
+
/** A short human label for a coverage level (terminal/markdown headline). */
|
|
14
|
+
export declare function coverageLabel(level: CoverageLevel): string;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
|
|
2
|
+
/**
|
|
3
|
+
* Coverage — a third, honest axis alongside Trust (grade) and Capability (blast
|
|
4
|
+
* radius). It states how much of the target the scan could actually inspect, so
|
|
5
|
+
* a clean grade from a shallow scan is never mistaken for a thorough one.
|
|
6
|
+
*
|
|
7
|
+
* Purely descriptive and deterministic: derived only from what the acquired
|
|
8
|
+
* surface contains. It never changes the score — it explains the score's reach.
|
|
9
|
+
*/
|
|
10
|
+
/** Derive the coverage descriptor from an acquired surface. */
|
|
11
|
+
export function computeCoverage(surface) {
|
|
12
|
+
const toolSurface = (Array.isArray(surface.tools) && surface.tools.length > 0) ||
|
|
13
|
+
(Array.isArray(surface.prompts) && surface.prompts.length > 0) ||
|
|
14
|
+
(Array.isArray(surface.resources) && surface.resources.length > 0);
|
|
15
|
+
const implementationSource = Array.isArray(surface.sourceFiles) && surface.sourceFiles.length > 0;
|
|
16
|
+
const m = surface.packageMeta;
|
|
17
|
+
const packageMetadata = Boolean(m && (m.name || m.version || (Array.isArray(m.dependencies) && m.dependencies.length > 0) || m.tarballSha256));
|
|
18
|
+
const liveTransport = surface.source?.kind === 'stdio' || surface.source?.kind === 'http';
|
|
19
|
+
// Richest signal wins for the headline label; `inputs` keeps the full detail.
|
|
20
|
+
let level;
|
|
21
|
+
if (liveTransport)
|
|
22
|
+
level = 'live';
|
|
23
|
+
else if (implementationSource)
|
|
24
|
+
level = 'source';
|
|
25
|
+
else if (toolSurface)
|
|
26
|
+
level = 'manifest';
|
|
27
|
+
else if (packageMetadata)
|
|
28
|
+
level = 'metadata';
|
|
29
|
+
else
|
|
30
|
+
level = 'empty';
|
|
31
|
+
const caveats = [];
|
|
32
|
+
if (level === 'empty') {
|
|
33
|
+
caveats.push('Nothing scannable was found on this target — an empty surface is not a clean bill of health.');
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
if (!toolSurface) {
|
|
37
|
+
caveats.push('No tools were enumerated, so prompt-injection, capability and toxic-flow analysis had no tool surface to inspect. ' +
|
|
38
|
+
'To grade a package’s real runtime tools, scan the running server: --command "npx -y <package>".');
|
|
39
|
+
}
|
|
40
|
+
if (!implementationSource && !liveTransport) {
|
|
41
|
+
caveats.push(packageMetadata
|
|
42
|
+
? 'The implementation source was not read — this reflects registry metadata only. Add --online to fetch and analyze the published package source.'
|
|
43
|
+
: 'The implementation source was not read — this reflects the declared tool metadata only.');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { level, inputs: { toolSurface, implementationSource, packageMetadata, liveTransport }, caveats };
|
|
47
|
+
}
|
|
48
|
+
/** A short human label for a coverage level (terminal/markdown headline). */
|
|
49
|
+
export function coverageLabel(level) {
|
|
50
|
+
switch (level) {
|
|
51
|
+
case 'live':
|
|
52
|
+
return 'running server — real runtime tools';
|
|
53
|
+
case 'source':
|
|
54
|
+
return 'published/local source read';
|
|
55
|
+
case 'manifest':
|
|
56
|
+
return 'static tool list only — no source';
|
|
57
|
+
case 'metadata':
|
|
58
|
+
return 'registry metadata only — no tools, no source';
|
|
59
|
+
case 'empty':
|
|
60
|
+
return 'nothing inspected';
|
|
61
|
+
}
|
|
62
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -102,6 +102,28 @@ export interface PackageMeta {
|
|
|
102
102
|
pinned?: boolean;
|
|
103
103
|
/** The raw version token from the install spec, if any (e.g. "latest", "^1.2.0"). */
|
|
104
104
|
requestedSpec?: string;
|
|
105
|
+
/**
|
|
106
|
+
* True when an EXACT pinned version was requested but the registry does not
|
|
107
|
+
* list it (unpublished, yanked, or hidden by a hostile registry response). The
|
|
108
|
+
* artifact is deliberately left unresolved rather than silently substituting
|
|
109
|
+
* `latest`, and a finding is raised.
|
|
110
|
+
*/
|
|
111
|
+
requestedVersionMissing?: boolean;
|
|
112
|
+
/** URL of the published artifact (npm dist.tarball / PyPI sdist or wheel). */
|
|
113
|
+
tarballUrl?: string | null;
|
|
114
|
+
/** Registry-declared artifact hash: SRI (`sha512-<b64>`) or `<algo>:<hex>`. */
|
|
115
|
+
tarballIntegrity?: string | null;
|
|
116
|
+
/** SHA-256 (hex) of the verified artifact the scan actually read — the byte-level pin. */
|
|
117
|
+
tarballSha256?: string;
|
|
118
|
+
/**
|
|
119
|
+
* Set when an `--online` artifact read was attempted but did not complete.
|
|
120
|
+
* `integrity`/`untrusted-redirect` are tamper evidence (a detector raises a
|
|
121
|
+
* finding); `network`/`other` mean the byte check simply could not run.
|
|
122
|
+
*/
|
|
123
|
+
artifactError?: {
|
|
124
|
+
kind: 'integrity' | 'untrusted-redirect' | 'network' | 'other';
|
|
125
|
+
detail: string;
|
|
126
|
+
};
|
|
105
127
|
}
|
|
106
128
|
/** The single normalized object every detector operates on. */
|
|
107
129
|
export interface ServerSurface {
|
|
@@ -186,6 +208,29 @@ export interface CapabilityProfile {
|
|
|
186
208
|
/** The union of capability tags observed across the server's tools. */
|
|
187
209
|
tags: CapabilityTag[];
|
|
188
210
|
}
|
|
211
|
+
/**
|
|
212
|
+
* The depth of what the scan actually observed. A Trust grade is only ever as
|
|
213
|
+
* meaningful as its coverage: a clean grade from a metadata-only scan (no tools
|
|
214
|
+
* enumerated, no source read) is NOT the same assurance as a clean grade from a
|
|
215
|
+
* live scan that saw the real runtime tools and read the code.
|
|
216
|
+
*/
|
|
217
|
+
export type CoverageLevel = 'live' | 'source' | 'manifest' | 'metadata' | 'empty';
|
|
218
|
+
export interface Coverage {
|
|
219
|
+
level: CoverageLevel;
|
|
220
|
+
/** Which analysis inputs had signal — drives which detectors could contribute. */
|
|
221
|
+
inputs: {
|
|
222
|
+
/** Tool/prompt/resource surface enumerated (injection, capability, toxic-flow). */
|
|
223
|
+
toolSurface: boolean;
|
|
224
|
+
/** Implementation source analyzed (MTC-SRC sinks). */
|
|
225
|
+
implementationSource: boolean;
|
|
226
|
+
/** Package/provenance metadata present (supply-chain). */
|
|
227
|
+
packageMetadata: boolean;
|
|
228
|
+
/** Spoke to a running server (live stdio/HTTP), so the tools are runtime-real. */
|
|
229
|
+
liveTransport: boolean;
|
|
230
|
+
};
|
|
231
|
+
/** Honest notes on what the scan could NOT see; empty when coverage is complete. */
|
|
232
|
+
caveats: string[];
|
|
233
|
+
}
|
|
189
234
|
export interface ToxicFlow {
|
|
190
235
|
id: string;
|
|
191
236
|
severity: Severity;
|
|
@@ -237,7 +282,7 @@ export interface Score {
|
|
|
237
282
|
}
|
|
238
283
|
export type IntegrityStatus = 'first-seen' | 'unchanged' | 'drift';
|
|
239
284
|
export interface SurfaceChange {
|
|
240
|
-
kind: 'tool-added' | 'tool-removed' | 'tool-changed' | 'instructions-changed';
|
|
285
|
+
kind: 'tool-added' | 'tool-removed' | 'tool-changed' | 'instructions-changed' | 'package-changed';
|
|
241
286
|
name?: string;
|
|
242
287
|
detail: string;
|
|
243
288
|
}
|
|
@@ -269,6 +314,8 @@ export interface ScanReport {
|
|
|
269
314
|
capabilities: ToolCapability[];
|
|
270
315
|
/** The server's blast-radius rating (independent of the trust grade). */
|
|
271
316
|
capabilityProfile: CapabilityProfile;
|
|
317
|
+
/** How much of the target the scan actually inspected (a grade's depth). */
|
|
318
|
+
coverage: Coverage;
|
|
272
319
|
toxicFlows: ToxicFlow[];
|
|
273
320
|
integrity?: IntegrityResult;
|
|
274
321
|
/** SHA-256 of the canonicalized surface — the rug-pull fingerprint. */
|
package/dist/version.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
|
|
2
2
|
/** Package version. Keep in sync with package.json. */
|
|
3
|
-
export declare const TOOL_VERSION = "1.
|
|
3
|
+
export declare const TOOL_VERSION = "1.2.0";
|
|
4
4
|
/**
|
|
5
5
|
* Methodology version. Bump this whenever scoring weights, gates, rule
|
|
6
6
|
* severities, or bundled threat data change in a way that could move a score.
|
package/dist/version.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/*! MCP Trust Checker · https://mcptrustchecker.com · support@mcptrustchecker.com · © 2026 Illia Haidar · MIT */
|
|
2
2
|
/** Package version. Keep in sync with package.json. */
|
|
3
|
-
export const TOOL_VERSION = '1.
|
|
3
|
+
export const TOOL_VERSION = '1.2.0';
|
|
4
4
|
/**
|
|
5
5
|
* Methodology version. Bump this whenever scoring weights, gates, rule
|
|
6
6
|
* severities, or bundled threat data change in a way that could move a score.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcptrustchecker",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Local-first, deterministic security scanner for Model Context Protocol (MCP) servers. Cross-tool toxic-flow analysis, Unicode-smuggling decode, prompt-injection & supply-chain detection, with an auditable 0–100 Trust Score.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|