@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/tui.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render } from "ink";
|
|
3
|
+
import { MpmTui } from "./tui/app.js";
|
|
4
|
+
export { commandLineFor, commandRequiresServer, shellQuote } from "./tui/command.js";
|
|
5
|
+
export { installedId, installedViewReducer, loadInstalledServerStates } from "./tui/installedState.js";
|
|
6
|
+
export { buildTuiHitZones, computeMenuLayout, hitTestTui, listWindowStart } from "./tui/layout.js";
|
|
7
|
+
export { browseSearchResults, buildTuiVersionInfo, cacheCoverage, cacheHasSource, clientSupportSummary, commandLogBelongsToView, commandLogForView, configTargetLabel, directInstallClientsForServerScope, filterBySource, formatVersionChoices, initialInstallVersionIndex, installClientChoicesForScope, installClientChoicesForServerScope, installClientLabel, browseSortLabel, nextClient, nextClientForServerScope, nextBrowseSortMode, nextResultLimit, nextSource, nextView, persistentRefreshOptions, pruneVersionSelections, scopeLabel, selectedClients, selectedClientsForScope, selectedInstallClientsForServerScope, selectedServerVersion, sortBrowseResults, switchView, } from "./tui/selectors.js";
|
|
8
|
+
export { buildOperationSnapshot, InstallWizard, OperationModal, OptionList, RegistryLoadingPanel, SourcesView, sourceCountLabel } from "./tui/views/panels.js";
|
|
9
|
+
export { InstalledServerDetails } from "./tui/views/installed.js";
|
|
10
|
+
export function runTui() {
|
|
11
|
+
render(_jsx(MpmTui, {}), { alternateScreen: Boolean(process.stdout.isTTY) });
|
|
12
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
const TRUSTED_OCI_REGISTRIES = new Set([
|
|
2
|
+
"docker.io",
|
|
3
|
+
"registry-1.docker.io",
|
|
4
|
+
"ghcr.io",
|
|
5
|
+
"gcr.io",
|
|
6
|
+
"mcr.microsoft.com",
|
|
7
|
+
"public.ecr.aws",
|
|
8
|
+
"registry.k8s.io",
|
|
9
|
+
"quay.io",
|
|
10
|
+
]);
|
|
11
|
+
const TRUSTED_AUTH_HOSTS = {
|
|
12
|
+
"docker.io": ["auth.docker.io", "docker.io", "registry-1.docker.io"],
|
|
13
|
+
"registry-1.docker.io": ["auth.docker.io", "docker.io", "registry-1.docker.io"],
|
|
14
|
+
"ghcr.io": ["ghcr.io"],
|
|
15
|
+
"gcr.io": ["gcr.io"],
|
|
16
|
+
"mcr.microsoft.com": ["mcr.microsoft.com"],
|
|
17
|
+
"public.ecr.aws": ["public.ecr.aws"],
|
|
18
|
+
"registry.k8s.io": ["registry.k8s.io"],
|
|
19
|
+
"quay.io": ["quay.io"],
|
|
20
|
+
};
|
|
21
|
+
export const TRUSTED_MCPB_SOURCES = new Set([
|
|
22
|
+
"registry.modelcontextprotocol.io",
|
|
23
|
+
"modelcontextprotocol.io",
|
|
24
|
+
"github.com",
|
|
25
|
+
"objects.githubusercontent.com",
|
|
26
|
+
"github-releases.githubusercontent.com",
|
|
27
|
+
"raw.githubusercontent.com",
|
|
28
|
+
]);
|
|
29
|
+
export const TRUSTED_NPM_PACKUMENT_HOSTS = new Set([
|
|
30
|
+
"registry.npmjs.org",
|
|
31
|
+
]);
|
|
32
|
+
export const TRUSTED_NPM_TARBALL_HOSTS = new Set([
|
|
33
|
+
"registry.npmjs.org",
|
|
34
|
+
]);
|
|
35
|
+
export function canonicalizeOciRef(identifier) {
|
|
36
|
+
const digestMatch = identifier.match(/@sha256:([a-fA-F0-9]{64})$/);
|
|
37
|
+
if (!digestMatch)
|
|
38
|
+
return undefined;
|
|
39
|
+
const image = identifier.slice(0, digestMatch.index);
|
|
40
|
+
if (!image || image.includes("://"))
|
|
41
|
+
return undefined;
|
|
42
|
+
const parts = image.split("/").filter(Boolean);
|
|
43
|
+
if (!parts.length)
|
|
44
|
+
return undefined;
|
|
45
|
+
let host;
|
|
46
|
+
let repositoryParts;
|
|
47
|
+
if (isExplicitRegistry(parts[0])) {
|
|
48
|
+
host = normalizeDockerHubHost(parts[0].toLowerCase());
|
|
49
|
+
repositoryParts = parts.slice(1);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
host = "docker.io";
|
|
53
|
+
repositoryParts = parts;
|
|
54
|
+
}
|
|
55
|
+
if (!repositoryParts.length)
|
|
56
|
+
return undefined;
|
|
57
|
+
if (host === "docker.io" && repositoryParts.length === 1) {
|
|
58
|
+
repositoryParts = ["library", repositoryParts[0]];
|
|
59
|
+
}
|
|
60
|
+
const repository = repositoryParts.join("/");
|
|
61
|
+
if (!repository || repository.includes("@") || repository.includes(":"))
|
|
62
|
+
return undefined;
|
|
63
|
+
return {
|
|
64
|
+
host,
|
|
65
|
+
repository,
|
|
66
|
+
digest: `sha256:${digestMatch[1].toLowerCase()}`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export function trustedOciRegistry(ref) {
|
|
70
|
+
const host = typeof ref === "string" ? normalizeDockerHubHost(ref.toLowerCase()) : ref.host;
|
|
71
|
+
return TRUSTED_OCI_REGISTRIES.has(host) || host === "docker.io";
|
|
72
|
+
}
|
|
73
|
+
export function trustedOciAuthHosts(host) {
|
|
74
|
+
const normalized = normalizeDockerHubHost(host.toLowerCase());
|
|
75
|
+
return new Set(TRUSTED_AUTH_HOSTS[normalized] ?? [normalized]);
|
|
76
|
+
}
|
|
77
|
+
export function trustedMcpbSourceHost(url) {
|
|
78
|
+
return trustedHttpsHost(url, TRUSTED_MCPB_SOURCES);
|
|
79
|
+
}
|
|
80
|
+
export function trustedNpmPackumentHost(url) {
|
|
81
|
+
return trustedHttpsHost(url, TRUSTED_NPM_PACKUMENT_HOSTS);
|
|
82
|
+
}
|
|
83
|
+
export function trustedNpmTarballHost(url) {
|
|
84
|
+
return trustedHttpsHost(url, TRUSTED_NPM_TARBALL_HOSTS);
|
|
85
|
+
}
|
|
86
|
+
function isExplicitRegistry(firstPart) {
|
|
87
|
+
return firstPart === "localhost" || firstPart.includes(".") || firstPart.includes(":");
|
|
88
|
+
}
|
|
89
|
+
function normalizeDockerHubHost(host) {
|
|
90
|
+
return host === "registry-1.docker.io" ? "docker.io" : host;
|
|
91
|
+
}
|
|
92
|
+
function trustedHttpsHost(url, trustedHosts) {
|
|
93
|
+
try {
|
|
94
|
+
const parsed = new URL(url);
|
|
95
|
+
const host = parsed.hostname.toLowerCase();
|
|
96
|
+
if (parsed.protocol !== "https:" || !trustedHosts.has(host))
|
|
97
|
+
return undefined;
|
|
98
|
+
return host;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { isIP } from "node:net";
|
|
3
|
+
import { selectLaunchTarget } from "./config.js";
|
|
4
|
+
import { attestationBadge, deriveCapabilityManifest, hashToolDescriptions, hashToolManifests, readAttestations, readCapabilityManifest } from "./capabilities.js";
|
|
5
|
+
import { hasOciDigestMarker, hasValidOciDigestPin, isValidSha256Hex } from "./integrity.js";
|
|
6
|
+
import { verifyNpmPackageIntegrity } from "./packageIntegrity.js";
|
|
7
|
+
import { safeFetch, safeFetchBuffer, safeFetchJson } from "./safeFetch.js";
|
|
8
|
+
import { scanFindingsToTrustIssues, scanServerMetadata, scanToolDescriptions } from "./scan.js";
|
|
9
|
+
import { testServer } from "./tester.js";
|
|
10
|
+
import { classifyTrust, scoreServer, trustedArtifactEvidenceProblem } from "./trust.js";
|
|
11
|
+
import { TRUSTED_MCPB_SOURCES, canonicalizeOciRef, trustedMcpbSourceHost, trustedOciAuthHosts, trustedOciRegistry } from "./verificationTrust.js";
|
|
12
|
+
export async function verifyServer(server, options = {}) {
|
|
13
|
+
const issues = [];
|
|
14
|
+
const badges = [];
|
|
15
|
+
const evidence = [...(scoreServer(server).evidence ?? [])];
|
|
16
|
+
const attestations = readAttestations(server);
|
|
17
|
+
const declaredCapabilityManifest = readCapabilityManifest(server);
|
|
18
|
+
const launch = selectLaunchTarget(server);
|
|
19
|
+
const generatedAt = new Date().toISOString();
|
|
20
|
+
let capabilityManifest = deriveCapabilityManifest(server, { generatedAt });
|
|
21
|
+
const metadataScan = scanServerMetadata(server, generatedAt);
|
|
22
|
+
const verifiedProvenance = Boolean(server.repositoryUrl && (server.registrySource === "toolpin" || server.registrySource === "official" || server.registrySource === "docker" || server.resolvedFromRegistry === "official"));
|
|
23
|
+
issues.push(...scanFindingsToTrustIssues(metadataScan));
|
|
24
|
+
if (metadataScan.findings.length)
|
|
25
|
+
badges.push("description-scan-advisory");
|
|
26
|
+
if (declaredCapabilityManifest) {
|
|
27
|
+
badges.push("capability-pinned");
|
|
28
|
+
}
|
|
29
|
+
for (const attestation of attestations) {
|
|
30
|
+
badges.push(attestationBadge(attestation));
|
|
31
|
+
}
|
|
32
|
+
if (!launch) {
|
|
33
|
+
issues.push({
|
|
34
|
+
severity: "critical",
|
|
35
|
+
code: "no_install_target",
|
|
36
|
+
message: `No install target is available for ${server.name}@${server.version}.`,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
else if (launch.kind === "package") {
|
|
40
|
+
await verifyPackagePins(launch.pkg, issues, badges, evidence, generatedAt, options);
|
|
41
|
+
if (options.livePackageProbe === true) {
|
|
42
|
+
const pinned = await verifyLiveToolManifest(server, generatedAt, issues, badges, evidence, "package", options.timeoutMs);
|
|
43
|
+
if (pinned)
|
|
44
|
+
capabilityManifest = pinned;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
badges.push("remote-target");
|
|
49
|
+
const remoteTrust = remoteTrustIssue(launch.remote.url);
|
|
50
|
+
if (remoteTrust) {
|
|
51
|
+
issues.push(remoteTrust.issue);
|
|
52
|
+
evidence.push(remoteTrust.evidence);
|
|
53
|
+
}
|
|
54
|
+
if (options.liveRemoteProbe !== false) {
|
|
55
|
+
const pinned = remoteTrust?.blockProbe ? undefined : await verifyLiveToolManifest(server, generatedAt, issues, badges, evidence, "remote", options.timeoutMs);
|
|
56
|
+
if (pinned)
|
|
57
|
+
capabilityManifest = pinned;
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
issues.push({
|
|
61
|
+
severity: "warning",
|
|
62
|
+
code: "remote_probe_skipped",
|
|
63
|
+
message: "Remote tool-description hashing was skipped; capability pin is metadata-only.",
|
|
64
|
+
});
|
|
65
|
+
evidence.push({
|
|
66
|
+
code: "tool_description_hash",
|
|
67
|
+
status: "unavailable",
|
|
68
|
+
message: "Remote tools/list hashing was skipped.",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const finalEvidence = dedupeEvidence(evidence);
|
|
73
|
+
if (options.requireVerified) {
|
|
74
|
+
const classified = classifyTrust(scoreServer(server).score, issues, finalEvidence, { verifiedProvenance });
|
|
75
|
+
if (classified.tier !== "verified") {
|
|
76
|
+
issues.push({
|
|
77
|
+
severity: "critical",
|
|
78
|
+
code: "verified_required",
|
|
79
|
+
message: `Verified proof is required but incomplete: ${verifiedProvenance ? trustedArtifactEvidenceProblem(finalEvidence) : "missing verified provenance"}.`,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const critical = issues.some((issue) => issue.severity === "critical");
|
|
84
|
+
return {
|
|
85
|
+
ok: !critical,
|
|
86
|
+
serverName: server.name,
|
|
87
|
+
serverVersion: server.version,
|
|
88
|
+
capabilityManifest,
|
|
89
|
+
attestations,
|
|
90
|
+
badges: [...new Set(badges)],
|
|
91
|
+
evidence: finalEvidence,
|
|
92
|
+
issues,
|
|
93
|
+
verifiedProvenance,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
async function verifyLiveToolManifest(server, generatedAt, issues, badges, evidence, targetKind, timeoutMs) {
|
|
97
|
+
const result = await testServer(server, timeoutMs);
|
|
98
|
+
if (!result.ok) {
|
|
99
|
+
const label = targetKind === "remote" ? "Remote" : "Package";
|
|
100
|
+
issues.push({
|
|
101
|
+
severity: "critical",
|
|
102
|
+
code: targetKind === "remote" ? "remote_probe_failed" : "package_probe_failed",
|
|
103
|
+
message: `${label} capability verification failed: ${result.message}`,
|
|
104
|
+
});
|
|
105
|
+
evidence.push({
|
|
106
|
+
code: "tool_description_hash",
|
|
107
|
+
status: "failed",
|
|
108
|
+
message: `Live tools/list descriptions were not hashed: ${result.message}`,
|
|
109
|
+
required: true,
|
|
110
|
+
});
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
const toolDescriptionHash = hashToolDescriptions(result.tools, generatedAt);
|
|
114
|
+
const toolManifestHash = hashToolManifests(result.tools, generatedAt);
|
|
115
|
+
const toolDescriptionScan = scanToolDescriptions(result.tools, { generatedAt });
|
|
116
|
+
const capabilityManifest = deriveCapabilityManifest(server, { generatedAt, toolDescriptionHash, toolManifestHash, toolDescriptionScan });
|
|
117
|
+
badges.push("tool-description-pinned");
|
|
118
|
+
badges.push("tool-manifest-pinned");
|
|
119
|
+
evidence.push({
|
|
120
|
+
code: "tool_description_hash",
|
|
121
|
+
status: "passed",
|
|
122
|
+
message: "Live tools/list descriptions were hashed into the capability manifest.",
|
|
123
|
+
});
|
|
124
|
+
if (toolDescriptionScan.findings.length) {
|
|
125
|
+
badges.push("tool-description-scan-advisory");
|
|
126
|
+
issues.push(...scanFindingsToTrustIssues(toolDescriptionScan));
|
|
127
|
+
}
|
|
128
|
+
return capabilityManifest;
|
|
129
|
+
}
|
|
130
|
+
function remoteTrustIssue(url) {
|
|
131
|
+
let parsed;
|
|
132
|
+
try {
|
|
133
|
+
parsed = new URL(url);
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
137
|
+
return {
|
|
138
|
+
issue: {
|
|
139
|
+
severity: "critical",
|
|
140
|
+
code: "invalid_remote_url",
|
|
141
|
+
message: `Remote MCP URL is invalid: ${reason}.`,
|
|
142
|
+
},
|
|
143
|
+
evidence: {
|
|
144
|
+
code: "remote_transport_trust",
|
|
145
|
+
status: "failed",
|
|
146
|
+
message: "Remote MCP target URL could not be parsed.",
|
|
147
|
+
claim: url,
|
|
148
|
+
verificationMethod: "remote-url-scheme-check",
|
|
149
|
+
verifiedByToolPin: true,
|
|
150
|
+
trustedAnchor: false,
|
|
151
|
+
required: true,
|
|
152
|
+
},
|
|
153
|
+
blockProbe: true,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
if (parsed.protocol === "https:")
|
|
157
|
+
return undefined;
|
|
158
|
+
if (parsed.protocol === "http:" && isLoopbackHost(parsed.hostname)) {
|
|
159
|
+
return {
|
|
160
|
+
issue: {
|
|
161
|
+
severity: "warning",
|
|
162
|
+
code: "local_http_remote",
|
|
163
|
+
message: `Remote MCP URL ${url} uses loopback HTTP; treat it as a local runtime target, not trusted public remote metadata.`,
|
|
164
|
+
},
|
|
165
|
+
evidence: {
|
|
166
|
+
code: "remote_transport_trust",
|
|
167
|
+
status: "unavailable",
|
|
168
|
+
message: "Loopback HTTP remote target is local/advisory and not trusted public remote metadata.",
|
|
169
|
+
claim: url,
|
|
170
|
+
verificationMethod: "remote-url-scheme-check",
|
|
171
|
+
verifiedByToolPin: true,
|
|
172
|
+
trustedAnchor: false,
|
|
173
|
+
},
|
|
174
|
+
blockProbe: false,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
issue: {
|
|
179
|
+
severity: "critical",
|
|
180
|
+
code: "insecure_remote",
|
|
181
|
+
message: `Remote MCP URL ${url} must use HTTPS unless it is a loopback local fixture.`,
|
|
182
|
+
},
|
|
183
|
+
evidence: {
|
|
184
|
+
code: "remote_transport_trust",
|
|
185
|
+
status: "failed",
|
|
186
|
+
message: "Remote MCP target uses insecure transport.",
|
|
187
|
+
claim: url,
|
|
188
|
+
verificationMethod: "remote-url-scheme-check",
|
|
189
|
+
verifiedByToolPin: true,
|
|
190
|
+
trustedAnchor: false,
|
|
191
|
+
required: true,
|
|
192
|
+
},
|
|
193
|
+
blockProbe: true,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function isLoopbackHost(hostname) {
|
|
197
|
+
const host = hostname.replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
|
|
198
|
+
if (host === "localhost" || host.endsWith(".localhost"))
|
|
199
|
+
return true;
|
|
200
|
+
if (isIP(host) === 4) {
|
|
201
|
+
const first = Number.parseInt(host.split(".")[0] ?? "", 10);
|
|
202
|
+
return first === 127;
|
|
203
|
+
}
|
|
204
|
+
return host === "::1" || host === "0:0:0:0:0:0:0:1";
|
|
205
|
+
}
|
|
206
|
+
async function verifyPackagePins(pkg, issues, badges, evidence, verifiedAt, options) {
|
|
207
|
+
if (pkg.registryType === "oci") {
|
|
208
|
+
if (hasValidOciDigestPin(pkg.identifier)) {
|
|
209
|
+
badges.push("digest-pinned");
|
|
210
|
+
const result = await verifyOciDigest(pkg.identifier, options);
|
|
211
|
+
if (result.status === "passed") {
|
|
212
|
+
badges.push("oci-digest-verified");
|
|
213
|
+
evidence.push({
|
|
214
|
+
code: "oci_digest_verified",
|
|
215
|
+
status: "passed",
|
|
216
|
+
message: `OCI manifest digest matched via ${result.trustAnchor}.`,
|
|
217
|
+
source: "oci-registry",
|
|
218
|
+
claim: result.expected,
|
|
219
|
+
verificationMethod: "registry-manifest-digest",
|
|
220
|
+
verifiedByToolPin: true,
|
|
221
|
+
trustedAnchor: true,
|
|
222
|
+
trustAnchor: result.trustAnchor,
|
|
223
|
+
verifiedAt,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
else if (result.status === "failed") {
|
|
227
|
+
issues.push({
|
|
228
|
+
severity: "critical",
|
|
229
|
+
code: "oci_digest_mismatch",
|
|
230
|
+
message: `OCI registry digest mismatch for ${pkg.identifier}: expected ${result.expected}, got ${result.actual ?? "unknown"}.`,
|
|
231
|
+
});
|
|
232
|
+
evidence.push({
|
|
233
|
+
code: "oci_digest_verified",
|
|
234
|
+
status: "failed",
|
|
235
|
+
message: `OCI registry digest mismatch for ${pkg.identifier}.`,
|
|
236
|
+
source: "oci-registry",
|
|
237
|
+
claim: result.expected,
|
|
238
|
+
verificationMethod: "registry-manifest-digest",
|
|
239
|
+
verifiedByToolPin: true,
|
|
240
|
+
trustedAnchor: result.trustedAnchor,
|
|
241
|
+
trustAnchor: result.trustAnchor,
|
|
242
|
+
verifiedAt,
|
|
243
|
+
failureReason: result.actual ? `resolved ${result.actual}` : result.reason,
|
|
244
|
+
required: true,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
evidence.push({
|
|
249
|
+
code: "oci_digest_verified",
|
|
250
|
+
status: "unavailable",
|
|
251
|
+
message: `OCI registry bytes were not resolved for ${pkg.identifier}: ${result.reason}.`,
|
|
252
|
+
source: "oci-registry",
|
|
253
|
+
claim: result.expected,
|
|
254
|
+
verificationMethod: "registry-manifest-digest",
|
|
255
|
+
verifiedByToolPin: false,
|
|
256
|
+
trustedAnchor: result.trustedAnchor,
|
|
257
|
+
trustAnchor: result.trustAnchor,
|
|
258
|
+
failureReason: result.reason,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
const reason = hasOciDigestMarker(pkg.identifier) ? "does not contain a valid sha256 digest pin" : "is not pinned by digest";
|
|
264
|
+
issues.push({
|
|
265
|
+
severity: "critical",
|
|
266
|
+
code: "mutable_oci_tag",
|
|
267
|
+
message: `OCI image ${pkg.identifier} ${reason}.`,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (pkg.registryType === "npm") {
|
|
272
|
+
const result = await verifyNpmPackageIntegrity(pkg, options);
|
|
273
|
+
if (result.status === "passed") {
|
|
274
|
+
badges.push("npm-integrity-verified");
|
|
275
|
+
evidence.push({
|
|
276
|
+
code: "npm_integrity_verified",
|
|
277
|
+
status: "passed",
|
|
278
|
+
message: `npm tarball integrity matched registry dist.integrity for ${pkg.identifier}@${pkg.version}.`,
|
|
279
|
+
source: result.source,
|
|
280
|
+
claim: result.expected,
|
|
281
|
+
verificationMethod: "npm-packument-sri",
|
|
282
|
+
verifiedByToolPin: true,
|
|
283
|
+
trustedAnchor: true,
|
|
284
|
+
trustAnchor: result.trustAnchor,
|
|
285
|
+
verifiedAt,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
else if (result.status === "failed") {
|
|
289
|
+
issues.push({
|
|
290
|
+
severity: "critical",
|
|
291
|
+
code: result.issueCode ?? "npm_integrity_failed",
|
|
292
|
+
message: `npm integrity verification failed for ${pkg.identifier}${pkg.version ? `@${pkg.version}` : ""}: ${result.reason ?? "unknown failure"}.`,
|
|
293
|
+
});
|
|
294
|
+
evidence.push({
|
|
295
|
+
code: "npm_integrity_verified",
|
|
296
|
+
status: "failed",
|
|
297
|
+
message: `npm integrity verification failed for ${pkg.identifier}${pkg.version ? `@${pkg.version}` : ""}.`,
|
|
298
|
+
source: result.source,
|
|
299
|
+
claim: result.expected ?? `${pkg.identifier}${pkg.version ? `@${pkg.version}` : ""}`,
|
|
300
|
+
verificationMethod: "npm-packument-sri",
|
|
301
|
+
verifiedByToolPin: true,
|
|
302
|
+
trustedAnchor: result.trustedAnchor,
|
|
303
|
+
trustAnchor: result.trustAnchor,
|
|
304
|
+
verifiedAt,
|
|
305
|
+
failureReason: result.actual ? `computed ${result.actual}` : result.reason,
|
|
306
|
+
required: true,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
evidence.push({
|
|
311
|
+
code: "npm_integrity_verified",
|
|
312
|
+
status: "unavailable",
|
|
313
|
+
message: `npm integrity evidence was not available for ${pkg.identifier}${pkg.version ? `@${pkg.version}` : ""}: ${result.reason}.`,
|
|
314
|
+
source: result.source,
|
|
315
|
+
claim: result.expected ?? `${pkg.identifier}${pkg.version ? `@${pkg.version}` : ""}`,
|
|
316
|
+
verificationMethod: "npm-packument-sri",
|
|
317
|
+
verifiedByToolPin: false,
|
|
318
|
+
trustedAnchor: result.trustedAnchor,
|
|
319
|
+
trustAnchor: result.trustAnchor,
|
|
320
|
+
failureReason: result.reason,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (pkg.registryType === "mcpb") {
|
|
325
|
+
if (isValidSha256Hex(pkg.fileSha256)) {
|
|
326
|
+
badges.push("fileSha256");
|
|
327
|
+
const result = await verifyMcpbSha256(pkg.identifier, pkg.fileSha256, options);
|
|
328
|
+
if (result.status === "passed") {
|
|
329
|
+
badges.push("mcpb-sha256-verified");
|
|
330
|
+
evidence.push({
|
|
331
|
+
code: "mcpb_sha256_verified",
|
|
332
|
+
status: "passed",
|
|
333
|
+
message: `MCPB bytes match declared fileSha256 for ${pkg.identifier}.`,
|
|
334
|
+
source: result.source,
|
|
335
|
+
claim: pkg.fileSha256,
|
|
336
|
+
verificationMethod: "sha256-bytes",
|
|
337
|
+
verifiedByToolPin: true,
|
|
338
|
+
trustedAnchor: true,
|
|
339
|
+
trustAnchor: result.trustAnchor,
|
|
340
|
+
verifiedAt,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
else if (result.status === "failed") {
|
|
344
|
+
issues.push({
|
|
345
|
+
severity: "critical",
|
|
346
|
+
code: "mcpb_sha256_mismatch",
|
|
347
|
+
message: `MCPB fileSha256 mismatch for ${pkg.identifier}: expected ${pkg.fileSha256}, got ${result.actual ?? "unknown"}.`,
|
|
348
|
+
});
|
|
349
|
+
evidence.push({
|
|
350
|
+
code: "mcpb_sha256_verified",
|
|
351
|
+
status: "failed",
|
|
352
|
+
message: `MCPB bytes do not match declared fileSha256 for ${pkg.identifier}.`,
|
|
353
|
+
source: result.source,
|
|
354
|
+
claim: pkg.fileSha256,
|
|
355
|
+
verificationMethod: "sha256-bytes",
|
|
356
|
+
verifiedByToolPin: true,
|
|
357
|
+
trustedAnchor: result.trustedAnchor,
|
|
358
|
+
trustAnchor: result.trustAnchor,
|
|
359
|
+
verifiedAt,
|
|
360
|
+
failureReason: result.actual ? `computed ${result.actual}` : result.reason,
|
|
361
|
+
required: true,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
evidence.push({
|
|
366
|
+
code: "mcpb_sha256_verified",
|
|
367
|
+
status: "unavailable",
|
|
368
|
+
message: `MCPB bytes were not available for ${pkg.identifier}: ${result.reason}.`,
|
|
369
|
+
source: result.source,
|
|
370
|
+
claim: pkg.fileSha256,
|
|
371
|
+
verificationMethod: "sha256-bytes",
|
|
372
|
+
verifiedByToolPin: false,
|
|
373
|
+
trustedAnchor: result.trustedAnchor,
|
|
374
|
+
trustAnchor: result.trustAnchor,
|
|
375
|
+
failureReason: result.reason,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
issues.push({
|
|
381
|
+
severity: "critical",
|
|
382
|
+
code: "missing_mcpb_hash",
|
|
383
|
+
message: "MCPB package is missing a valid 64-character fileSha256.",
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
async function verifyMcpbSha256(identifier, expected, options) {
|
|
389
|
+
const normalizedExpected = normalizeSha256(expected);
|
|
390
|
+
const source = artifactSource(identifier);
|
|
391
|
+
if (source === "file-artifact" || source === "local-file") {
|
|
392
|
+
return {
|
|
393
|
+
status: "unavailable",
|
|
394
|
+
expected: normalizedExpected,
|
|
395
|
+
source,
|
|
396
|
+
trustedAnchor: false,
|
|
397
|
+
reason: "registry metadata cannot request local file hashing",
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
if (identifier.startsWith("http://")) {
|
|
401
|
+
return {
|
|
402
|
+
status: "unavailable",
|
|
403
|
+
expected: normalizedExpected,
|
|
404
|
+
source,
|
|
405
|
+
trustedAnchor: false,
|
|
406
|
+
reason: "MCPB artifact URL is not HTTPS",
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
if (identifier.startsWith("https://")) {
|
|
410
|
+
const trustAnchor = trustedMcpbSourceHost(identifier);
|
|
411
|
+
if (!trustAnchor) {
|
|
412
|
+
return {
|
|
413
|
+
status: "unavailable",
|
|
414
|
+
expected: normalizedExpected,
|
|
415
|
+
source,
|
|
416
|
+
trustedAnchor: false,
|
|
417
|
+
reason: "MCPB artifact host is not a ToolPin trusted anchor",
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
try {
|
|
421
|
+
const bytes = await safeFetchBuffer(identifier, {
|
|
422
|
+
allowedHosts: TRUSTED_MCPB_SOURCES,
|
|
423
|
+
fetch: options.fetch,
|
|
424
|
+
lookup: options.lookup,
|
|
425
|
+
timeoutMs: options.timeoutMs,
|
|
426
|
+
maxBytes: 64 * 1024 * 1024,
|
|
427
|
+
});
|
|
428
|
+
const actual = createHash("sha256").update(bytes).digest("hex");
|
|
429
|
+
return {
|
|
430
|
+
status: actual === normalizedExpected ? "passed" : "failed",
|
|
431
|
+
expected: normalizedExpected,
|
|
432
|
+
actual,
|
|
433
|
+
source,
|
|
434
|
+
trustedAnchor: true,
|
|
435
|
+
trustAnchor,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
catch (error) {
|
|
439
|
+
return {
|
|
440
|
+
status: "unavailable",
|
|
441
|
+
expected: normalizedExpected,
|
|
442
|
+
source,
|
|
443
|
+
trustedAnchor: true,
|
|
444
|
+
trustAnchor,
|
|
445
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return {
|
|
450
|
+
status: "unavailable",
|
|
451
|
+
expected: normalizedExpected,
|
|
452
|
+
source,
|
|
453
|
+
trustedAnchor: false,
|
|
454
|
+
reason: "unsupported MCPB artifact identifier",
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
function artifactSource(identifier) {
|
|
458
|
+
if (identifier.startsWith("https://") || identifier.startsWith("http://"))
|
|
459
|
+
return "http-artifact";
|
|
460
|
+
if (identifier.startsWith("file://"))
|
|
461
|
+
return "file-artifact";
|
|
462
|
+
return "local-file";
|
|
463
|
+
}
|
|
464
|
+
async function verifyOciDigest(identifier, options = {}) {
|
|
465
|
+
const parsed = canonicalizeOciRef(identifier);
|
|
466
|
+
if (!parsed)
|
|
467
|
+
return { status: "unavailable", expected: "", trustedAnchor: false, reason: "unsupported OCI identifier" };
|
|
468
|
+
const trustAnchor = parsed.host;
|
|
469
|
+
if (!trustedOciRegistry(parsed)) {
|
|
470
|
+
return {
|
|
471
|
+
status: "unavailable",
|
|
472
|
+
expected: parsed.digest,
|
|
473
|
+
trustedAnchor: false,
|
|
474
|
+
trustAnchor,
|
|
475
|
+
reason: `OCI registry ${parsed.host} is not a ToolPin trusted anchor`,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
const registryHost = parsed.host === "docker.io" ? "registry-1.docker.io" : parsed.host;
|
|
479
|
+
const url = `https://${registryHost}/v2/${parsed.repository}/manifests/${encodeURIComponent(parsed.digest)}`;
|
|
480
|
+
const headers = {
|
|
481
|
+
Accept: [
|
|
482
|
+
"application/vnd.oci.image.manifest.v1+json",
|
|
483
|
+
"application/vnd.docker.distribution.manifest.v2+json",
|
|
484
|
+
"application/vnd.docker.distribution.manifest.list.v2+json",
|
|
485
|
+
"application/vnd.oci.image.index.v1+json",
|
|
486
|
+
].join(", "),
|
|
487
|
+
};
|
|
488
|
+
try {
|
|
489
|
+
let response = await safeFetch(url, { method: "HEAD", headers, fetch: options.fetch, lookup: options.lookup, timeoutMs: options.timeoutMs });
|
|
490
|
+
if (response.status === 401) {
|
|
491
|
+
const token = await fetchBearerToken(response.headers.get("www-authenticate"), parsed.host, options);
|
|
492
|
+
if (token)
|
|
493
|
+
response = await safeFetch(url, { method: "HEAD", headers: { ...headers, Authorization: `Bearer ${token}` }, fetch: options.fetch, lookup: options.lookup, timeoutMs: options.timeoutMs });
|
|
494
|
+
}
|
|
495
|
+
if (!response.ok)
|
|
496
|
+
return { status: "unavailable", expected: parsed.digest, trustedAnchor: true, trustAnchor, reason: `HTTP ${response.status} ${response.statusText}` };
|
|
497
|
+
const actual = response.headers.get("docker-content-digest") ?? "";
|
|
498
|
+
if (!actual)
|
|
499
|
+
return { status: "unavailable", expected: parsed.digest, trustedAnchor: true, trustAnchor, reason: "registry did not return Docker-Content-Digest" };
|
|
500
|
+
return { status: actual === parsed.digest ? "passed" : "failed", expected: parsed.digest, actual, trustedAnchor: true, trustAnchor };
|
|
501
|
+
}
|
|
502
|
+
catch (error) {
|
|
503
|
+
return { status: "unavailable", expected: parsed.digest, trustedAnchor: true, trustAnchor, reason: error instanceof Error ? error.message : String(error) };
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
async function fetchBearerToken(wwwAuthenticate, registryHost, options = {}) {
|
|
507
|
+
if (!wwwAuthenticate?.startsWith("Bearer "))
|
|
508
|
+
return undefined;
|
|
509
|
+
const params = Object.fromEntries(wwwAuthenticate
|
|
510
|
+
.slice("Bearer ".length)
|
|
511
|
+
.split(",")
|
|
512
|
+
.map((part) => part.trim().split("="))
|
|
513
|
+
.filter((parts) => parts.length === 2)
|
|
514
|
+
.map(([key, value]) => [key, value.replace(/^"|"$/g, "")]));
|
|
515
|
+
if (!params.realm)
|
|
516
|
+
return undefined;
|
|
517
|
+
const url = new URL(params.realm);
|
|
518
|
+
const allowedHosts = trustedOciAuthHosts(registryHost);
|
|
519
|
+
if (params.service)
|
|
520
|
+
url.searchParams.set("service", params.service);
|
|
521
|
+
if (params.scope)
|
|
522
|
+
url.searchParams.set("scope", params.scope);
|
|
523
|
+
const body = await safeFetchJson(url, { allowedHosts, maxBytes: 256 * 1024, fetch: options.fetch, lookup: options.lookup, timeoutMs: options.timeoutMs });
|
|
524
|
+
return body.token ?? body.access_token;
|
|
525
|
+
}
|
|
526
|
+
function normalizeSha256(value) {
|
|
527
|
+
return value.startsWith("sha256:") ? value.slice("sha256:".length).toLowerCase() : value.toLowerCase();
|
|
528
|
+
}
|
|
529
|
+
function dedupeEvidence(evidence) {
|
|
530
|
+
const byKey = new Map();
|
|
531
|
+
for (const entry of evidence) {
|
|
532
|
+
const key = `${entry.code}:${entry.status}:${entry.message}`;
|
|
533
|
+
if (!byKey.has(key))
|
|
534
|
+
byKey.set(key, entry);
|
|
535
|
+
}
|
|
536
|
+
return [...byKey.values()];
|
|
537
|
+
}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const TOOLPIN_VERSION = "0.2.3";
|