trustdex 0.4.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.
@@ -0,0 +1,394 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ function assertFetch(fetchImpl) {
4
+ if (typeof fetchImpl !== 'function') {
5
+ throw new Error('This Node.js runtime does not provide fetch(). Node.js 20+ is required.');
6
+ }
7
+ }
8
+
9
+ async function fetchJson(url, options = {}, fetchImpl = globalThis.fetch) {
10
+ assertFetch(fetchImpl);
11
+ const response = await fetchImpl(url, options);
12
+ if (!response?.ok) {
13
+ throw new Error(`Provenance lookup failed (${response?.status || 'unknown'}): ${url}`);
14
+ }
15
+ return response.json();
16
+ }
17
+
18
+ function normalizeRepoSlug(value) {
19
+ const slug = String(value || '').trim().replace(/^https?:\/\/github\.com\//i, '').replace(/\.git$/, '').replace(/^\/+|\/+$/g, '');
20
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(slug)) {
21
+ throw new Error('GitHub repository must be in owner/repo form.');
22
+ }
23
+ return slug;
24
+ }
25
+
26
+ export async function inspectGitHubRepository(repository, options = {}) {
27
+ const slug = normalizeRepoSlug(repository);
28
+ const url = `https://api.github.com/repos/${slug}`;
29
+ const data = await fetchJson(url, {
30
+ headers: {
31
+ Accept: 'application/vnd.github+json',
32
+ 'User-Agent': 'trustdex'
33
+ }
34
+ }, options.fetchImpl);
35
+
36
+ if (!data?.full_name || String(data.full_name).toLowerCase() !== slug.toLowerCase()) {
37
+ throw new Error('GitHub API response did not match the requested repository.');
38
+ }
39
+
40
+ return {
41
+ adapter: 'github',
42
+ type: 'repository',
43
+ subject: data.full_name,
44
+ publisherHint: data.owner?.login || null,
45
+ observedAt: new Date().toISOString(),
46
+ evidence: {
47
+ kind: 'github-api',
48
+ reference: data.html_url || `https://github.com/${data.full_name}`,
49
+ repositoryId: data.id ?? null,
50
+ ownerLogin: data.owner?.login || null,
51
+ ownerType: data.owner?.type || null,
52
+ archived: Boolean(data.archived),
53
+ disabled: Boolean(data.disabled),
54
+ visibility: data.visibility || (data.private ? 'private' : 'public'),
55
+ defaultBranch: data.default_branch || null,
56
+ pushedAt: data.pushed_at || null
57
+ }
58
+ };
59
+ }
60
+
61
+ function normalizeNpmPackage(value) {
62
+ const name = String(value || '').trim();
63
+ if (!name || /\s/.test(name) || name.includes('://')) throw new Error('Invalid npm package name.');
64
+ return name;
65
+ }
66
+
67
+ export async function inspectGitHubReleaseSignature(repository, options = {}) {
68
+ const slug = normalizeRepoSlug(repository);
69
+ const repoUrl = `https://api.github.com/repos/${slug}`;
70
+ const repoData = await fetchJson(repoUrl, {
71
+ headers: {
72
+ Accept: 'application/vnd.github+json',
73
+ 'User-Agent': 'trustdex'
74
+ }
75
+ }, options.fetchImpl);
76
+
77
+ if (!repoData?.full_name || String(repoData.full_name).toLowerCase() !== slug.toLowerCase()) {
78
+ throw new Error('GitHub API response did not match the requested repository.');
79
+ }
80
+
81
+ const release = await fetchJson(`${repoUrl}/releases/latest`, {
82
+ headers: {
83
+ Accept: 'application/vnd.github+json',
84
+ 'User-Agent': 'trustdex'
85
+ }
86
+ }, options.fetchImpl);
87
+
88
+ if (!release?.tag_name) throw new Error('Latest GitHub release does not expose a tag name.');
89
+
90
+ const ref = await fetchJson(
91
+ `${repoUrl}/git/ref/tags/${encodeURIComponent(release.tag_name)}`,
92
+ {
93
+ headers: {
94
+ Accept: 'application/vnd.github+json',
95
+ 'User-Agent': 'trustdex'
96
+ }
97
+ },
98
+ options.fetchImpl
99
+ );
100
+
101
+ const objectType = ref?.object?.type;
102
+ const objectSha = ref?.object?.sha;
103
+ if (!objectType || !objectSha) throw new Error('GitHub tag reference is incomplete.');
104
+
105
+ let verification = null;
106
+ if (objectType === 'tag') {
107
+ const tag = await fetchJson(`${repoUrl}/git/tags/${objectSha}`, {
108
+ headers: {
109
+ Accept: 'application/vnd.github+json',
110
+ 'User-Agent': 'trustdex'
111
+ }
112
+ }, options.fetchImpl);
113
+ verification = tag?.verification || null;
114
+ } else if (objectType === 'commit') {
115
+ const commit = await fetchJson(`${repoUrl}/commits/${objectSha}`, {
116
+ headers: {
117
+ Accept: 'application/vnd.github+json',
118
+ 'User-Agent': 'trustdex'
119
+ }
120
+ }, options.fetchImpl);
121
+ verification = commit?.commit?.verification || commit?.verification || null;
122
+ } else {
123
+ throw new Error(`Unsupported GitHub tag object type: ${objectType}`);
124
+ }
125
+
126
+ return {
127
+ adapter: 'github-release',
128
+ type: 'repository',
129
+ subject: repoData.full_name,
130
+ publisherHint: repoData.owner?.login || null,
131
+ observedAt: new Date().toISOString(),
132
+ evidence: {
133
+ kind: 'github-release-signature',
134
+ reference: release.html_url || `https://github.com/${repoData.full_name}/releases/tag/${release.tag_name}`,
135
+ tag: release.tag_name,
136
+ objectType,
137
+ objectSha,
138
+ signatureVerified: Boolean(verification?.verified),
139
+ signatureReason: verification?.reason || null,
140
+ signer: verification?.signer?.login || null
141
+ }
142
+ };
143
+ }
144
+
145
+ export async function inspectNpmPackage(packageName, options = {}) {
146
+ const name = normalizeNpmPackage(packageName);
147
+ const url = `https://registry.npmjs.org/${encodeURIComponent(name)}`;
148
+ const data = await fetchJson(url, {
149
+ headers: {
150
+ Accept: 'application/json',
151
+ 'User-Agent': 'trustdex'
152
+ }
153
+ }, options.fetchImpl);
154
+
155
+ if (!data?.name || data.name !== name) {
156
+ throw new Error('npm registry response did not match the requested package.');
157
+ }
158
+
159
+ const latest = data['dist-tags']?.latest || null;
160
+ const latestMeta = latest && data.versions?.[latest] ? data.versions[latest] : null;
161
+ const repository = typeof data.repository === 'string'
162
+ ? data.repository
163
+ : data.repository?.url || latestMeta?.repository?.url || null;
164
+
165
+ return {
166
+ adapter: 'npm',
167
+ type: 'package',
168
+ subject: data.name,
169
+ publisherHint: Array.isArray(data.maintainers) && data.maintainers.length === 1
170
+ ? data.maintainers[0]?.name || null
171
+ : null,
172
+ observedAt: new Date().toISOString(),
173
+ evidence: {
174
+ kind: 'npm-registry',
175
+ reference: `https://www.npmjs.com/package/${name}`,
176
+ latestVersion: latest,
177
+ artifact: latest && latestMeta?.dist?.integrity ? {
178
+ registryType: 'npm',
179
+ identifier: data.name,
180
+ version: latest,
181
+ integrity: String(latestMeta.dist.integrity),
182
+ shasum: latestMeta.dist.shasum ? String(latestMeta.dist.shasum) : null
183
+ } : null,
184
+ repository,
185
+ maintainers: Array.isArray(data.maintainers)
186
+ ? data.maintainers.map((item) => item?.name).filter(Boolean).sort()
187
+ : []
188
+ }
189
+ };
190
+ }
191
+
192
+ function normalizeMcpServerName(value) {
193
+ const name = String(value || '').trim();
194
+ if (!name || !name.includes('/') || /\s/.test(name)) {
195
+ throw new Error('MCP Registry server name must be a namespace-qualified name such as io.github.user/server.');
196
+ }
197
+ return name;
198
+ }
199
+
200
+ export async function inspectMcpRegistryServer(serverName, options = {}) {
201
+ const name = normalizeMcpServerName(serverName);
202
+ const baseUrl = options.baseUrl || 'https://registry.modelcontextprotocol.io';
203
+ if (!/^https:\/\//i.test(baseUrl)) throw new Error('MCP Registry base URL must use HTTPS.');
204
+ const url = `${baseUrl.replace(/\/$/, '')}/v0.1/servers/${encodeURIComponent(name)}/versions/latest`;
205
+ const data = await fetchJson(url, {
206
+ headers: {
207
+ Accept: 'application/json',
208
+ 'User-Agent': 'trustdex'
209
+ }
210
+ }, options.fetchImpl);
211
+
212
+ const server = data?.server || data;
213
+ if (!server?.name || server.name !== name) {
214
+ throw new Error('MCP Registry response did not match the requested server.');
215
+ }
216
+
217
+ const packages = Array.isArray(server.packages) ? server.packages : [];
218
+ const remotes = Array.isArray(server.remotes) ? server.remotes : [];
219
+ let type = 'registry';
220
+ let subject = server.name;
221
+
222
+ if (packages.length === 1 && packages[0]?.identifier) {
223
+ type = 'package';
224
+ subject = String(packages[0].identifier);
225
+ } else if (packages.length === 0 && remotes.length === 1 && remotes[0]?.url) {
226
+ try {
227
+ type = 'remote';
228
+ subject = new URL(remotes[0].url).hostname.toLowerCase();
229
+ } catch {
230
+ type = 'registry';
231
+ subject = server.name;
232
+ }
233
+ }
234
+
235
+ return {
236
+ adapter: 'mcp',
237
+ type,
238
+ subject,
239
+ registryName: server.name,
240
+ publisherHint: null,
241
+ observedAt: new Date().toISOString(),
242
+ evidence: {
243
+ kind: 'mcp-official-registry',
244
+ reference: url,
245
+ serverName: server.name,
246
+ version: server.version || null,
247
+ artifact: packages.length === 1 && packages[0]?.identifier && packages[0]?.version && packages[0]?.fileSha256
248
+ ? {
249
+ registryType: packages[0].registryType || null,
250
+ identifier: String(packages[0].identifier),
251
+ version: String(packages[0].version),
252
+ algorithm: 'sha256',
253
+ digest: String(packages[0].fileSha256).toLowerCase()
254
+ }
255
+ : null,
256
+ repository: server.repository?.url || null,
257
+ repositorySource: server.repository?.source || null,
258
+ packages: packages.map((pkg) => ({
259
+ registryType: pkg?.registryType || null,
260
+ identifier: pkg?.identifier || null,
261
+ version: pkg?.version || null,
262
+ fileSha256: pkg?.fileSha256 || null
263
+ })),
264
+ remotes: remotes.map((remote) => ({
265
+ type: remote?.type || null,
266
+ url: remote?.url || null
267
+ }))
268
+ }
269
+ };
270
+ }
271
+
272
+ function digestMatches(actual, expected) {
273
+ return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
274
+ }
275
+
276
+ export function verifyArtifactBytes(observation, content) {
277
+ const artifact = observation?.evidence?.artifact;
278
+ if (!artifact) throw new Error('The provenance observation does not provide verifiable artifact integrity.');
279
+
280
+ const bytes = Buffer.isBuffer(content) ? content : Buffer.from(content);
281
+ const version = artifact.version ? String(artifact.version) : null;
282
+ const identifier = artifact.identifier ? String(artifact.identifier) : String(observation.subject || '');
283
+
284
+ if (artifact.integrity) {
285
+ const candidates = String(artifact.integrity).trim().split(/\s+/);
286
+ let supported = 0;
287
+ for (const candidate of candidates) {
288
+ const match = candidate.match(/^(sha(?:256|384|512))-([A-Za-z0-9+/=]+)(?:\?.*)?$/i);
289
+ if (!match) continue;
290
+ supported += 1;
291
+ const algorithm = match[1].toLowerCase();
292
+ const expected = Buffer.from(match[2], 'base64');
293
+ const actual = crypto.createHash(algorithm).update(bytes).digest();
294
+ if (digestMatches(actual, expected)) {
295
+ return {
296
+ verified: true,
297
+ algorithm,
298
+ digest: actual.toString('base64'),
299
+ encoding: 'base64',
300
+ version,
301
+ identifier,
302
+ verifiedAt: new Date().toISOString()
303
+ };
304
+ }
305
+ }
306
+ if (!supported) throw new Error('Artifact integrity does not contain a supported SHA-256, SHA-384, or SHA-512 digest.');
307
+ throw new Error('Artifact bytes do not match the registry integrity metadata.');
308
+ }
309
+
310
+ if (artifact.algorithm && artifact.digest) {
311
+ const algorithm = String(artifact.algorithm).toLowerCase();
312
+ if (!['sha256', 'sha384', 'sha512'].includes(algorithm)) {
313
+ throw new Error(`Unsupported artifact digest algorithm: ${algorithm}`);
314
+ }
315
+ const expectedHex = String(artifact.digest).toLowerCase();
316
+ if (!/^[a-f0-9]+$/.test(expectedHex) || expectedHex.length !== crypto.createHash(algorithm).digest().length * 2) {
317
+ throw new Error('Artifact digest has an invalid hexadecimal format.');
318
+ }
319
+ const actual = crypto.createHash(algorithm).update(bytes).digest();
320
+ const expected = Buffer.from(expectedHex, 'hex');
321
+ if (!digestMatches(actual, expected)) {
322
+ throw new Error('Artifact bytes do not match the registry digest.');
323
+ }
324
+ return {
325
+ verified: true,
326
+ algorithm,
327
+ digest: actual.toString('hex'),
328
+ encoding: 'hex',
329
+ version,
330
+ identifier,
331
+ verifiedAt: new Date().toISOString()
332
+ };
333
+ }
334
+
335
+ throw new Error('The provenance observation does not provide a supported cryptographic digest.');
336
+ }
337
+
338
+ export function provenanceObservationToClaim(observation, publisher, options = {}) {
339
+ if (!observation?.type || !observation?.subject || !observation?.evidence) {
340
+ throw new Error('Invalid provenance observation.');
341
+ }
342
+ if (!['package', 'remote', 'repository'].includes(observation.type)) {
343
+ throw new Error('This observation does not map to a single trustable source. Review it manually instead.');
344
+ }
345
+ if (observation.adapter === 'github-release' && !observation.evidence?.signatureVerified) {
346
+ throw new Error('Refusing to trust a GitHub release without a verified signature.');
347
+ }
348
+
349
+ const publisherName = String(publisher || '').trim();
350
+ if (!publisherName) throw new Error('A publisher name is required for explicit approval.');
351
+
352
+ const evidence = {
353
+ kind: observation.evidence.kind,
354
+ reference: observation.evidence.reference,
355
+ checkedAt: observation.observedAt || new Date().toISOString()
356
+ };
357
+
358
+ if (options.artifactVerification) {
359
+ if (!options.artifactVerification.verified || !observation.evidence.artifact) {
360
+ throw new Error('Artifact verification is incomplete.');
361
+ }
362
+ evidence.artifact = JSON.parse(JSON.stringify(observation.evidence.artifact));
363
+ evidence.artifactVerification = {
364
+ verified: true,
365
+ algorithm: String(options.artifactVerification.algorithm),
366
+ digest: String(options.artifactVerification.digest),
367
+ encoding: String(options.artifactVerification.encoding),
368
+ version: options.artifactVerification.version ? String(options.artifactVerification.version) : null,
369
+ identifier: options.artifactVerification.identifier ? String(options.artifactVerification.identifier) : null,
370
+ verifiedAt: String(options.artifactVerification.verifiedAt)
371
+ };
372
+ }
373
+
374
+ return {
375
+ type: observation.type,
376
+ subject: observation.subject,
377
+ publisher: publisherName,
378
+ status: 'verified',
379
+ evidence
380
+ };
381
+ }
382
+
383
+ export function upsertTrustStoreClaim(store, claim) {
384
+ const current = store && typeof store === 'object' ? store : { version: 1, claims: [] };
385
+ if (current.version !== 1) throw new Error('Unsupported trust-store version. Expected version 1.');
386
+ const claims = Array.isArray(current.claims) ? [...current.claims] : [];
387
+ const index = claims.findIndex((item) =>
388
+ item?.type === claim.type &&
389
+ String(item?.subject || '').toLowerCase() === String(claim.subject).toLowerCase()
390
+ );
391
+ if (index >= 0) claims[index] = claim;
392
+ else claims.push(claim);
393
+ return { version: 1, claims };
394
+ }
@@ -0,0 +1,194 @@
1
+ function normalizeHost(value) {
2
+ return String(value || '').trim().toLowerCase().replace(/\.$/, '');
3
+ }
4
+
5
+ function normalizePackage(value) {
6
+ const spec = String(value || '').trim();
7
+ if (spec.startsWith('@')) {
8
+ const slash = spec.indexOf('/');
9
+ if (slash < 0) return spec;
10
+ const versionAt = spec.indexOf('@', slash);
11
+ return versionAt > slash ? spec.slice(0, versionAt) : spec;
12
+ }
13
+ const versionAt = spec.lastIndexOf('@');
14
+ return versionAt > 0 ? spec.slice(0, versionAt) : spec.replace(/==[^=\s]+$/, '');
15
+ }
16
+
17
+ function packageVersion(value) {
18
+ const spec = String(value || '').trim();
19
+ if (spec.startsWith('@')) {
20
+ const slash = spec.indexOf('/');
21
+ const versionAt = slash >= 0 ? spec.indexOf('@', slash) : -1;
22
+ return versionAt > slash ? spec.slice(versionAt + 1) : null;
23
+ }
24
+ const versionAt = spec.lastIndexOf('@');
25
+ if (versionAt > 0) return spec.slice(versionAt + 1);
26
+ const equals = spec.match(/==([^=\s]+)$/);
27
+ return equals ? equals[1] : null;
28
+ }
29
+
30
+ function validateClaim(claim, index) {
31
+ if (!claim || typeof claim !== 'object' || Array.isArray(claim)) {
32
+ throw new Error(`Trust-store claim #${index + 1} must be an object.`);
33
+ }
34
+ if (!['package', 'remote', 'repository'].includes(claim.type)) {
35
+ throw new Error(`Trust-store claim #${index + 1} has unsupported type "${claim.type}".`);
36
+ }
37
+ if (!claim.subject || !claim.publisher) {
38
+ throw new Error(`Trust-store claim #${index + 1} requires subject and publisher.`);
39
+ }
40
+ if (claim.status !== 'verified') {
41
+ throw new Error(`Trust-store claim #${index + 1} must explicitly use status "verified".`);
42
+ }
43
+ if (!claim.evidence || typeof claim.evidence !== 'object') {
44
+ throw new Error(`Trust-store claim #${index + 1} requires evidence metadata.`);
45
+ }
46
+ if (!claim.evidence.kind || !claim.evidence.reference) {
47
+ throw new Error(`Trust-store claim #${index + 1} evidence requires kind and reference.`);
48
+ }
49
+
50
+ const artifact = claim.evidence.artifact;
51
+ const verification = claim.evidence.artifactVerification;
52
+ if (Boolean(artifact) !== Boolean(verification)) {
53
+ throw new Error(`Trust-store claim #${index + 1} requires artifact and artifactVerification together.`);
54
+ }
55
+ if (verification) {
56
+ if (verification.verified !== true || !verification.algorithm || !verification.digest || !verification.verifiedAt) {
57
+ throw new Error(`Trust-store claim #${index + 1} has incomplete artifact verification.`);
58
+ }
59
+ if (!artifact.version || !artifact.identifier) {
60
+ throw new Error(`Trust-store claim #${index + 1} artifact requires identifier and version.`);
61
+ }
62
+ if (String(verification.version || '') !== String(artifact.version)) {
63
+ throw new Error(`Trust-store claim #${index + 1} artifact version does not match its verification.`);
64
+ }
65
+ if (String(verification.identifier || '') !== String(artifact.identifier)) {
66
+ throw new Error(`Trust-store claim #${index + 1} artifact identifier does not match its verification.`);
67
+ }
68
+
69
+ const algorithm = String(verification.algorithm).toLowerCase();
70
+ const digest = String(verification.digest);
71
+ if (artifact.integrity) {
72
+ const expected = `${algorithm}-${digest}`;
73
+ const entries = String(artifact.integrity).trim().split(/\s+/).map((entry) => entry.split('?')[0]);
74
+ if (String(verification.encoding || '') !== 'base64' || !entries.includes(expected)) {
75
+ throw new Error(`Trust-store claim #${index + 1} artifact integrity does not match its verification.`);
76
+ }
77
+ } else if (
78
+ String(artifact.algorithm || '').toLowerCase() !== algorithm ||
79
+ String(artifact.digest || '').toLowerCase() !== digest.toLowerCase() ||
80
+ String(verification.encoding || '') !== 'hex'
81
+ ) {
82
+ throw new Error(`Trust-store claim #${index + 1} artifact digest does not match its verification.`);
83
+ }
84
+ }
85
+ }
86
+
87
+ export function parseTrustStore(value) {
88
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
89
+ throw new Error('Trust store must be a JSON object.');
90
+ }
91
+ if (value.version !== 1) throw new Error('Unsupported trust-store version. Expected version 1.');
92
+ const claims = Array.isArray(value.claims) ? value.claims : [];
93
+ claims.forEach(validateClaim);
94
+ return {
95
+ version: 1,
96
+ claims: claims.map((claim) => ({
97
+ type: claim.type,
98
+ subject: claim.type === 'remote' ? normalizeHost(claim.subject) : String(claim.subject),
99
+ publisher: String(claim.publisher),
100
+ status: 'verified',
101
+ evidence: {
102
+ kind: String(claim.evidence.kind),
103
+ reference: String(claim.evidence.reference),
104
+ checkedAt: claim.evidence.checkedAt ? String(claim.evidence.checkedAt) : null,
105
+ ...(claim.evidence.artifact ? {
106
+ artifact: {
107
+ registryType: claim.evidence.artifact.registryType ? String(claim.evidence.artifact.registryType) : null,
108
+ identifier: String(claim.evidence.artifact.identifier),
109
+ version: String(claim.evidence.artifact.version),
110
+ integrity: claim.evidence.artifact.integrity ? String(claim.evidence.artifact.integrity) : null,
111
+ shasum: claim.evidence.artifact.shasum ? String(claim.evidence.artifact.shasum) : null,
112
+ algorithm: claim.evidence.artifact.algorithm ? String(claim.evidence.artifact.algorithm) : null,
113
+ digest: claim.evidence.artifact.digest ? String(claim.evidence.artifact.digest).toLowerCase() : null
114
+ },
115
+ artifactVerification: {
116
+ verified: true,
117
+ algorithm: String(claim.evidence.artifactVerification.algorithm),
118
+ digest: String(claim.evidence.artifactVerification.digest),
119
+ encoding: String(claim.evidence.artifactVerification.encoding || 'hex'),
120
+ version: claim.evidence.artifactVerification.version
121
+ ? String(claim.evidence.artifactVerification.version)
122
+ : null,
123
+ identifier: claim.evidence.artifactVerification.identifier
124
+ ? String(claim.evidence.artifactVerification.identifier)
125
+ : null,
126
+ verifiedAt: String(claim.evidence.artifactVerification.verifiedAt)
127
+ }
128
+ } : {})
129
+ }
130
+ }))
131
+ };
132
+ }
133
+
134
+ function claimForSource(source, store) {
135
+ if (!store?.claims?.length || !source) return null;
136
+
137
+ if (source.type === 'package') {
138
+ const subject = normalizePackage(source.package);
139
+ return store.claims.find((claim) => claim.type === 'package' && normalizePackage(claim.subject) === subject) || null;
140
+ }
141
+
142
+ if (source.type === 'remote') {
143
+ const subject = normalizeHost(source.host);
144
+ return store.claims.find((claim) => claim.type === 'remote' && normalizeHost(claim.subject) === subject) || null;
145
+ }
146
+
147
+ if (source.type === 'repository' && source.repository) {
148
+ return store.claims.find((claim) =>
149
+ claim.type === 'repository' &&
150
+ String(claim.subject).toLowerCase() === String(source.repository).toLowerCase()
151
+ ) || null;
152
+ }
153
+
154
+ return null;
155
+ }
156
+
157
+ export function attachProvenance(item, trustStore) {
158
+ const claim = claimForSource(item.source, trustStore);
159
+ if (!claim) return { ...item, provenance: { status: 'unknown' } };
160
+
161
+ const verifiedArtifact = claim.evidence?.artifactVerification?.verified === true
162
+ ? claim.evidence.artifact
163
+ : null;
164
+ const configuredVersion = item.source?.type === 'package'
165
+ ? packageVersion(item.source.package)
166
+ : null;
167
+ if (verifiedArtifact?.version && configuredVersion && verifiedArtifact.version !== configuredVersion) {
168
+ return {
169
+ ...item,
170
+ signals: [...new Set([...(item.signals || []), 'artifact-version-mismatch'])].sort(),
171
+ provenance: {
172
+ status: 'mismatch',
173
+ publisher: claim.publisher,
174
+ expectedVersion: verifiedArtifact.version,
175
+ configuredVersion,
176
+ evidence: claim.evidence
177
+ }
178
+ };
179
+ }
180
+
181
+ return {
182
+ ...item,
183
+ provenance: {
184
+ status: 'verified',
185
+ publisher: claim.publisher,
186
+ integrityVerified: Boolean(verifiedArtifact),
187
+ evidence: claim.evidence
188
+ }
189
+ };
190
+ }
191
+
192
+ export function attachProvenanceAll(items, trustStore) {
193
+ return items.map((item) => attachProvenance(item, trustStore));
194
+ }
package/src/review.mjs ADDED
@@ -0,0 +1,39 @@
1
+ import { diffSnapshots } from './snapshot.mjs';
2
+ import { verifyTrustRecord } from './trust-record.mjs';
3
+
4
+ export function assessReview({ record, publicKey, baseline, current, policy = null }) {
5
+ const signature = verifyTrustRecord(record, publicKey, {
6
+ snapshot: baseline,
7
+ policy
8
+ });
9
+
10
+ if (!signature.valid) {
11
+ return {
12
+ status: 'invalid',
13
+ reason: signature.reason,
14
+ changes: []
15
+ };
16
+ }
17
+
18
+ if (!current?.digest) {
19
+ return {
20
+ status: 'invalid',
21
+ reason: 'current snapshot is missing a digest',
22
+ changes: []
23
+ };
24
+ }
25
+
26
+ if (current.digest === baseline.digest) {
27
+ return {
28
+ status: 'approved',
29
+ reason: 'current state matches the signed review',
30
+ changes: []
31
+ };
32
+ }
33
+
34
+ return {
35
+ status: 'needs-review',
36
+ reason: 'current state differs from the signed review',
37
+ changes: diffSnapshots(baseline, current)
38
+ };
39
+ }