xgauntlet 0.8.2 → 0.8.4

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.
Files changed (2) hide show
  1. package/bin/xgauntlet.js +218 -10
  2. package/package.json +4 -1
package/bin/xgauntlet.js CHANGED
@@ -8,6 +8,7 @@ import { spawnSync, spawn, execFileSync } from 'node:child_process';
8
8
  import path from 'node:path';
9
9
  import fs from 'node:fs';
10
10
  import os from 'node:os';
11
+ import crypto from 'node:crypto';
11
12
  import { fileURLToPath } from 'node:url';
12
13
 
13
14
  const __filename = fileURLToPath(import.meta.url);
@@ -17,6 +18,149 @@ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
17
18
  const VERSION = pkg.version;
18
19
  const REPO = 'rolfmadsen/xgauntlet';
19
20
 
21
+ export const EXPECTED_REPOSITORY_ID = '1359184794';
22
+ export const EXPECTED_REPO = 'rolfmadsen/xgauntlet';
23
+ export const EXPECTED_WORKFLOW = '.github/workflows/release.yml';
24
+
25
+ export function extractBundleClaims(bundle, statement) {
26
+ let repositoryId = null;
27
+ let repository = null;
28
+ let workflow = null;
29
+ let ref = null;
30
+
31
+ if (statement?.predicate) {
32
+ const pred = statement.predicate;
33
+ repositoryId =
34
+ pred.buildDefinition?.internalParameters?.github?.repository_id ||
35
+ pred.invocation?.environment?.github?.repository_id ||
36
+ null;
37
+
38
+ repository =
39
+ pred.buildDefinition?.externalParameters?.workflow?.repository ||
40
+ pred.invocation?.configSource?.uri ||
41
+ null;
42
+
43
+ workflow =
44
+ pred.buildDefinition?.externalParameters?.workflow?.path ||
45
+ pred.invocation?.configSource?.entryPoint ||
46
+ null;
47
+
48
+ ref =
49
+ pred.buildDefinition?.externalParameters?.workflow?.ref ||
50
+ pred.invocation?.configSource?.ref ||
51
+ null;
52
+ }
53
+
54
+ const certBytes =
55
+ bundle?.verificationMaterial?.certificate?.rawBytes ||
56
+ bundle?.verificationMaterial?.x509CertificateChain?.certificates?.[0]?.rawBytes;
57
+
58
+ if (certBytes) {
59
+ const certBuf = Buffer.isBuffer(certBytes) ? certBytes : Buffer.from(certBytes, 'base64');
60
+ const certStr = certBuf.toString('utf8');
61
+
62
+ if (!repositoryId && certStr.includes('1359184794')) {
63
+ repositoryId = '1359184794';
64
+ }
65
+ if (!repository && certStr.includes('rolfmadsen/xgauntlet')) {
66
+ repository = 'rolfmadsen/xgauntlet';
67
+ }
68
+ if (!workflow && certStr.includes('.github/workflows/release.yml')) {
69
+ workflow = '.github/workflows/release.yml';
70
+ }
71
+ }
72
+
73
+ return { repositoryId, repository, workflow, ref };
74
+ }
75
+
76
+ export async function verifyReleaseArchive({
77
+ archiveBuffer,
78
+ archiveName,
79
+ bundle,
80
+ expectedRepoId = EXPECTED_REPOSITORY_ID,
81
+ expectedRepo = EXPECTED_REPO,
82
+ expectedWorkflow = EXPECTED_WORKFLOW,
83
+ expectedRef,
84
+ sigstoreVerifier,
85
+ claimsExtractor,
86
+ }) {
87
+ if (!bundle) {
88
+ throw new Error('INVALID_BUNDLE: Missing attestation bundle');
89
+ }
90
+
91
+ if (sigstoreVerifier) {
92
+ await sigstoreVerifier(bundle, archiveBuffer);
93
+ } else {
94
+ try {
95
+ const sigstore = await import('sigstore');
96
+ await sigstore.verify(bundle, archiveBuffer);
97
+ } catch (err) {
98
+ throw new Error(`SIGNATURE_VERIFICATION_FAILED: ${err.message}`);
99
+ }
100
+ }
101
+
102
+ if (!bundle.dsseEnvelope?.payload) {
103
+ throw new Error('INVALID_BUNDLE: Missing dsseEnvelope payload in attestation bundle');
104
+ }
105
+
106
+ const payloadStr = Buffer.from(bundle.dsseEnvelope.payload, 'base64').toString('utf8');
107
+ let statement;
108
+ try {
109
+ statement = JSON.parse(payloadStr);
110
+ } catch (err) {
111
+ throw new Error(`INVALID_PAYLOAD: Failed to parse in-toto statement JSON: ${err.message}`);
112
+ }
113
+
114
+ const actualDigest = crypto.createHash('sha256').update(archiveBuffer).digest('hex');
115
+
116
+ const subjects = statement.subject || [];
117
+ const matchingSubject = subjects.find((s) => {
118
+ const subjDigest = s.digest?.sha256;
119
+ if (!subjDigest) return false;
120
+ return subjDigest.toLowerCase() === actualDigest.toLowerCase();
121
+ });
122
+
123
+ if (!matchingSubject) {
124
+ throw new Error(
125
+ `DIGEST_MISMATCH: Archive digest (${actualDigest}) does not match any attested subject in bundle`
126
+ );
127
+ }
128
+
129
+ const claims = claimsExtractor
130
+ ? claimsExtractor(bundle, statement)
131
+ : extractBundleClaims(bundle, statement);
132
+
133
+ if (claims.repositoryId && String(claims.repositoryId) !== String(expectedRepoId)) {
134
+ throw new Error(
135
+ `REPOSITORY_ID_MISMATCH: Attested repository ID '${claims.repositoryId}' does not match expected ID '${expectedRepoId}'`
136
+ );
137
+ }
138
+
139
+ if (claims.repository && !claims.repository.toLowerCase().includes(expectedRepo.toLowerCase())) {
140
+ throw new Error(
141
+ `REPOSITORY_MISMATCH: Attested repository '${claims.repository}' does not match expected repository '${expectedRepo}'`
142
+ );
143
+ }
144
+
145
+ if (claims.workflow && !claims.workflow.includes(expectedWorkflow)) {
146
+ throw new Error(
147
+ `WORKFLOW_MISMATCH: Attested workflow '${claims.workflow}' does not match expected workflow '${expectedWorkflow}'`
148
+ );
149
+ }
150
+
151
+ if (expectedRef && claims.ref && claims.ref !== expectedRef) {
152
+ throw new Error(
153
+ `REF_MISMATCH: Attested ref '${claims.ref}' does not match expected ref '${expectedRef}'`
154
+ );
155
+ }
156
+
157
+ return {
158
+ verified: true,
159
+ digest: actualDigest,
160
+ claims,
161
+ };
162
+ }
163
+
20
164
  export function getPlatformInfo(platform = os.platform(), arch = os.arch()) {
21
165
  const isWindows = platform === 'win32';
22
166
  const binName = isWindows ? 'xgauntlet.exe' : 'xgauntlet';
@@ -48,6 +192,9 @@ function getCacheDir() {
48
192
  }
49
193
 
50
194
  function findLocalBinary() {
195
+ if (process.env.XGAUNTLET_TEST_FORCE_NOT_FOUND === '1') {
196
+ return null;
197
+ }
51
198
  const { isWindows, binName } = getPlatformInfo();
52
199
 
53
200
  // 1. Check Cargo target directory in local workspace
@@ -89,7 +236,38 @@ function findLocalBinary() {
89
236
  return null;
90
237
  }
91
238
 
239
+ export async function fetchAttestationBundle(repo = REPO, version = VERSION, customFetcher = null) {
240
+ const fetchFn = customFetcher || fetch;
241
+ const candidates = [
242
+ `https://github.com/${repo}/releases/download/v${version}/attestation.bundle`,
243
+ `https://github.com/${repo}/releases/download/v${version}/attestation.json`,
244
+ ];
245
+
246
+ let lastStatus = 404;
247
+ let lastStatusText = 'Not Found';
248
+
249
+ for (const url of candidates) {
250
+ try {
251
+ const res = await fetchFn(url, { redirect: 'follow' });
252
+ if (res && res.ok) {
253
+ return await res.json();
254
+ }
255
+ if (res) {
256
+ lastStatus = res.status;
257
+ lastStatusText = res.statusText || 'Not Found';
258
+ }
259
+ } catch (err) {
260
+ lastStatusText = err.message;
261
+ }
262
+ }
263
+
264
+ throw new Error(`HTTP ${lastStatus} fetching attestation bundle: ${lastStatusText}`);
265
+ }
266
+
92
267
  async function downloadAndCacheBinary() {
268
+ if (process.env.XGAUNTLET_TEST_FORCE_NOT_FOUND === '1') {
269
+ throw new Error('Forced missing binary for test');
270
+ }
93
271
  const { platform, arch, isWindows, binName, archiveName } = getPlatformInfo();
94
272
 
95
273
  if (!archiveName) {
@@ -106,17 +284,39 @@ async function downloadAndCacheBinary() {
106
284
  const downloadUrl = `https://github.com/${REPO}/releases/download/v${VERSION}/${archiveName}`;
107
285
  process.stderr.write(`[xgauntlet] Fetching native engine v${VERSION} for ${platform}-${arch}...\n`);
108
286
 
109
- fs.mkdirSync(cacheDir, { recursive: true });
110
- const tempArchive = path.join(cacheDir, `temp-${Date.now()}-${archiveName}`);
287
+ let tempArchive = null;
288
+ let createdCacheDir = false;
111
289
 
112
290
  try {
113
- const res = await fetch(downloadUrl, { redirect: 'follow' });
114
- if (!res.ok) {
115
- throw new Error(`HTTP ${res.status}: ${res.statusText}`);
291
+ const [resArchive, bundleJson] = await Promise.all([
292
+ fetch(downloadUrl, { redirect: 'follow' }),
293
+ fetchAttestationBundle(REPO, VERSION),
294
+ ]);
295
+
296
+ if (!resArchive.ok) {
297
+ throw new Error(`HTTP ${resArchive.status} fetching binary archive: ${resArchive.statusText}`);
116
298
  }
117
299
 
118
- const buffer = Buffer.from(await res.arrayBuffer());
119
- fs.writeFileSync(tempArchive, buffer);
300
+ const archiveBuffer = Buffer.from(await resArchive.arrayBuffer());
301
+
302
+ process.stderr.write(`[xgauntlet] Verifying cryptographic release attestation for ${archiveName}...\n`);
303
+ await verifyReleaseArchive({
304
+ archiveBuffer,
305
+ archiveName,
306
+ bundle: bundleJson,
307
+ expectedRepoId: EXPECTED_REPOSITORY_ID,
308
+ expectedRepo: EXPECTED_REPO,
309
+ expectedWorkflow: EXPECTED_WORKFLOW,
310
+ expectedRef: `refs/tags/v${VERSION}`,
311
+ });
312
+ process.stderr.write(`[xgauntlet] Attestation verified successfully (Repo ID: ${EXPECTED_REPOSITORY_ID}).\n`);
313
+
314
+ if (!fs.existsSync(cacheDir)) {
315
+ fs.mkdirSync(cacheDir, { recursive: true });
316
+ createdCacheDir = true;
317
+ }
318
+ tempArchive = path.join(cacheDir, `temp-${Date.now()}-${archiveName}`);
319
+ fs.writeFileSync(tempArchive, archiveBuffer);
120
320
 
121
321
  if (archiveName.endsWith('.tar.gz')) {
122
322
  execFileSync('tar', ['-xzf', tempArchive, '-C', cacheDir], { stdio: 'pipe' });
@@ -128,7 +328,7 @@ async function downloadAndCacheBinary() {
128
328
  }
129
329
  }
130
330
 
131
- if (fs.existsSync(tempArchive)) {
331
+ if (tempArchive && fs.existsSync(tempArchive)) {
132
332
  fs.unlinkSync(tempArchive);
133
333
  }
134
334
 
@@ -143,9 +343,17 @@ async function downloadAndCacheBinary() {
143
343
  throw new Error(`Extracted archive did not contain expected binary: ${binName}`);
144
344
  }
145
345
  } catch (err) {
146
- if (fs.existsSync(tempArchive)) {
346
+ if (tempArchive && fs.existsSync(tempArchive)) {
147
347
  try { fs.unlinkSync(tempArchive); } catch {}
148
348
  }
349
+ if (createdCacheDir && fs.existsSync(cacheDir)) {
350
+ try {
351
+ const files = fs.readdirSync(cacheDir);
352
+ if (files.length === 0) {
353
+ fs.rmdirSync(cacheDir);
354
+ }
355
+ } catch {}
356
+ }
149
357
  throw err;
150
358
  }
151
359
  }
@@ -165,7 +373,7 @@ async function main() {
165
373
  console.log(` Version: v${VERSION}`);
166
374
  console.log(` Engine: NOT_FOUND (${err.message})`);
167
375
  console.log(` Notice: Run \`cargo build --release\` in xGauntlet repo or download v${VERSION} release.`);
168
- process.exit(0);
376
+ process.exit(1);
169
377
  }
170
378
 
171
379
  console.error(`\x1b[31m[xgauntlet] Error: Could not resolve native engine binary for ${platform}-${arch}.\x1b[0m`);
package/package.json CHANGED
@@ -38,5 +38,8 @@
38
38
  "test": "node --test"
39
39
  },
40
40
  "type": "module",
41
- "version": "0.8.2"
41
+ "version": "0.8.4",
42
+ "dependencies": {
43
+ "sigstore": "^3.1.0"
44
+ }
42
45
  }