@attest-it/cli 0.0.1
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/README.md +125 -0
- package/dist/bin/attest-it.js +855 -0
- package/dist/bin/attest-it.js.map +1 -0
- package/dist/index.cjs +883 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +854 -0
- package/dist/index.js.map +1 -0
- package/package.json +50 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,854 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import YAML from 'yaml';
|
|
5
|
+
import pc from 'picocolors';
|
|
6
|
+
import { confirm, input, select } from '@inquirer/prompts';
|
|
7
|
+
import { loadConfig, readAttestations, computeFingerprint, findAttestation, createAttestation, upsertAttestation, getDefaultPrivateKeyPath, writeSignedAttestations, checkOpenSSL, getDefaultPublicKeyPath, generateKeyPair, setKeyPermissions, verifyAttestations, toAttestItConfig } from '@attest-it/core';
|
|
8
|
+
import { spawn } from 'child_process';
|
|
9
|
+
import * as os from 'os';
|
|
10
|
+
import { parse } from 'shell-quote';
|
|
11
|
+
|
|
12
|
+
// src/index.ts
|
|
13
|
+
var globalOptions = {};
|
|
14
|
+
function setOutputOptions(options) {
|
|
15
|
+
globalOptions = options;
|
|
16
|
+
}
|
|
17
|
+
function log(message) {
|
|
18
|
+
if (!globalOptions.quiet) {
|
|
19
|
+
console.log(message);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function verbose(message) {
|
|
23
|
+
if (globalOptions.verbose && !globalOptions.quiet) {
|
|
24
|
+
console.log(pc.dim(message));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function success(message) {
|
|
28
|
+
log(pc.green("\u2713 " + message));
|
|
29
|
+
}
|
|
30
|
+
function error(message) {
|
|
31
|
+
console.error(pc.red("\u2717 " + message));
|
|
32
|
+
}
|
|
33
|
+
function warn(message) {
|
|
34
|
+
if (!globalOptions.quiet) {
|
|
35
|
+
console.warn(pc.yellow("\u26A0 " + message));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function info(message) {
|
|
39
|
+
log(pc.blue("\u2139 " + message));
|
|
40
|
+
}
|
|
41
|
+
function formatTable(rows) {
|
|
42
|
+
const headers = ["Suite", "Status", "Fingerprint", "Age"];
|
|
43
|
+
const getRowValues = (row) => [
|
|
44
|
+
row.suite,
|
|
45
|
+
row.status,
|
|
46
|
+
row.fingerprint,
|
|
47
|
+
row.age
|
|
48
|
+
];
|
|
49
|
+
const widths = headers.map((h, i) => {
|
|
50
|
+
const columnValues = rows.map((r) => {
|
|
51
|
+
const values = getRowValues(r);
|
|
52
|
+
return values[i] ?? "";
|
|
53
|
+
});
|
|
54
|
+
const maxValueLength = Math.max(...columnValues.map((v) => v.length), 0);
|
|
55
|
+
return Math.max(h.length, maxValueLength);
|
|
56
|
+
});
|
|
57
|
+
const separator = "\u2500";
|
|
58
|
+
const lines = [];
|
|
59
|
+
lines.push(headers.map((h, i) => h.padEnd(widths[i] ?? 0)).join(" \u2502 "));
|
|
60
|
+
lines.push(widths.map((w) => separator.repeat(w)).join("\u2500\u253C\u2500"));
|
|
61
|
+
for (const row of rows) {
|
|
62
|
+
const values = getRowValues(row);
|
|
63
|
+
lines.push(values.map((v, i) => v.padEnd(widths[i] ?? 0)).join(" \u2502 "));
|
|
64
|
+
}
|
|
65
|
+
return lines.join("\n");
|
|
66
|
+
}
|
|
67
|
+
function colorizeStatus(status) {
|
|
68
|
+
switch (status) {
|
|
69
|
+
case "VALID":
|
|
70
|
+
return pc.green(status);
|
|
71
|
+
case "NEEDS_ATTESTATION":
|
|
72
|
+
case "FINGERPRINT_CHANGED":
|
|
73
|
+
return pc.yellow(status);
|
|
74
|
+
case "EXPIRED":
|
|
75
|
+
case "INVALIDATED_BY_PARENT":
|
|
76
|
+
return pc.red(status);
|
|
77
|
+
case "SIGNATURE_INVALID":
|
|
78
|
+
return pc.red(pc.bold(status));
|
|
79
|
+
default:
|
|
80
|
+
return status;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function outputJson(data) {
|
|
84
|
+
console.log(JSON.stringify(data, null, 2));
|
|
85
|
+
}
|
|
86
|
+
async function confirmAction(options) {
|
|
87
|
+
return confirm({
|
|
88
|
+
message: options.message,
|
|
89
|
+
default: options.default ?? false
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
async function selectOption(options) {
|
|
93
|
+
return select({
|
|
94
|
+
message: options.message,
|
|
95
|
+
choices: options.choices
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
async function getInput(options) {
|
|
99
|
+
const inputConfig = {
|
|
100
|
+
message: options.message
|
|
101
|
+
};
|
|
102
|
+
if (options.default !== void 0) {
|
|
103
|
+
inputConfig.default = options.default;
|
|
104
|
+
}
|
|
105
|
+
if (options.validate !== void 0) {
|
|
106
|
+
inputConfig.validate = options.validate;
|
|
107
|
+
}
|
|
108
|
+
return input(inputConfig);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// src/utils/exit-codes.ts
|
|
112
|
+
var ExitCode = {
|
|
113
|
+
/** Operation completed successfully */
|
|
114
|
+
SUCCESS: 0,
|
|
115
|
+
/** Tests failed or attestation invalid */
|
|
116
|
+
FAILURE: 1,
|
|
117
|
+
/** Configuration or validation error */
|
|
118
|
+
CONFIG_ERROR: 2,
|
|
119
|
+
/** User cancelled the operation */
|
|
120
|
+
CANCELLED: 3,
|
|
121
|
+
/** Missing required key file */
|
|
122
|
+
MISSING_KEY: 4
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// src/commands/init.ts
|
|
126
|
+
var initCommand = new Command("init").description("Initialize attest-it configuration").option("-p, --path <path>", "Config file path", ".attest-it/config.yaml").option("-f, --force", "Overwrite existing config").option("--json", "Output JSON instead of YAML").action(async (options) => {
|
|
127
|
+
await runInit(options);
|
|
128
|
+
});
|
|
129
|
+
async function runInit(options) {
|
|
130
|
+
try {
|
|
131
|
+
const configPath = path.resolve(options.path);
|
|
132
|
+
const configDir = path.dirname(configPath);
|
|
133
|
+
if (fs.existsSync(configPath) && !options.force) {
|
|
134
|
+
const overwrite = await confirmAction({
|
|
135
|
+
message: `Config already exists at ${configPath}. Overwrite?`,
|
|
136
|
+
default: false
|
|
137
|
+
});
|
|
138
|
+
if (!overwrite) {
|
|
139
|
+
error("Init cancelled");
|
|
140
|
+
process.exit(ExitCode.CANCELLED);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
log("");
|
|
144
|
+
info("Welcome to attest-it!");
|
|
145
|
+
log("This will create a configuration file for human-gated test attestations.");
|
|
146
|
+
log("");
|
|
147
|
+
const maxAgeDays = await getInput({
|
|
148
|
+
message: "Maximum attestation age (days):",
|
|
149
|
+
default: "30",
|
|
150
|
+
validate: (v) => {
|
|
151
|
+
const n = parseInt(v, 10);
|
|
152
|
+
return !isNaN(n) && n > 0 ? true : "Must be a positive number";
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
const algorithm = await selectOption({
|
|
156
|
+
message: "Signing algorithm:",
|
|
157
|
+
choices: [
|
|
158
|
+
{
|
|
159
|
+
value: "ed25519",
|
|
160
|
+
name: "Ed25519 (Recommended)",
|
|
161
|
+
description: "Fast, modern, secure"
|
|
162
|
+
},
|
|
163
|
+
{ value: "rsa", name: "RSA", description: "Broader compatibility" }
|
|
164
|
+
]
|
|
165
|
+
});
|
|
166
|
+
const suites = [];
|
|
167
|
+
let addMore = true;
|
|
168
|
+
log("");
|
|
169
|
+
info("Now configure your test suites.");
|
|
170
|
+
log("Suites are groups of tests that require human verification.");
|
|
171
|
+
log("");
|
|
172
|
+
while (addMore) {
|
|
173
|
+
const suiteName = await getInput({
|
|
174
|
+
message: "Suite name:",
|
|
175
|
+
validate: (v) => v.length > 0 ? true : "Required"
|
|
176
|
+
});
|
|
177
|
+
const description = await getInput({
|
|
178
|
+
message: "Description (optional):"
|
|
179
|
+
});
|
|
180
|
+
const packagesInput = await getInput({
|
|
181
|
+
message: "Package paths (comma-separated):",
|
|
182
|
+
default: `packages/${suiteName}`,
|
|
183
|
+
validate: (v) => v.length > 0 ? true : "At least one package required"
|
|
184
|
+
});
|
|
185
|
+
const command = await getInput({
|
|
186
|
+
message: "Test command:",
|
|
187
|
+
default: `pnpm vitest ${packagesInput.split(",")[0]?.trim() ?? ""}`
|
|
188
|
+
});
|
|
189
|
+
suites.push({
|
|
190
|
+
name: suiteName,
|
|
191
|
+
description,
|
|
192
|
+
packages: packagesInput.split(",").map((p) => p.trim()).filter(Boolean),
|
|
193
|
+
command
|
|
194
|
+
});
|
|
195
|
+
addMore = await confirmAction({
|
|
196
|
+
message: "Add another suite?",
|
|
197
|
+
default: false
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
if (suites.length === 0) {
|
|
201
|
+
error("At least one suite is required");
|
|
202
|
+
process.exit(ExitCode.CONFIG_ERROR);
|
|
203
|
+
}
|
|
204
|
+
const config = {
|
|
205
|
+
version: 1,
|
|
206
|
+
settings: {
|
|
207
|
+
maxAgeDays: parseInt(maxAgeDays, 10),
|
|
208
|
+
publicKeyPath: ".attest-it/pubkey.pem",
|
|
209
|
+
attestationsPath: ".attest-it/attestations.json",
|
|
210
|
+
algorithm
|
|
211
|
+
},
|
|
212
|
+
suites: Object.fromEntries(
|
|
213
|
+
suites.map((s) => {
|
|
214
|
+
const suite = {
|
|
215
|
+
packages: s.packages,
|
|
216
|
+
command: s.command
|
|
217
|
+
};
|
|
218
|
+
if (s.description) {
|
|
219
|
+
suite.description = s.description;
|
|
220
|
+
}
|
|
221
|
+
return [s.name, suite];
|
|
222
|
+
})
|
|
223
|
+
)
|
|
224
|
+
};
|
|
225
|
+
await fs.promises.mkdir(configDir, { recursive: true });
|
|
226
|
+
const content = options.json ? JSON.stringify(config, null, 2) : YAML.stringify(config, { indent: 2 });
|
|
227
|
+
await fs.promises.writeFile(configPath, content, "utf-8");
|
|
228
|
+
const attestDir = path.dirname(
|
|
229
|
+
path.resolve(path.dirname(configPath), config.settings.attestationsPath)
|
|
230
|
+
);
|
|
231
|
+
await fs.promises.mkdir(attestDir, { recursive: true });
|
|
232
|
+
success(`Configuration created at ${configPath}`);
|
|
233
|
+
log("");
|
|
234
|
+
log("Next steps:");
|
|
235
|
+
log(" 1. Review and edit the configuration as needed");
|
|
236
|
+
log(" 2. Run: attest-it keygen");
|
|
237
|
+
log(" 3. Run: attest-it run --suite <suite-name>");
|
|
238
|
+
log(" 4. Commit the attestation file");
|
|
239
|
+
} catch (err) {
|
|
240
|
+
if (err instanceof Error) {
|
|
241
|
+
error(err.message);
|
|
242
|
+
} else {
|
|
243
|
+
error("Unknown error occurred");
|
|
244
|
+
}
|
|
245
|
+
process.exit(ExitCode.CONFIG_ERROR);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
var statusCommand = new Command("status").description("Show attestation status for all suites").option("-s, --suite <name>", "Show status for specific suite only").option("--json", "Output JSON for machine parsing").action(async (options) => {
|
|
249
|
+
await runStatus(options);
|
|
250
|
+
});
|
|
251
|
+
async function runStatus(options) {
|
|
252
|
+
try {
|
|
253
|
+
const config = await loadConfig();
|
|
254
|
+
const attestationsPath = config.settings.attestationsPath;
|
|
255
|
+
let attestationsFile = null;
|
|
256
|
+
try {
|
|
257
|
+
attestationsFile = await readAttestations(attestationsPath);
|
|
258
|
+
} catch (err) {
|
|
259
|
+
if (err instanceof Error && !err.message.includes("ENOENT")) {
|
|
260
|
+
throw err;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const attestations = attestationsFile?.attestations ?? [];
|
|
264
|
+
const suiteNames = options.suite ? [options.suite] : Object.keys(config.suites);
|
|
265
|
+
if (options.suite && !config.suites[options.suite]) {
|
|
266
|
+
error(`Suite "${options.suite}" not found in config`);
|
|
267
|
+
process.exit(ExitCode.CONFIG_ERROR);
|
|
268
|
+
}
|
|
269
|
+
const results = [];
|
|
270
|
+
let hasInvalid = false;
|
|
271
|
+
for (const suiteName of suiteNames) {
|
|
272
|
+
const suiteConfig = config.suites[suiteName];
|
|
273
|
+
if (!suiteConfig) continue;
|
|
274
|
+
const fingerprintResult = await computeFingerprint({
|
|
275
|
+
packages: suiteConfig.packages,
|
|
276
|
+
...suiteConfig.ignore && { ignore: suiteConfig.ignore }
|
|
277
|
+
});
|
|
278
|
+
const attestation = findAttestation(
|
|
279
|
+
{
|
|
280
|
+
schemaVersion: "1",
|
|
281
|
+
attestations,
|
|
282
|
+
signature: ""
|
|
283
|
+
},
|
|
284
|
+
suiteName
|
|
285
|
+
);
|
|
286
|
+
const status = determineStatus(
|
|
287
|
+
attestation ?? null,
|
|
288
|
+
fingerprintResult.fingerprint,
|
|
289
|
+
config.settings.maxAgeDays
|
|
290
|
+
);
|
|
291
|
+
let age;
|
|
292
|
+
if (attestation) {
|
|
293
|
+
const attestedAt = new Date(attestation.attestedAt);
|
|
294
|
+
age = Math.floor((Date.now() - attestedAt.getTime()) / (1e3 * 60 * 60 * 24));
|
|
295
|
+
}
|
|
296
|
+
if (status !== "VALID") {
|
|
297
|
+
hasInvalid = true;
|
|
298
|
+
}
|
|
299
|
+
results.push({
|
|
300
|
+
name: suiteName,
|
|
301
|
+
status,
|
|
302
|
+
currentFingerprint: fingerprintResult.fingerprint,
|
|
303
|
+
attestedFingerprint: attestation?.fingerprint,
|
|
304
|
+
attestedAt: attestation?.attestedAt,
|
|
305
|
+
age
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
if (options.json) {
|
|
309
|
+
outputJson(results);
|
|
310
|
+
} else {
|
|
311
|
+
displayStatusTable(results, hasInvalid);
|
|
312
|
+
}
|
|
313
|
+
process.exit(hasInvalid ? ExitCode.FAILURE : ExitCode.SUCCESS);
|
|
314
|
+
} catch (err) {
|
|
315
|
+
if (err instanceof Error) {
|
|
316
|
+
error(err.message);
|
|
317
|
+
} else {
|
|
318
|
+
error("Unknown error occurred");
|
|
319
|
+
}
|
|
320
|
+
process.exit(ExitCode.CONFIG_ERROR);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
function determineStatus(attestation, currentFingerprint, maxAgeDays) {
|
|
324
|
+
if (!attestation) {
|
|
325
|
+
return "NEEDS_ATTESTATION";
|
|
326
|
+
}
|
|
327
|
+
if (attestation.fingerprint !== currentFingerprint) {
|
|
328
|
+
return "FINGERPRINT_CHANGED";
|
|
329
|
+
}
|
|
330
|
+
const attestedAt = new Date(attestation.attestedAt);
|
|
331
|
+
const ageInDays = Math.floor((Date.now() - attestedAt.getTime()) / (1e3 * 60 * 60 * 24));
|
|
332
|
+
if (ageInDays > maxAgeDays) {
|
|
333
|
+
return "EXPIRED";
|
|
334
|
+
}
|
|
335
|
+
return "VALID";
|
|
336
|
+
}
|
|
337
|
+
function displayStatusTable(results, hasInvalid) {
|
|
338
|
+
const tableRows = results.map((r) => ({
|
|
339
|
+
suite: r.name,
|
|
340
|
+
status: colorizeStatus(r.status),
|
|
341
|
+
fingerprint: r.currentFingerprint.slice(0, 16) + "...",
|
|
342
|
+
age: formatAge(r)
|
|
343
|
+
}));
|
|
344
|
+
log("");
|
|
345
|
+
log(formatTable(tableRows));
|
|
346
|
+
log("");
|
|
347
|
+
if (hasInvalid) {
|
|
348
|
+
log("Run `attest-it run --suite <name>` to update attestations");
|
|
349
|
+
} else {
|
|
350
|
+
success("All attestations valid");
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
function formatAge(result) {
|
|
354
|
+
if (result.status === "VALID") {
|
|
355
|
+
return `${String(result.age ?? 0)} days`;
|
|
356
|
+
}
|
|
357
|
+
if (result.status === "FINGERPRINT_CHANGED") {
|
|
358
|
+
return "(changed)";
|
|
359
|
+
}
|
|
360
|
+
if (result.status === "NEEDS_ATTESTATION") {
|
|
361
|
+
return "(none)";
|
|
362
|
+
}
|
|
363
|
+
if (result.status === "EXPIRED") {
|
|
364
|
+
return `${String(result.age ?? 0)} days (expired)`;
|
|
365
|
+
}
|
|
366
|
+
return "-";
|
|
367
|
+
}
|
|
368
|
+
var runCommand = new Command("run").description("Execute tests and create attestation").option("-s, --suite <name>", "Run specific suite (required unless --all)").option("-a, --all", "Run all suites needing attestation").option("--no-attest", "Run tests without creating attestation").option("-y, --yes", "Skip confirmation prompt").action(async (options) => {
|
|
369
|
+
await runTests(options);
|
|
370
|
+
});
|
|
371
|
+
async function runTests(options) {
|
|
372
|
+
try {
|
|
373
|
+
if (!options.suite && !options.all) {
|
|
374
|
+
error("Either --suite or --all is required");
|
|
375
|
+
process.exit(ExitCode.CONFIG_ERROR);
|
|
376
|
+
}
|
|
377
|
+
const config = await loadConfig();
|
|
378
|
+
const suitesToRun = options.all ? Object.keys(config.suites) : options.suite ? [options.suite] : [];
|
|
379
|
+
if (options.suite && !config.suites[options.suite]) {
|
|
380
|
+
error(`Suite "${options.suite}" not found in config`);
|
|
381
|
+
process.exit(ExitCode.CONFIG_ERROR);
|
|
382
|
+
}
|
|
383
|
+
const isDirty = await checkDirtyWorkingTree();
|
|
384
|
+
if (isDirty) {
|
|
385
|
+
error("Working tree has uncommitted changes. Please commit or stash before attesting.");
|
|
386
|
+
process.exit(ExitCode.CONFIG_ERROR);
|
|
387
|
+
}
|
|
388
|
+
for (const suiteName of suitesToRun) {
|
|
389
|
+
const suiteConfig = config.suites[suiteName];
|
|
390
|
+
if (!suiteConfig) continue;
|
|
391
|
+
log(`
|
|
392
|
+
=== Running suite: ${suiteName} ===
|
|
393
|
+
`);
|
|
394
|
+
const fingerprintOptions = {
|
|
395
|
+
packages: suiteConfig.packages,
|
|
396
|
+
...suiteConfig.ignore && { ignore: suiteConfig.ignore }
|
|
397
|
+
};
|
|
398
|
+
const fingerprintResult = await computeFingerprint(fingerprintOptions);
|
|
399
|
+
verbose(`Fingerprint: ${fingerprintResult.fingerprint}`);
|
|
400
|
+
verbose(`Files: ${String(fingerprintResult.fileCount)}`);
|
|
401
|
+
const command = buildCommand(config, suiteConfig.command, suiteConfig.files);
|
|
402
|
+
log(`Running: ${command}`);
|
|
403
|
+
log("");
|
|
404
|
+
const exitCode = await executeCommand(command);
|
|
405
|
+
if (exitCode !== 0) {
|
|
406
|
+
error(`Tests failed with exit code ${String(exitCode)}`);
|
|
407
|
+
process.exit(ExitCode.FAILURE);
|
|
408
|
+
}
|
|
409
|
+
success("Tests passed!");
|
|
410
|
+
if (options.attest === false) {
|
|
411
|
+
log("Skipping attestation (--no-attest)");
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
const shouldAttest = options.yes ?? await confirmAction({
|
|
415
|
+
message: "Create attestation?",
|
|
416
|
+
default: true
|
|
417
|
+
});
|
|
418
|
+
if (!shouldAttest) {
|
|
419
|
+
warn("Attestation cancelled");
|
|
420
|
+
process.exit(ExitCode.CANCELLED);
|
|
421
|
+
}
|
|
422
|
+
const attestation = createAttestation({
|
|
423
|
+
suite: suiteName,
|
|
424
|
+
fingerprint: fingerprintResult.fingerprint,
|
|
425
|
+
command,
|
|
426
|
+
attestedBy: os.userInfo().username
|
|
427
|
+
});
|
|
428
|
+
const attestationsPath = config.settings.attestationsPath;
|
|
429
|
+
const existingFile = await readAttestations(attestationsPath);
|
|
430
|
+
const existingAttestations = existingFile?.attestations ?? [];
|
|
431
|
+
const newAttestations = upsertAttestation(existingAttestations, attestation);
|
|
432
|
+
const privateKeyPath = getDefaultPrivateKeyPath();
|
|
433
|
+
if (!fs.existsSync(privateKeyPath)) {
|
|
434
|
+
error(`Private key not found: ${privateKeyPath}`);
|
|
435
|
+
error('Run "attest-it keygen" first to generate a keypair.');
|
|
436
|
+
process.exit(ExitCode.MISSING_KEY);
|
|
437
|
+
}
|
|
438
|
+
await writeSignedAttestations({
|
|
439
|
+
filePath: attestationsPath,
|
|
440
|
+
attestations: newAttestations,
|
|
441
|
+
privateKeyPath
|
|
442
|
+
});
|
|
443
|
+
success(`Attestation created for ${suiteName}`);
|
|
444
|
+
log(` Fingerprint: ${fingerprintResult.fingerprint}`);
|
|
445
|
+
log(` Attested by: ${attestation.attestedBy}`);
|
|
446
|
+
log(` Attested at: ${attestation.attestedAt}`);
|
|
447
|
+
}
|
|
448
|
+
log("");
|
|
449
|
+
success("All suites completed!");
|
|
450
|
+
log(
|
|
451
|
+
`
|
|
452
|
+
To commit: git add ${config.settings.attestationsPath} && git commit -m "Update attestations"`
|
|
453
|
+
);
|
|
454
|
+
} catch (err) {
|
|
455
|
+
if (err instanceof Error) {
|
|
456
|
+
error(err.message);
|
|
457
|
+
} else {
|
|
458
|
+
error("Unknown error occurred");
|
|
459
|
+
}
|
|
460
|
+
process.exit(ExitCode.CONFIG_ERROR);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
function buildCommand(config, suiteCommand, suiteFiles) {
|
|
464
|
+
let command = suiteCommand ?? config.settings.defaultCommand;
|
|
465
|
+
if (!command) {
|
|
466
|
+
error("No command specified for suite and no defaultCommand in settings");
|
|
467
|
+
process.exit(ExitCode.CONFIG_ERROR);
|
|
468
|
+
}
|
|
469
|
+
if (command.includes("${files}") && suiteFiles) {
|
|
470
|
+
const files = suiteFiles.join(" ");
|
|
471
|
+
command = command.replaceAll("${files}", files);
|
|
472
|
+
}
|
|
473
|
+
return command;
|
|
474
|
+
}
|
|
475
|
+
function parseCommand(command) {
|
|
476
|
+
const parsed = parse(command);
|
|
477
|
+
const stringArgs = parsed.filter((token) => {
|
|
478
|
+
return typeof token === "string";
|
|
479
|
+
});
|
|
480
|
+
if (stringArgs.length === 0) {
|
|
481
|
+
throw new Error("Command string is empty or contains only control operators");
|
|
482
|
+
}
|
|
483
|
+
const [executable, ...args] = stringArgs;
|
|
484
|
+
if (executable === void 0) {
|
|
485
|
+
throw new Error("Command string is empty or contains only control operators");
|
|
486
|
+
}
|
|
487
|
+
return { executable, args };
|
|
488
|
+
}
|
|
489
|
+
async function executeCommand(command) {
|
|
490
|
+
return new Promise((resolve2) => {
|
|
491
|
+
let parsed;
|
|
492
|
+
try {
|
|
493
|
+
parsed = parseCommand(command);
|
|
494
|
+
} catch (err) {
|
|
495
|
+
if (err instanceof Error) {
|
|
496
|
+
error(`Failed to parse command: ${err.message}`);
|
|
497
|
+
} else {
|
|
498
|
+
error("Failed to parse command: Unknown error");
|
|
499
|
+
}
|
|
500
|
+
resolve2(1);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
const child = spawn(parsed.executable, parsed.args, {
|
|
504
|
+
stdio: "inherit"
|
|
505
|
+
// Stream output to terminal
|
|
506
|
+
});
|
|
507
|
+
child.on("close", (code) => {
|
|
508
|
+
resolve2(code ?? 1);
|
|
509
|
+
});
|
|
510
|
+
child.on("error", (err) => {
|
|
511
|
+
error(`Failed to execute command: ${err.message}`);
|
|
512
|
+
resolve2(1);
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
async function checkDirtyWorkingTree() {
|
|
517
|
+
return new Promise((resolve2) => {
|
|
518
|
+
const child = spawn("git", ["status", "--porcelain"], {
|
|
519
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
520
|
+
});
|
|
521
|
+
let output = "";
|
|
522
|
+
child.stdout.on("data", (data) => {
|
|
523
|
+
output += data.toString();
|
|
524
|
+
});
|
|
525
|
+
child.on("close", () => {
|
|
526
|
+
resolve2(output.trim().length > 0);
|
|
527
|
+
});
|
|
528
|
+
child.on("error", () => {
|
|
529
|
+
resolve2(false);
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
var keygenCommand = new Command("keygen").description("Generate a new keypair for signing attestations").option("-a, --algorithm <alg>", "Algorithm: ed25519 (default) or rsa", "ed25519").option("-o, --output <path>", "Private key output path").option("-p, --public <path>", "Public key output path").option("-f, --force", "Overwrite existing keys").action(async (options) => {
|
|
534
|
+
await runKeygen(options);
|
|
535
|
+
});
|
|
536
|
+
async function runKeygen(options) {
|
|
537
|
+
try {
|
|
538
|
+
const algorithm = validateAlgorithm(options.algorithm);
|
|
539
|
+
log("Checking OpenSSL...");
|
|
540
|
+
const version = await checkOpenSSL();
|
|
541
|
+
info(`OpenSSL: ${version}`);
|
|
542
|
+
const privatePath = options.output ?? getDefaultPrivateKeyPath();
|
|
543
|
+
const publicPath = options.public ?? getDefaultPublicKeyPath();
|
|
544
|
+
log(`Private key: ${privatePath}`);
|
|
545
|
+
log(`Public key: ${publicPath}`);
|
|
546
|
+
const privateExists = fs.existsSync(privatePath);
|
|
547
|
+
const publicExists = fs.existsSync(publicPath);
|
|
548
|
+
if ((privateExists || publicExists) && !options.force) {
|
|
549
|
+
if (privateExists) {
|
|
550
|
+
warn(`Private key already exists: ${privatePath}`);
|
|
551
|
+
}
|
|
552
|
+
if (publicExists) {
|
|
553
|
+
warn(`Public key already exists: ${publicPath}`);
|
|
554
|
+
}
|
|
555
|
+
const shouldOverwrite = await confirmAction({
|
|
556
|
+
message: "Overwrite existing keys?",
|
|
557
|
+
default: false
|
|
558
|
+
});
|
|
559
|
+
if (!shouldOverwrite) {
|
|
560
|
+
error("Keygen cancelled");
|
|
561
|
+
process.exit(ExitCode.CANCELLED);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
log(`
|
|
565
|
+
Generating ${algorithm.toUpperCase()} keypair...`);
|
|
566
|
+
const result = await generateKeyPair({
|
|
567
|
+
algorithm,
|
|
568
|
+
privatePath,
|
|
569
|
+
publicPath,
|
|
570
|
+
force: true
|
|
571
|
+
});
|
|
572
|
+
await setKeyPermissions(result.privatePath);
|
|
573
|
+
success("Keypair generated successfully!");
|
|
574
|
+
log("");
|
|
575
|
+
log("Private key (KEEP SECRET):");
|
|
576
|
+
log(` ${result.privatePath}`);
|
|
577
|
+
log("");
|
|
578
|
+
log("Public key (commit to repo):");
|
|
579
|
+
log(` ${result.publicPath}`);
|
|
580
|
+
log("");
|
|
581
|
+
info("Important: Back up your private key securely!");
|
|
582
|
+
log("");
|
|
583
|
+
log("Next steps:");
|
|
584
|
+
log(` 1. git add ${result.publicPath}`);
|
|
585
|
+
log(" 2. Update .attest-it/config.yaml publicKeyPath if needed");
|
|
586
|
+
log(" 3. attest-it run --suite <suite-name>");
|
|
587
|
+
} catch (err) {
|
|
588
|
+
if (err instanceof Error) {
|
|
589
|
+
error(err.message);
|
|
590
|
+
} else {
|
|
591
|
+
error("Unknown error occurred");
|
|
592
|
+
}
|
|
593
|
+
process.exit(ExitCode.CONFIG_ERROR);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
function validateAlgorithm(alg) {
|
|
597
|
+
const normalized = alg.toLowerCase();
|
|
598
|
+
if (normalized === "ed25519" || normalized === "rsa") {
|
|
599
|
+
return normalized;
|
|
600
|
+
}
|
|
601
|
+
error(`Invalid algorithm: ${alg}. Use 'ed25519' or 'rsa'.`);
|
|
602
|
+
process.exit(ExitCode.CONFIG_ERROR);
|
|
603
|
+
}
|
|
604
|
+
var pruneCommand = new Command("prune").description("Remove stale attestations").option("-n, --dry-run", "Show what would be removed without removing").option("-k, --keep-days <n>", "Keep attestations newer than n days", "30").action(async (options) => {
|
|
605
|
+
await runPrune(options);
|
|
606
|
+
});
|
|
607
|
+
async function runPrune(options) {
|
|
608
|
+
try {
|
|
609
|
+
const keepDays = parseInt(options.keepDays, 10);
|
|
610
|
+
if (isNaN(keepDays) || keepDays < 1) {
|
|
611
|
+
error("--keep-days must be a positive integer");
|
|
612
|
+
process.exit(ExitCode.CONFIG_ERROR);
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
const config = await loadConfig();
|
|
616
|
+
const attestationsPath = config.settings.attestationsPath;
|
|
617
|
+
const file = await readAttestations(attestationsPath);
|
|
618
|
+
if (!file || file.attestations.length === 0) {
|
|
619
|
+
info("No attestations to prune");
|
|
620
|
+
process.exit(ExitCode.SUCCESS);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
const now = Date.now();
|
|
624
|
+
const keepMs = keepDays * 24 * 60 * 60 * 1e3;
|
|
625
|
+
const stale = [];
|
|
626
|
+
const keep = [];
|
|
627
|
+
for (const attestation of file.attestations) {
|
|
628
|
+
const attestedAt = new Date(attestation.attestedAt).getTime();
|
|
629
|
+
const ageMs = now - attestedAt;
|
|
630
|
+
const ageDays = Math.floor(ageMs / (1e3 * 60 * 60 * 24));
|
|
631
|
+
const suiteExists = attestation.suite in config.suites;
|
|
632
|
+
let fingerprintMatches = false;
|
|
633
|
+
if (suiteExists) {
|
|
634
|
+
const suiteConfig = config.suites[attestation.suite];
|
|
635
|
+
if (suiteConfig) {
|
|
636
|
+
const fingerprintOptions = {
|
|
637
|
+
packages: suiteConfig.packages,
|
|
638
|
+
...suiteConfig.ignore && { ignore: suiteConfig.ignore }
|
|
639
|
+
};
|
|
640
|
+
const result = await computeFingerprint(fingerprintOptions);
|
|
641
|
+
fingerprintMatches = result.fingerprint === attestation.fingerprint;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
const isStale = !fingerprintMatches && ageMs > keepMs;
|
|
645
|
+
const orphaned = !suiteExists;
|
|
646
|
+
if (isStale || orphaned) {
|
|
647
|
+
stale.push(attestation);
|
|
648
|
+
const reason = orphaned ? "suite removed" : !fingerprintMatches ? "fingerprint changed" : "expired";
|
|
649
|
+
verbose(`Stale: ${attestation.suite} (${reason}, ${String(ageDays)} days old)`);
|
|
650
|
+
} else {
|
|
651
|
+
keep.push(attestation);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
if (stale.length === 0) {
|
|
655
|
+
success("No stale attestations found");
|
|
656
|
+
process.exit(ExitCode.SUCCESS);
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
log(`Found ${String(stale.length)} stale attestation(s):`);
|
|
660
|
+
for (const attestation of stale) {
|
|
661
|
+
const ageDays = Math.floor(
|
|
662
|
+
(now - new Date(attestation.attestedAt).getTime()) / (1e3 * 60 * 60 * 24)
|
|
663
|
+
);
|
|
664
|
+
log(` - ${attestation.suite} (${String(ageDays)} days old)`);
|
|
665
|
+
}
|
|
666
|
+
if (options.dryRun) {
|
|
667
|
+
info("Dry run - no changes made");
|
|
668
|
+
process.exit(ExitCode.SUCCESS);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
const privateKeyPath = getDefaultPrivateKeyPath();
|
|
672
|
+
if (!fs.existsSync(privateKeyPath)) {
|
|
673
|
+
error(`Private key not found: ${privateKeyPath}`);
|
|
674
|
+
error("Cannot re-sign attestations file.");
|
|
675
|
+
process.exit(ExitCode.MISSING_KEY);
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
await writeSignedAttestations({
|
|
679
|
+
filePath: attestationsPath,
|
|
680
|
+
attestations: keep,
|
|
681
|
+
privateKeyPath
|
|
682
|
+
});
|
|
683
|
+
success(`Pruned ${String(stale.length)} stale attestation(s)`);
|
|
684
|
+
log(`Remaining: ${String(keep.length)} attestation(s)`);
|
|
685
|
+
process.exit(ExitCode.SUCCESS);
|
|
686
|
+
} catch (err) {
|
|
687
|
+
if (err instanceof Error) {
|
|
688
|
+
error(err.message);
|
|
689
|
+
} else {
|
|
690
|
+
error("Unknown error occurred");
|
|
691
|
+
}
|
|
692
|
+
process.exit(2);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
var verifyCommand = new Command("verify").description("Verify all attestations (for CI)").option("-s, --suite <name>", "Verify specific suite only").option("--json", "Output JSON for machine parsing").option("--strict", "Fail on warnings (approaching expiry)").action(async (options) => {
|
|
697
|
+
await runVerify(options);
|
|
698
|
+
});
|
|
699
|
+
async function runVerify(options) {
|
|
700
|
+
try {
|
|
701
|
+
const config = await loadConfig();
|
|
702
|
+
if (options.suite) {
|
|
703
|
+
if (!config.suites[options.suite]) {
|
|
704
|
+
error(`Suite "${options.suite}" not found in config`);
|
|
705
|
+
process.exit(ExitCode.CONFIG_ERROR);
|
|
706
|
+
}
|
|
707
|
+
const filteredSuiteEntry = config.suites[options.suite];
|
|
708
|
+
if (!filteredSuiteEntry) {
|
|
709
|
+
error(`Suite "${options.suite}" not found in config`);
|
|
710
|
+
process.exit(ExitCode.CONFIG_ERROR);
|
|
711
|
+
}
|
|
712
|
+
const filteredConfig = {
|
|
713
|
+
version: config.version,
|
|
714
|
+
settings: config.settings,
|
|
715
|
+
suites: { [options.suite]: filteredSuiteEntry }
|
|
716
|
+
};
|
|
717
|
+
const result2 = await verifyAttestations({ config: toAttestItConfig(filteredConfig) });
|
|
718
|
+
if (options.json) {
|
|
719
|
+
outputJson(result2);
|
|
720
|
+
} else {
|
|
721
|
+
displayResults(result2, filteredConfig.settings.maxAgeDays, options.strict);
|
|
722
|
+
}
|
|
723
|
+
if (!result2.success) {
|
|
724
|
+
process.exit(ExitCode.FAILURE);
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
if (options.strict && hasWarnings(result2, filteredConfig.settings.maxAgeDays)) {
|
|
728
|
+
process.exit(ExitCode.FAILURE);
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
process.exit(ExitCode.SUCCESS);
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
const result = await verifyAttestations({ config: toAttestItConfig(config) });
|
|
735
|
+
if (options.json) {
|
|
736
|
+
outputJson(result);
|
|
737
|
+
} else {
|
|
738
|
+
displayResults(result, config.settings.maxAgeDays, options.strict);
|
|
739
|
+
}
|
|
740
|
+
if (!result.success) {
|
|
741
|
+
process.exit(ExitCode.FAILURE);
|
|
742
|
+
}
|
|
743
|
+
if (options.strict && hasWarnings(result, config.settings.maxAgeDays)) {
|
|
744
|
+
process.exit(ExitCode.FAILURE);
|
|
745
|
+
}
|
|
746
|
+
process.exit(ExitCode.SUCCESS);
|
|
747
|
+
} catch (err) {
|
|
748
|
+
if (err instanceof Error) {
|
|
749
|
+
error(err.message);
|
|
750
|
+
} else {
|
|
751
|
+
error("Unknown error occurred");
|
|
752
|
+
}
|
|
753
|
+
process.exit(2);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
function displayResults(result, maxAgeDays, strict) {
|
|
757
|
+
log("");
|
|
758
|
+
if (!result.signatureValid) {
|
|
759
|
+
error("Signature verification FAILED");
|
|
760
|
+
log("The attestations file may have been tampered with.");
|
|
761
|
+
log("");
|
|
762
|
+
}
|
|
763
|
+
for (const errorMsg of result.errors) {
|
|
764
|
+
error(errorMsg);
|
|
765
|
+
}
|
|
766
|
+
if (result.errors.length > 0) {
|
|
767
|
+
log("");
|
|
768
|
+
}
|
|
769
|
+
const tableRows = result.suites.map((s) => ({
|
|
770
|
+
suite: s.suite,
|
|
771
|
+
status: colorizeStatus(s.status),
|
|
772
|
+
fingerprint: s.fingerprint.slice(0, 16) + "...",
|
|
773
|
+
age: formatAgeColumn(s)
|
|
774
|
+
}));
|
|
775
|
+
log(formatTable(tableRows));
|
|
776
|
+
log("");
|
|
777
|
+
if (result.success) {
|
|
778
|
+
success("All attestations valid");
|
|
779
|
+
} else {
|
|
780
|
+
const needsAttestation = result.suites.filter((s) => s.status !== "VALID");
|
|
781
|
+
if (needsAttestation.length > 0) {
|
|
782
|
+
log("Remediation:");
|
|
783
|
+
for (const suite of needsAttestation) {
|
|
784
|
+
log(` attest-it run --suite ${suite.suite}`);
|
|
785
|
+
if (suite.message) {
|
|
786
|
+
log(` ${suite.message}`);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
const warningThreshold = 7;
|
|
792
|
+
const nearExpiry = result.suites.filter(
|
|
793
|
+
(s) => s.status === "VALID" && (s.age ?? 0) > maxAgeDays - warningThreshold
|
|
794
|
+
);
|
|
795
|
+
if (nearExpiry.length > 0) {
|
|
796
|
+
log("");
|
|
797
|
+
for (const suite of nearExpiry) {
|
|
798
|
+
warn(`${suite.suite} attestation approaching expiry (${String(suite.age)} days old)`);
|
|
799
|
+
}
|
|
800
|
+
if (strict) {
|
|
801
|
+
log("(--strict mode: warnings are treated as errors)");
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
function formatAgeColumn(s) {
|
|
806
|
+
if (s.status === "VALID") {
|
|
807
|
+
return `${String(s.age ?? 0)} days`;
|
|
808
|
+
}
|
|
809
|
+
if (s.status === "NEEDS_ATTESTATION") {
|
|
810
|
+
return "(none)";
|
|
811
|
+
}
|
|
812
|
+
if (s.status === "EXPIRED") {
|
|
813
|
+
return `${String(s.age ?? 0)} days (expired)`;
|
|
814
|
+
}
|
|
815
|
+
if (s.status === "FINGERPRINT_CHANGED") {
|
|
816
|
+
return "(changed)";
|
|
817
|
+
}
|
|
818
|
+
if (s.status === "INVALIDATED_BY_PARENT") {
|
|
819
|
+
return "(invalidated)";
|
|
820
|
+
}
|
|
821
|
+
return "-";
|
|
822
|
+
}
|
|
823
|
+
function hasWarnings(result, maxAgeDays) {
|
|
824
|
+
const warningThreshold = 7;
|
|
825
|
+
return result.suites.some(
|
|
826
|
+
(s) => s.status === "VALID" && (s.age ?? 0) > maxAgeDays - warningThreshold
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// src/index.ts
|
|
831
|
+
var program = new Command();
|
|
832
|
+
program.name("attest-it").description("Human-gated test attestation system").version("0.0.1").option("-c, --config <path>", "Path to config file").option("-v, --verbose", "Verbose output").option("-q, --quiet", "Minimal output");
|
|
833
|
+
program.addCommand(initCommand);
|
|
834
|
+
program.addCommand(statusCommand);
|
|
835
|
+
program.addCommand(runCommand);
|
|
836
|
+
program.addCommand(keygenCommand);
|
|
837
|
+
program.addCommand(pruneCommand);
|
|
838
|
+
program.addCommand(verifyCommand);
|
|
839
|
+
function run() {
|
|
840
|
+
program.parse();
|
|
841
|
+
const options = program.opts();
|
|
842
|
+
const outputOptions = {};
|
|
843
|
+
if (options.verbose !== void 0) {
|
|
844
|
+
outputOptions.verbose = options.verbose;
|
|
845
|
+
}
|
|
846
|
+
if (options.quiet !== void 0) {
|
|
847
|
+
outputOptions.quiet = options.quiet;
|
|
848
|
+
}
|
|
849
|
+
setOutputOptions(outputOptions);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
export { program, run };
|
|
853
|
+
//# sourceMappingURL=index.js.map
|
|
854
|
+
//# sourceMappingURL=index.js.map
|