xgauntlet 0.8.2 → 0.8.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.
Files changed (2) hide show
  1. package/bin/xgauntlet.js +178 -6
  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
@@ -90,6 +237,9 @@ function findLocalBinary() {
90
237
  }
91
238
 
92
239
  async function downloadAndCacheBinary() {
240
+ if (process.env.XGAUNTLET_TEST_FORCE_NOT_FOUND === '1') {
241
+ throw new Error('Forced missing binary for test');
242
+ }
93
243
  const { platform, arch, isWindows, binName, archiveName } = getPlatformInfo();
94
244
 
95
245
  if (!archiveName) {
@@ -104,19 +254,41 @@ async function downloadAndCacheBinary() {
104
254
  }
105
255
 
106
256
  const downloadUrl = `https://github.com/${REPO}/releases/download/v${VERSION}/${archiveName}`;
257
+ const bundleUrl = `https://github.com/${REPO}/releases/download/v${VERSION}/attestation.bundle`;
107
258
  process.stderr.write(`[xgauntlet] Fetching native engine v${VERSION} for ${platform}-${arch}...\n`);
108
259
 
109
260
  fs.mkdirSync(cacheDir, { recursive: true });
110
261
  const tempArchive = path.join(cacheDir, `temp-${Date.now()}-${archiveName}`);
111
262
 
112
263
  try {
113
- const res = await fetch(downloadUrl, { redirect: 'follow' });
114
- if (!res.ok) {
115
- throw new Error(`HTTP ${res.status}: ${res.statusText}`);
264
+ const [resArchive, resBundle] = await Promise.all([
265
+ fetch(downloadUrl, { redirect: 'follow' }),
266
+ fetch(bundleUrl, { redirect: 'follow' }),
267
+ ]);
268
+
269
+ if (!resArchive.ok) {
270
+ throw new Error(`HTTP ${resArchive.status} fetching binary archive: ${resArchive.statusText}`);
116
271
  }
272
+ if (!resBundle.ok) {
273
+ throw new Error(`HTTP ${resBundle.status} fetching attestation bundle: ${resBundle.statusText}`);
274
+ }
275
+
276
+ const archiveBuffer = Buffer.from(await resArchive.arrayBuffer());
277
+ const bundleJson = await resBundle.json();
278
+
279
+ process.stderr.write(`[xgauntlet] Verifying cryptographic release attestation for ${archiveName}...\n`);
280
+ await verifyReleaseArchive({
281
+ archiveBuffer,
282
+ archiveName,
283
+ bundle: bundleJson,
284
+ expectedRepoId: EXPECTED_REPOSITORY_ID,
285
+ expectedRepo: EXPECTED_REPO,
286
+ expectedWorkflow: EXPECTED_WORKFLOW,
287
+ expectedRef: `refs/tags/v${VERSION}`,
288
+ });
289
+ process.stderr.write(`[xgauntlet] Attestation verified successfully (Repo ID: ${EXPECTED_REPOSITORY_ID}).\n`);
117
290
 
118
- const buffer = Buffer.from(await res.arrayBuffer());
119
- fs.writeFileSync(tempArchive, buffer);
291
+ fs.writeFileSync(tempArchive, archiveBuffer);
120
292
 
121
293
  if (archiveName.endsWith('.tar.gz')) {
122
294
  execFileSync('tar', ['-xzf', tempArchive, '-C', cacheDir], { stdio: 'pipe' });
@@ -165,7 +337,7 @@ async function main() {
165
337
  console.log(` Version: v${VERSION}`);
166
338
  console.log(` Engine: NOT_FOUND (${err.message})`);
167
339
  console.log(` Notice: Run \`cargo build --release\` in xGauntlet repo or download v${VERSION} release.`);
168
- process.exit(0);
340
+ process.exit(1);
169
341
  }
170
342
 
171
343
  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.3",
42
+ "dependencies": {
43
+ "sigstore": "^3.1.0"
44
+ }
42
45
  }