@proofofwork-agency/toolpin 0.2.3
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/CONTRIBUTING.md +117 -0
- package/LICENSE +183 -0
- package/README.md +323 -0
- package/SECURITY.md +61 -0
- package/action.yml +134 -0
- package/dist/canonicalJson.js +38 -0
- package/dist/capabilities.js +139 -0
- package/dist/ci.js +26 -0
- package/dist/cli.js +1843 -0
- package/dist/clientSupport.js +76 -0
- package/dist/codexToml.js +213 -0
- package/dist/config.js +337 -0
- package/dist/constants.js +3 -0
- package/dist/continueYaml.js +76 -0
- package/dist/doctor.js +163 -0
- package/dist/install.js +191 -0
- package/dist/installed.js +405 -0
- package/dist/integrity.js +14 -0
- package/dist/inventory.js +169 -0
- package/dist/packageIntegrity.js +153 -0
- package/dist/plan.js +595 -0
- package/dist/policy.js +310 -0
- package/dist/registry.js +1610 -0
- package/dist/runtimeAdvisory.js +80 -0
- package/dist/safeFetch.js +157 -0
- package/dist/sarif.js +162 -0
- package/dist/scan.js +113 -0
- package/dist/search.js +44 -0
- package/dist/secrets.js +165 -0
- package/dist/signing.js +146 -0
- package/dist/tester.js +240 -0
- package/dist/trust.js +528 -0
- package/dist/tui/app.js +1731 -0
- package/dist/tui/command.js +50 -0
- package/dist/tui/configSnippet.js +11 -0
- package/dist/tui/constants.js +37 -0
- package/dist/tui/format.js +31 -0
- package/dist/tui/installedState.js +23 -0
- package/dist/tui/layout.js +65 -0
- package/dist/tui/selectors.js +282 -0
- package/dist/tui/types.js +1 -0
- package/dist/tui/ui/trust.js +77 -0
- package/dist/tui/views/installed.js +82 -0
- package/dist/tui/views/panels.js +637 -0
- package/dist/tui.js +12 -0
- package/dist/types.js +1 -0
- package/dist/verificationTrust.js +103 -0
- package/dist/verify.js +537 -0
- package/dist/version.js +1 -0
- package/dist/versions.js +127 -0
- package/docs/assets/readme/terminal-demo.svg +174 -0
- package/docs/assets/readme/tui-browse-overview.jpg +0 -0
- package/docs/assets/readme/tui-config-preview.jpg +0 -0
- package/docs/assets/readme/tui-help.jpg +0 -0
- package/docs/assets/readme/tui-installed-inventory.jpg +0 -0
- package/docs/how-to/catch-drift-in-ci.md +189 -0
- package/docs/how-to/custom-registries.md +156 -0
- package/docs/how-to/toolpin-curated-registry.md +153 -0
- package/package.json +76 -0
- package/registry/README.md +92 -0
- package/registry/v0/servers +115 -0
package/dist/trust.js
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
import { attestationBadge, readAttestations, readCapabilityManifest } from "./capabilities.js";
|
|
2
|
+
import { hasOciDigestMarker, hasValidOciDigestPin, isValidSha256Hex } from "./integrity.js";
|
|
3
|
+
import { scanFindingsToTrustIssues, scanServerMetadata } from "./scan.js";
|
|
4
|
+
import { TRUSTED_MCPB_SOURCES, TRUSTED_NPM_PACKUMENT_HOSTS, TRUSTED_NPM_TARBALL_HOSTS, trustedOciRegistry } from "./verificationTrust.js";
|
|
5
|
+
const STRONG_PACKAGE_TYPES = new Set(["oci", "mcpb"]);
|
|
6
|
+
const SUPPORTED_PACKAGE_TYPES = new Set(["npm", "pypi", "nuget", "cargo", "oci", "mcpb"]);
|
|
7
|
+
const BLOCKED_TRUST_CODES = new Set(["no_install_target", "insecure_remote", "invalid_remote_url"]);
|
|
8
|
+
const UNVERIFIED_TRUST_CODES = new Set(["mutable_oci_tag", "missing_mcpb_hash"]);
|
|
9
|
+
const TRUSTED_ARTIFACT_EVIDENCE_CODES = new Set(["oci_digest_verified", "mcpb_sha256_verified", "npm_integrity_verified"]);
|
|
10
|
+
const VERIFIED_EVIDENCE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
11
|
+
const TOOLPIN_EVIDENCE_META = "dev.toolpin/evidence";
|
|
12
|
+
export function scoreServer(server) {
|
|
13
|
+
const issues = [];
|
|
14
|
+
const badges = [];
|
|
15
|
+
const evidence = [];
|
|
16
|
+
let score = 50;
|
|
17
|
+
if (server.repositoryUrl) {
|
|
18
|
+
score += 8;
|
|
19
|
+
badges.push("source repo");
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
score -= 8;
|
|
23
|
+
issues.push({ severity: "warning", code: "missing_repository", message: "No source repository is declared." });
|
|
24
|
+
}
|
|
25
|
+
if (server.name.includes("/")) {
|
|
26
|
+
score += 6;
|
|
27
|
+
badges.push("namespaced");
|
|
28
|
+
}
|
|
29
|
+
const packages = server.raw.packages ?? [];
|
|
30
|
+
const remotes = server.raw.remotes ?? [];
|
|
31
|
+
let integritySignals = 0;
|
|
32
|
+
if (packages.length === 0 && remotes.length === 0) {
|
|
33
|
+
score -= 35;
|
|
34
|
+
issues.push({ severity: "critical", code: "no_install_target", message: "No package or remote endpoint is declared." });
|
|
35
|
+
}
|
|
36
|
+
for (const pkg of packages) {
|
|
37
|
+
const before = badges.length;
|
|
38
|
+
score += packageScore(pkg, issues, badges, evidence);
|
|
39
|
+
integritySignals += countIntegrityBadges(badges.slice(before));
|
|
40
|
+
}
|
|
41
|
+
for (const remote of remotes) {
|
|
42
|
+
const before = badges.length;
|
|
43
|
+
score += remoteScore(remote, issues, badges);
|
|
44
|
+
integritySignals += countIntegrityBadges(badges.slice(before));
|
|
45
|
+
}
|
|
46
|
+
if (server.requiresSecrets) {
|
|
47
|
+
score -= 6;
|
|
48
|
+
badges.push("requires secrets");
|
|
49
|
+
issues.push({ severity: "info", code: "requires_secrets", message: "This server declares secret configuration inputs." });
|
|
50
|
+
}
|
|
51
|
+
if (server.transports.includes("sse")) {
|
|
52
|
+
score -= 4;
|
|
53
|
+
issues.push({ severity: "info", code: "legacy_transport", message: "SSE transport appears in the manifest; streamable HTTP is preferred for remote servers." });
|
|
54
|
+
}
|
|
55
|
+
if (server.isLatest)
|
|
56
|
+
badges.push("latest");
|
|
57
|
+
if (readCapabilityManifest(server))
|
|
58
|
+
badges.push("capability-pinned");
|
|
59
|
+
for (const attestation of readAttestations(server)) {
|
|
60
|
+
badges.push(attestationBadge(attestation));
|
|
61
|
+
evidence.push({
|
|
62
|
+
code: "attestation_declared",
|
|
63
|
+
status: "declared",
|
|
64
|
+
message: `${attestation.type} attestation metadata is declared but not cryptographically verified.`,
|
|
65
|
+
source: "registry-metadata",
|
|
66
|
+
claim: attestation.type,
|
|
67
|
+
verificationMethod: "metadata-presence",
|
|
68
|
+
verifiedByToolPin: false,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
const registryEvidence = readToolPinEvidence(server);
|
|
72
|
+
if (registryEvidence.length) {
|
|
73
|
+
evidence.push(...registryEvidence);
|
|
74
|
+
if (registryEvidence.some((entry) => entry.code === "npm_integrity_verified" && entry.status === "passed"))
|
|
75
|
+
badges.push("npm-integrity-verified");
|
|
76
|
+
if (registryEvidence.some((entry) => entry.code === "oci_digest_verified" && entry.status === "passed"))
|
|
77
|
+
badges.push("oci-digest-verified");
|
|
78
|
+
if (registryEvidence.some((entry) => entry.code === "mcpb_sha256_verified" && entry.status === "passed"))
|
|
79
|
+
badges.push("mcpb-sha256-verified");
|
|
80
|
+
}
|
|
81
|
+
const metadataScan = scanServerMetadata(server);
|
|
82
|
+
if (metadataScan.findings.length) {
|
|
83
|
+
badges.push("description-scan-advisory");
|
|
84
|
+
issues.push(...scanFindingsToTrustIssues(metadataScan));
|
|
85
|
+
}
|
|
86
|
+
const metadataCompleteness = clamp(score);
|
|
87
|
+
const uniqueEvidence = dedupeEvidence(evidence);
|
|
88
|
+
const verifiedProvenance = Boolean(server.repositoryUrl && (server.registrySource === "toolpin" || server.registrySource === "official" || server.registrySource === "docker" || server.resolvedFromRegistry === "official"));
|
|
89
|
+
const pillars = {
|
|
90
|
+
...trustPillars(server, metadataCompleteness, issues, integritySignals, verifiedProvenance),
|
|
91
|
+
...(hasFreshTrustedArtifactEvidence(uniqueEvidence) ? { integrity: 100 } : {}),
|
|
92
|
+
};
|
|
93
|
+
const gated = gateTrust(metadataCompleteness, issues, uniqueEvidence, pillars, verifiedProvenance);
|
|
94
|
+
return {
|
|
95
|
+
score: metadataCompleteness,
|
|
96
|
+
overallScore: gated.overallScore,
|
|
97
|
+
metadataCompleteness,
|
|
98
|
+
tier: gated.tier,
|
|
99
|
+
capReason: gated.capReason,
|
|
100
|
+
vetoes: gated.vetoes,
|
|
101
|
+
gates: gated.gates,
|
|
102
|
+
gatedBy: gated.gatedBy,
|
|
103
|
+
verifiedProvenance,
|
|
104
|
+
pillars,
|
|
105
|
+
evidence: uniqueEvidence,
|
|
106
|
+
badges: [...new Set(badges)],
|
|
107
|
+
issues,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
export function classifyTrust(score, issues, evidence = [], options = {}) {
|
|
111
|
+
const gates = criticalGates(issues);
|
|
112
|
+
const criticalCodes = gates.map((gate) => gate.code);
|
|
113
|
+
const failedEvidence = evidence.filter((entry) => entry.status === "failed");
|
|
114
|
+
const failedRequiredEvidence = failedEvidence.filter((entry) => entry.required);
|
|
115
|
+
if (gates.some((gate) => gate.tier === "blocked"))
|
|
116
|
+
return { tier: "blocked", gatedBy: criticalCodes, gates };
|
|
117
|
+
if (failedRequiredEvidence.length) {
|
|
118
|
+
return {
|
|
119
|
+
tier: "blocked",
|
|
120
|
+
gatedBy: [...criticalCodes, ...failedRequiredEvidence.map((entry) => entry.code)],
|
|
121
|
+
gates: [
|
|
122
|
+
...gates,
|
|
123
|
+
...failedRequiredEvidence.map((entry) => ({ code: entry.code, message: entry.message, tier: "blocked" })),
|
|
124
|
+
],
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (gates.length)
|
|
128
|
+
return { tier: "unverified", gatedBy: criticalCodes, gates };
|
|
129
|
+
if (failedEvidence.length)
|
|
130
|
+
return { tier: "unverified", gatedBy: failedEvidence.map((entry) => entry.code), gates };
|
|
131
|
+
if (options.verifiedProvenance === true && hasUsablePinEvidence(evidence) && hasFreshTrustedArtifactEvidence(evidence, options.now))
|
|
132
|
+
return { tier: "verified", gatedBy: [], gates };
|
|
133
|
+
if (score >= 40 || hasUsablePinEvidence(evidence))
|
|
134
|
+
return { tier: "conditional", gatedBy: [], gates };
|
|
135
|
+
return { tier: "unverified", gatedBy: [], gates };
|
|
136
|
+
}
|
|
137
|
+
export function trustTier(report) {
|
|
138
|
+
return report.tier ?? classifyTrust(report.score, report.issues, report.evidence).tier;
|
|
139
|
+
}
|
|
140
|
+
export function trustProfileScore(report) {
|
|
141
|
+
return clamp(report.metadataCompleteness ?? report.score);
|
|
142
|
+
}
|
|
143
|
+
export function trustRankingScore(report) {
|
|
144
|
+
const profileScore = trustProfileScore(report);
|
|
145
|
+
const tier = trustTier(report);
|
|
146
|
+
if (tier === "verified")
|
|
147
|
+
return 100;
|
|
148
|
+
if (tier === "conditional")
|
|
149
|
+
return bandScore(profileScore, 60, 99);
|
|
150
|
+
if (tier === "unverified")
|
|
151
|
+
return bandScore(profileScore, 30, 59);
|
|
152
|
+
return bandScore(profileScore, 0, 20);
|
|
153
|
+
}
|
|
154
|
+
export function regateTrustReport(report) {
|
|
155
|
+
const verifiedProvenance = report.verifiedProvenance === true;
|
|
156
|
+
const pillars = report.pillars ?? {
|
|
157
|
+
provenance: verifiedProvenance ? 80 : 20,
|
|
158
|
+
integrity: hasFreshTrustedArtifactEvidence(report.evidence ?? []) ? 85 : 50,
|
|
159
|
+
reputation: 60,
|
|
160
|
+
metadataCompleteness: report.metadataCompleteness ?? report.score,
|
|
161
|
+
};
|
|
162
|
+
const gated = gateTrust(report.score, report.issues, report.evidence ?? [], pillars, verifiedProvenance);
|
|
163
|
+
return {
|
|
164
|
+
...report,
|
|
165
|
+
overallScore: gated.overallScore,
|
|
166
|
+
tier: gated.tier,
|
|
167
|
+
capReason: gated.capReason,
|
|
168
|
+
vetoes: gated.vetoes,
|
|
169
|
+
gates: gated.gates,
|
|
170
|
+
gatedBy: gated.gatedBy,
|
|
171
|
+
pillars,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
export function evidenceSummary(report) {
|
|
175
|
+
const evidence = report.evidence ?? [];
|
|
176
|
+
if (!evidence.length)
|
|
177
|
+
return "no automated evidence";
|
|
178
|
+
const verified = evidence.filter((entry) => entry.status === "passed" && entry.verifiedByToolPin).map((entry) => entry.code);
|
|
179
|
+
const passed = evidence.filter((entry) => entry.status === "passed" && !entry.verifiedByToolPin).map((entry) => entry.code);
|
|
180
|
+
const failed = evidence.filter((entry) => entry.status === "failed").map((entry) => entry.code);
|
|
181
|
+
const declared = evidence.filter((entry) => entry.status === "declared").map((entry) => entry.code);
|
|
182
|
+
const unavailable = evidence.filter((entry) => entry.status === "unavailable").map((entry) => entry.code);
|
|
183
|
+
return [
|
|
184
|
+
verified.length ? `ToolPin-verified ${verified.join(", ")}` : "",
|
|
185
|
+
passed.length ? `passed ${passed.join(", ")}` : "",
|
|
186
|
+
failed.length ? `failed ${failed.join(", ")}` : "",
|
|
187
|
+
declared.length ? `declared ${declared.join(", ")}` : "",
|
|
188
|
+
unavailable.length ? `unavailable ${unavailable.join(", ")}` : "",
|
|
189
|
+
].filter(Boolean).join("; ");
|
|
190
|
+
}
|
|
191
|
+
export function evidenceStatus(report) {
|
|
192
|
+
const tier = trustTier(report);
|
|
193
|
+
if (tier === "verified")
|
|
194
|
+
return "ToolPin-verified evidence passed";
|
|
195
|
+
if ((report.evidence ?? []).some((entry) => entry.status === "failed"))
|
|
196
|
+
return "evidence failed";
|
|
197
|
+
if ((report.evidence ?? []).some((entry) => entry.status === "passed" && entry.verifiedByToolPin))
|
|
198
|
+
return "ToolPin evidence incomplete";
|
|
199
|
+
if ((report.evidence ?? []).some((entry) => entry.status === "passed"))
|
|
200
|
+
return "evidence incomplete";
|
|
201
|
+
if ((report.evidence ?? []).some((entry) => entry.status === "declared"))
|
|
202
|
+
return "evidence declared";
|
|
203
|
+
return "no automated evidence";
|
|
204
|
+
}
|
|
205
|
+
export function trustCapExplanation(report) {
|
|
206
|
+
if (!report.capReason)
|
|
207
|
+
return undefined;
|
|
208
|
+
if (report.capReason === "automated evidence incomplete") {
|
|
209
|
+
const evidence = report.evidence ?? [];
|
|
210
|
+
const hasPin = hasUsablePinEvidence(evidence);
|
|
211
|
+
const hasArtifact = hasFreshTrustedArtifactEvidence(evidence);
|
|
212
|
+
const hasStaleArtifact = evidence.some((entry) => isTrustedArtifactEvidence(entry) && entry.verifiedAt && !isFreshVerifiedAt(entry.verifiedAt));
|
|
213
|
+
const hasUntrustedArtifact = evidence.some((entry) => TRUSTED_ARTIFACT_EVIDENCE_CODES.has(entry.code) && entry.status === "passed" && entry.verifiedByToolPin === true && entry.trustedAnchor !== true);
|
|
214
|
+
const declaredAttestation = evidence.some((entry) => entry.code === "attestation_declared");
|
|
215
|
+
const missing = [
|
|
216
|
+
hasPin ? "" : "exact package pin",
|
|
217
|
+
hasArtifact ? "" : artifactMissingReason(hasStaleArtifact, hasUntrustedArtifact),
|
|
218
|
+
].filter(Boolean);
|
|
219
|
+
const base = missing.length
|
|
220
|
+
? `automated evidence incomplete: missing ${missing.join(" and ")}`
|
|
221
|
+
: "automated evidence incomplete: required automated evidence has not all passed";
|
|
222
|
+
return declaredAttestation ? `${base}; declared attestations are not verified` : base;
|
|
223
|
+
}
|
|
224
|
+
if (report.capReason === "no verified provenance") {
|
|
225
|
+
return "no verified provenance: source must be ToolPin, official, or Docker and include a repository URL";
|
|
226
|
+
}
|
|
227
|
+
if (report.capReason.startsWith("veto: ")) {
|
|
228
|
+
const codes = report.capReason.slice("veto: ".length).split(", ");
|
|
229
|
+
const messages = codes.map((code) => report.issues.find((issue) => issue.code === code)?.message ?? code);
|
|
230
|
+
return `blocked by critical issue: ${messages.join("; ")}`;
|
|
231
|
+
}
|
|
232
|
+
const issue = report.issues.find((entry) => entry.code === report.capReason);
|
|
233
|
+
if (issue)
|
|
234
|
+
return `${report.capReason}: ${issue.message}`;
|
|
235
|
+
return report.capReason;
|
|
236
|
+
}
|
|
237
|
+
function criticalGates(issues) {
|
|
238
|
+
return issues.flatMap((issue) => {
|
|
239
|
+
if (issue.severity !== "critical")
|
|
240
|
+
return [];
|
|
241
|
+
const tier = BLOCKED_TRUST_CODES.has(issue.code) ? "blocked" : "unverified";
|
|
242
|
+
return [{ code: issue.code, message: issue.message, tier }];
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
function trustPillars(server, metadataCompleteness, issues, integritySignals, verifiedProvenance) {
|
|
246
|
+
const provenance = verifiedProvenance ? (server.registrySource === "official" ? 85 : server.registrySource === "toolpin" ? 82 : 80) : server.repositoryUrl ? 55 : 20;
|
|
247
|
+
const blocked = issues.some((issue) => issue.severity === "critical" && BLOCKED_TRUST_CODES.has(issue.code));
|
|
248
|
+
const unverified = issues.some((issue) => issue.severity === "critical" && UNVERIFIED_TRUST_CODES.has(issue.code));
|
|
249
|
+
const integrityPenalty = blocked ? 60 : unverified ? 35 : 0;
|
|
250
|
+
const integrity = clamp(35 + integritySignals * 15 - integrityPenalty);
|
|
251
|
+
const baseReputation = server.registrySource === "official" ? 80 : server.registrySource === "toolpin" ? 78 : server.registrySource === "docker" ? 75 : server.registryMode === "discovery" ? 45 : 60;
|
|
252
|
+
const scanPenalty = issues.filter((issue) => issue.code === "agent_instruction_in_description" || issue.code === "hidden_unicode_in_description").length * 10;
|
|
253
|
+
return { provenance: clamp(provenance), integrity, reputation: clamp(baseReputation - scanPenalty), metadataCompleteness };
|
|
254
|
+
}
|
|
255
|
+
function gateTrust(score, issues, evidence, pillars, verifiedProvenance) {
|
|
256
|
+
const classified = classifyTrust(score, issues, evidence, { verifiedProvenance });
|
|
257
|
+
const vetoes = classified.gates.filter((gate) => gate.tier === "blocked");
|
|
258
|
+
const unverifiedGates = classified.gates.filter((gate) => gate.tier === "unverified");
|
|
259
|
+
let overallScore = Math.round(pillars.provenance * 0.25 + pillars.integrity * 0.30 + pillars.reputation * 0.15 + pillars.metadataCompleteness * 0.30);
|
|
260
|
+
let capReason;
|
|
261
|
+
if (vetoes.length) {
|
|
262
|
+
overallScore = Math.min(overallScore, 20);
|
|
263
|
+
capReason = `veto: ${vetoes.map((gate) => gate.code).join(", ")}`;
|
|
264
|
+
}
|
|
265
|
+
else if (classified.tier === "verified") {
|
|
266
|
+
overallScore = 100;
|
|
267
|
+
}
|
|
268
|
+
else if (unverifiedGates.length) {
|
|
269
|
+
overallScore = Math.min(overallScore, 45);
|
|
270
|
+
capReason = unverifiedGates.map((gate) => gate.code).join(", ");
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
const cap = verifiedProvenance ? 69 : 59;
|
|
274
|
+
if (overallScore > cap)
|
|
275
|
+
overallScore = cap;
|
|
276
|
+
capReason = verifiedProvenance ? "automated evidence incomplete" : "no verified provenance";
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
overallScore,
|
|
280
|
+
tier: classified.tier,
|
|
281
|
+
capReason,
|
|
282
|
+
vetoes,
|
|
283
|
+
gates: classified.gates,
|
|
284
|
+
gatedBy: classified.gatedBy,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
function bandScore(score, min, max) {
|
|
288
|
+
return min + Math.round((clamp(score) / 100) * (max - min));
|
|
289
|
+
}
|
|
290
|
+
function packageScore(pkg, issues, badges, evidence) {
|
|
291
|
+
let score = 0;
|
|
292
|
+
if (SUPPORTED_PACKAGE_TYPES.has(pkg.registryType)) {
|
|
293
|
+
score += 5;
|
|
294
|
+
badges.push(pkg.registryType);
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
score -= 8;
|
|
298
|
+
issues.push({ severity: "warning", code: "unknown_package_type", message: `Unknown package registry type: ${pkg.registryType}.` });
|
|
299
|
+
}
|
|
300
|
+
if (STRONG_PACKAGE_TYPES.has(pkg.registryType))
|
|
301
|
+
score += 4;
|
|
302
|
+
if (pkg.version && !isFloatingVersion(pkg.version)) {
|
|
303
|
+
score += 5;
|
|
304
|
+
badges.push("pinned version");
|
|
305
|
+
evidence.push({
|
|
306
|
+
code: "package_pin",
|
|
307
|
+
status: "declared",
|
|
308
|
+
message: `Package ${pkg.identifier} declares exact version ${pkg.version}.`,
|
|
309
|
+
source: "registry-metadata",
|
|
310
|
+
claim: `${pkg.identifier}@${pkg.version}`,
|
|
311
|
+
verificationMethod: "metadata-presence",
|
|
312
|
+
verifiedByToolPin: false,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
else if (pkg.registryType !== "oci") {
|
|
316
|
+
score -= 6;
|
|
317
|
+
evidence.push({
|
|
318
|
+
code: "package_pin",
|
|
319
|
+
status: "failed",
|
|
320
|
+
message: `Package ${pkg.identifier} does not declare an exact package version.`,
|
|
321
|
+
source: "registry-metadata",
|
|
322
|
+
claim: pkg.identifier,
|
|
323
|
+
verificationMethod: "metadata-presence",
|
|
324
|
+
verifiedByToolPin: false,
|
|
325
|
+
failureReason: "missing exact version",
|
|
326
|
+
});
|
|
327
|
+
issues.push({ severity: "warning", code: "unpinned_package", message: `Package ${pkg.identifier} does not declare an exact package version.` });
|
|
328
|
+
}
|
|
329
|
+
if (pkg.registryType === "oci") {
|
|
330
|
+
if (hasValidOciDigestPin(pkg.identifier)) {
|
|
331
|
+
score += 8;
|
|
332
|
+
badges.push("digest-pinned");
|
|
333
|
+
evidence.push({
|
|
334
|
+
code: "digest_present",
|
|
335
|
+
status: "declared",
|
|
336
|
+
message: `OCI image ${pkg.identifier} declares a digest pin; image bytes were not resolved by ToolPin.`,
|
|
337
|
+
source: "registry-metadata",
|
|
338
|
+
claim: pkg.identifier,
|
|
339
|
+
verificationMethod: "metadata-presence",
|
|
340
|
+
verifiedByToolPin: false,
|
|
341
|
+
});
|
|
342
|
+
evidence.push({
|
|
343
|
+
code: "package_pin",
|
|
344
|
+
status: "declared",
|
|
345
|
+
message: `OCI image ${pkg.identifier} is pinned by digest.`,
|
|
346
|
+
source: "registry-metadata",
|
|
347
|
+
claim: pkg.identifier,
|
|
348
|
+
verificationMethod: "metadata-presence",
|
|
349
|
+
verifiedByToolPin: false,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
score -= 10;
|
|
354
|
+
const reason = hasOciDigestMarker(pkg.identifier) ? "does not contain a valid sha256 digest pin" : "is not pinned by digest";
|
|
355
|
+
evidence.push({
|
|
356
|
+
code: "digest_present",
|
|
357
|
+
status: "failed",
|
|
358
|
+
message: `OCI image ${pkg.identifier} ${reason}.`,
|
|
359
|
+
source: "registry-metadata",
|
|
360
|
+
claim: pkg.identifier,
|
|
361
|
+
verificationMethod: "metadata-presence",
|
|
362
|
+
verifiedByToolPin: false,
|
|
363
|
+
failureReason: reason,
|
|
364
|
+
});
|
|
365
|
+
issues.push({ severity: "critical", code: "mutable_oci_tag", message: `OCI image ${pkg.identifier} ${reason}.` });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (pkg.registryType === "mcpb") {
|
|
369
|
+
if (isValidSha256Hex(pkg.fileSha256)) {
|
|
370
|
+
score += 8;
|
|
371
|
+
badges.push("fileSha256");
|
|
372
|
+
evidence.push({
|
|
373
|
+
code: "file_hash_present",
|
|
374
|
+
status: "declared",
|
|
375
|
+
message: "MCPB package declares fileSha256; package bytes were not hashed by ToolPin in metadata scoring.",
|
|
376
|
+
source: "registry-metadata",
|
|
377
|
+
claim: pkg.fileSha256,
|
|
378
|
+
verificationMethod: "metadata-presence",
|
|
379
|
+
verifiedByToolPin: false,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
score -= 12;
|
|
384
|
+
evidence.push({
|
|
385
|
+
code: "file_hash_present",
|
|
386
|
+
status: "failed",
|
|
387
|
+
message: "MCPB package is missing a valid 64-character fileSha256.",
|
|
388
|
+
source: "registry-metadata",
|
|
389
|
+
claim: pkg.identifier,
|
|
390
|
+
verificationMethod: "metadata-presence",
|
|
391
|
+
verifiedByToolPin: false,
|
|
392
|
+
failureReason: "missing valid 64-character fileSha256",
|
|
393
|
+
});
|
|
394
|
+
issues.push({ severity: "critical", code: "missing_mcpb_hash", message: "MCPB packages should include a valid 64-character fileSha256." });
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return score;
|
|
398
|
+
}
|
|
399
|
+
function remoteScore(remote, issues, badges) {
|
|
400
|
+
let score = 6;
|
|
401
|
+
badges.push(remote.type);
|
|
402
|
+
try {
|
|
403
|
+
const url = new URL(remote.url);
|
|
404
|
+
if (url.protocol === "https:") {
|
|
405
|
+
score += 6;
|
|
406
|
+
badges.push("https remote");
|
|
407
|
+
}
|
|
408
|
+
else {
|
|
409
|
+
score -= 15;
|
|
410
|
+
issues.push({ severity: "critical", code: "insecure_remote", message: `Remote MCP endpoint is not HTTPS: ${remote.url}` });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
score -= 15;
|
|
415
|
+
issues.push({ severity: "critical", code: "invalid_remote_url", message: `Remote MCP endpoint is not a valid URL: ${remote.url}` });
|
|
416
|
+
}
|
|
417
|
+
if (remote.type === "streamable-http")
|
|
418
|
+
score += 4;
|
|
419
|
+
return score;
|
|
420
|
+
}
|
|
421
|
+
function countIntegrityBadges(badges) {
|
|
422
|
+
return badges.filter((badge) => ["pinned version", "digest-pinned", "fileSha256", "https remote"].includes(badge)).length;
|
|
423
|
+
}
|
|
424
|
+
function readToolPinEvidence(server) {
|
|
425
|
+
if (server.registrySource !== "toolpin")
|
|
426
|
+
return [];
|
|
427
|
+
const rawValue = server.raw._meta?.[TOOLPIN_EVIDENCE_META] ?? server.registryMeta?.[TOOLPIN_EVIDENCE_META];
|
|
428
|
+
if (!Array.isArray(rawValue))
|
|
429
|
+
return [];
|
|
430
|
+
return rawValue.flatMap((entry) => {
|
|
431
|
+
if (!isRecord(entry))
|
|
432
|
+
return [];
|
|
433
|
+
if (typeof entry.code !== "string" || typeof entry.status !== "string" || typeof entry.message !== "string")
|
|
434
|
+
return [];
|
|
435
|
+
if (!["passed", "declared", "failed", "unavailable"].includes(entry.status))
|
|
436
|
+
return [];
|
|
437
|
+
const declaredTrustedAnchor = typeof entry.trustedAnchor === "boolean" ? entry.trustedAnchor : undefined;
|
|
438
|
+
const trustAnchorHost = typeof entry.trustAnchor === "string" ? entry.trustAnchor : undefined;
|
|
439
|
+
const trustedAnchor = declaredTrustedAnchor === true
|
|
440
|
+
? anchorAllowsEvidenceCode(entry.code, trustAnchorHost)
|
|
441
|
+
: declaredTrustedAnchor;
|
|
442
|
+
return [{
|
|
443
|
+
code: entry.code,
|
|
444
|
+
status: entry.status,
|
|
445
|
+
message: entry.message,
|
|
446
|
+
...(typeof entry.source === "string" ? { source: entry.source } : {}),
|
|
447
|
+
...(typeof entry.claim === "string" ? { claim: entry.claim } : {}),
|
|
448
|
+
...(typeof entry.verificationMethod === "string" ? { verificationMethod: entry.verificationMethod } : {}),
|
|
449
|
+
...(typeof entry.verifiedByToolPin === "boolean" ? { verifiedByToolPin: entry.verifiedByToolPin } : {}),
|
|
450
|
+
...(trustedAnchor !== undefined ? { trustedAnchor } : {}),
|
|
451
|
+
...(trustAnchorHost ? { trustAnchor: trustAnchorHost } : {}),
|
|
452
|
+
...(typeof entry.verifiedAt === "string" ? { verifiedAt: entry.verifiedAt } : {}),
|
|
453
|
+
...(typeof entry.failureReason === "string" ? { failureReason: entry.failureReason } : {}),
|
|
454
|
+
...(typeof entry.required === "boolean" ? { required: entry.required } : {}),
|
|
455
|
+
}];
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
function anchorAllowsEvidenceCode(code, trustAnchor) {
|
|
459
|
+
if (typeof trustAnchor !== "string")
|
|
460
|
+
return false;
|
|
461
|
+
const host = trustAnchor.toLowerCase();
|
|
462
|
+
if (code === "npm_integrity_verified")
|
|
463
|
+
return TRUSTED_NPM_PACKUMENT_HOSTS.has(host) || TRUSTED_NPM_TARBALL_HOSTS.has(host);
|
|
464
|
+
if (code === "oci_digest_verified")
|
|
465
|
+
return trustedOciRegistry(host);
|
|
466
|
+
if (code === "mcpb_sha256_verified")
|
|
467
|
+
return TRUSTED_MCPB_SOURCES.has(host);
|
|
468
|
+
return false;
|
|
469
|
+
}
|
|
470
|
+
function isRecord(value) {
|
|
471
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
472
|
+
}
|
|
473
|
+
function clamp(value) {
|
|
474
|
+
return Math.max(0, Math.min(100, Math.round(value)));
|
|
475
|
+
}
|
|
476
|
+
function isFloatingVersion(version) {
|
|
477
|
+
return ["latest", "*"].includes(version.trim().toLowerCase()) || /[~^x*]/i.test(version);
|
|
478
|
+
}
|
|
479
|
+
function hasUsablePinEvidence(evidence) {
|
|
480
|
+
return evidence.some((entry) => (entry.status === "passed" || entry.status === "declared") && ["package_pin", "digest_present", "file_hash_present"].includes(entry.code));
|
|
481
|
+
}
|
|
482
|
+
export function hasFreshTrustedArtifactEvidence(evidence, now = new Date()) {
|
|
483
|
+
return evidence.some((entry) => isTrustedArtifactEvidence(entry) && isFreshVerifiedAt(entry.verifiedAt, now));
|
|
484
|
+
}
|
|
485
|
+
export function trustedArtifactEvidenceProblem(evidence, now = new Date()) {
|
|
486
|
+
const candidates = evidence.filter((entry) => TRUSTED_ARTIFACT_EVIDENCE_CODES.has(entry.code));
|
|
487
|
+
if (!candidates.length)
|
|
488
|
+
return "missing trusted artifact evidence";
|
|
489
|
+
if (candidates.some((entry) => entry.status === "failed" && entry.required))
|
|
490
|
+
return "required artifact evidence failed";
|
|
491
|
+
if (candidates.some((entry) => entry.status === "passed" && entry.verifiedByToolPin === true && entry.trustedAnchor !== true))
|
|
492
|
+
return "artifact evidence used an untrusted anchor";
|
|
493
|
+
if (candidates.some((entry) => isTrustedArtifactEvidence(entry) && isFreshVerifiedAt(entry.verifiedAt, now)))
|
|
494
|
+
return undefined;
|
|
495
|
+
if (candidates.some((entry) => isTrustedArtifactEvidence(entry) && !isFreshVerifiedAt(entry.verifiedAt, now)))
|
|
496
|
+
return "trusted artifact evidence is stale";
|
|
497
|
+
return "missing trusted artifact evidence";
|
|
498
|
+
}
|
|
499
|
+
function isTrustedArtifactEvidence(entry) {
|
|
500
|
+
return entry.status === "passed"
|
|
501
|
+
&& entry.verifiedByToolPin === true
|
|
502
|
+
&& entry.trustedAnchor === true
|
|
503
|
+
&& TRUSTED_ARTIFACT_EVIDENCE_CODES.has(entry.code);
|
|
504
|
+
}
|
|
505
|
+
function isFreshVerifiedAt(value, now = new Date()) {
|
|
506
|
+
if (!value)
|
|
507
|
+
return false;
|
|
508
|
+
const verifiedAt = Date.parse(value);
|
|
509
|
+
if (!Number.isFinite(verifiedAt))
|
|
510
|
+
return false;
|
|
511
|
+
return now.getTime() - verifiedAt <= VERIFIED_EVIDENCE_MAX_AGE_MS && verifiedAt <= now.getTime() + 60_000;
|
|
512
|
+
}
|
|
513
|
+
function artifactMissingReason(stale, untrusted) {
|
|
514
|
+
if (stale)
|
|
515
|
+
return "fresh ToolPin-verified artifact proof";
|
|
516
|
+
if (untrusted)
|
|
517
|
+
return "trusted-anchor artifact proof";
|
|
518
|
+
return "ToolPin-verified artifact proof (OCI registry digest, MCPB byte hash, or npm tarball integrity)";
|
|
519
|
+
}
|
|
520
|
+
function dedupeEvidence(evidence) {
|
|
521
|
+
const byKey = new Map();
|
|
522
|
+
for (const entry of evidence) {
|
|
523
|
+
const key = `${entry.code}:${entry.status}:${entry.message}`;
|
|
524
|
+
if (!byKey.has(key))
|
|
525
|
+
byKey.set(key, entry);
|
|
526
|
+
}
|
|
527
|
+
return [...byKey.values()];
|
|
528
|
+
}
|