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.
- package/bin/xgauntlet.js +178 -6
- 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
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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(
|
|
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`);
|