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.
- package/bin/xgauntlet.js +218 -10
- 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
|
-
|
|
110
|
-
|
|
287
|
+
let tempArchive = null;
|
|
288
|
+
let createdCacheDir = false;
|
|
111
289
|
|
|
112
290
|
try {
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
119
|
-
|
|
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(
|
|
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`);
|