@pipobscure/bundle 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/HISTORY.md +1924 -0
- package/README.md +623 -0
- package/bundle.run +0 -0
- package/dist/api.d.ts +147 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +174 -0
- package/dist/api.js.map +1 -0
- package/dist/archive.d.ts +115 -0
- package/dist/archive.d.ts.map +1 -0
- package/dist/archive.js +188 -0
- package/dist/archive.js.map +1 -0
- package/dist/audit.d.ts +78 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +119 -0
- package/dist/audit.js.map +1 -0
- package/dist/cli.d.ts +23 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +555 -0
- package/dist/cli.js.map +1 -0
- package/dist/files.d.ts +53 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +118 -0
- package/dist/files.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -0
- package/dist/launch.d.ts +97 -0
- package/dist/launch.d.ts.map +1 -0
- package/dist/launch.js +267 -0
- package/dist/launch.js.map +1 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +19 -0
- package/dist/main.js.map +1 -0
- package/dist/manifest.d.ts +139 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +504 -0
- package/dist/manifest.js.map +1 -0
- package/dist/oidc.d.ts +40 -0
- package/dist/oidc.d.ts.map +1 -0
- package/dist/oidc.js +320 -0
- package/dist/oidc.js.map +1 -0
- package/dist/preload.d.ts +14 -0
- package/dist/preload.d.ts.map +1 -0
- package/dist/preload.js +38 -0
- package/dist/preload.js.map +1 -0
- package/dist/provider.d.ts +83 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +206 -0
- package/dist/provider.js.map +1 -0
- package/dist/record.d.ts +2 -0
- package/dist/record.d.ts.map +1 -0
- package/dist/record.js +23 -0
- package/dist/record.js.map +1 -0
- package/dist/recorder.d.ts +64 -0
- package/dist/recorder.d.ts.map +1 -0
- package/dist/recorder.js +111 -0
- package/dist/recorder.js.map +1 -0
- package/dist/register.d.ts +2 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/register.js +28 -0
- package/dist/register.js.map +1 -0
- package/dist/sea.d.ts +97 -0
- package/dist/sea.d.ts.map +1 -0
- package/dist/sea.js +220 -0
- package/dist/sea.js.map +1 -0
- package/dist/sigstore.d.ts +112 -0
- package/dist/sigstore.d.ts.map +1 -0
- package/dist/sigstore.js +385 -0
- package/dist/sigstore.js.map +1 -0
- package/dist/skill.d.ts +36 -0
- package/dist/skill.d.ts.map +1 -0
- package/dist/skill.js +108 -0
- package/dist/skill.js.map +1 -0
- package/package.json +84 -0
- package/shell-base +2 -0
- package/skills/audit-bundle/SKILL.md +271 -0
- package/src/api.ts +293 -0
- package/src/archive.ts +312 -0
- package/src/audit.ts +206 -0
- package/src/cli.ts +575 -0
- package/src/files.ts +156 -0
- package/src/index.ts +114 -0
- package/src/launch.ts +336 -0
- package/src/main.ts +20 -0
- package/src/manifest.ts +615 -0
- package/src/oidc.ts +372 -0
- package/src/preload.ts +40 -0
- package/src/provider.ts +270 -0
- package/src/record.ts +25 -0
- package/src/recorder.ts +166 -0
- package/src/register.ts +30 -0
- package/src/sea.ts +341 -0
- package/src/sigstore.ts +492 -0
- package/src/skill.ts +132 -0
- package/src/types/node-vfs.d.ts +90 -0
- package/src/types/node-zip.d.ts +85 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
import * as FS from 'node:fs';
|
|
2
|
+
import * as PATH from 'node:path';
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import { createBundle, signBundle, verifyBundle, runBundle, fileSigner } from './api.ts';
|
|
5
|
+
import { members } from './archive.ts';
|
|
6
|
+
import { launcherPath } from './files.ts';
|
|
7
|
+
import * as AUDIT from './audit.ts';
|
|
8
|
+
import { message, STATES, type VerificationResult, type VerificationState } from './manifest.ts';
|
|
9
|
+
|
|
10
|
+
// Re-exported because this is where a CLI consumer looks for it; it is defined
|
|
11
|
+
// in the format layer so the `bundle` launcher can report an exit code without
|
|
12
|
+
// loading the whole CLI.
|
|
13
|
+
export { STATES };
|
|
14
|
+
import * as SKILLS from './skill.ts';
|
|
15
|
+
|
|
16
|
+
// Argument parsing and reporting, and nothing else. Every command below is a
|
|
17
|
+
// `parseArgs` call, a message or two, and one call into `api.ts` — which is
|
|
18
|
+
// deliberate: what the CLI can do is exactly what an embedder can do, because
|
|
19
|
+
// they are the same functions.
|
|
20
|
+
|
|
21
|
+
export const USAGE = `usage: bundle <command> [options]
|
|
22
|
+
|
|
23
|
+
commands:
|
|
24
|
+
create build an archive from a list of files
|
|
25
|
+
sign sign an archive into a new file, optionally behind a prefix
|
|
26
|
+
audit report what is about to be reviewed, and gate signing on the verdict
|
|
27
|
+
verify verify an archive and report its trust state
|
|
28
|
+
run mount a signed archive and run it
|
|
29
|
+
sea build a node runtime that verifies an archive before running it
|
|
30
|
+
trust refresh the sigstore trust root used to check sigstore signatures
|
|
31
|
+
skill install this package's bundle-auditing skill into a project
|
|
32
|
+
|
|
33
|
+
create options:
|
|
34
|
+
-b, --base <dir> base directory the file list is relative to (default: .)
|
|
35
|
+
-p, --prefix <file> prefix prepended before the archive (launcher or binary);
|
|
36
|
+
omit it for a plain archive meant to be run from a mount
|
|
37
|
+
-f, --files <file> read the newline-separated file list from here (default: stdin)
|
|
38
|
+
-o, --output <file> write the archive here (default: stdout)
|
|
39
|
+
-k, --key <file> leaf private key (PEM); signs at build time with --chain
|
|
40
|
+
-c, --chain <file> full certificate chain (PEM, leaf first)
|
|
41
|
+
--hash <alg> digest for the whole-file hash and member digests (default: sha256)
|
|
42
|
+
--sign <alg> digest the signature over that hash uses (default: sha256)
|
|
43
|
+
|
|
44
|
+
sign options: usage: sign [options] <archive>
|
|
45
|
+
-o, --output <file> write the signed archive here (default: stdout)
|
|
46
|
+
-l, --launcher prepend this package's shell launcher, so the result runs
|
|
47
|
+
by name — the usual way to make a self-executing archive
|
|
48
|
+
-p, --prefix <file> prepend some other prefix: a launcher of your own, or a
|
|
49
|
+
node binary; omit both for a plain mountable archive
|
|
50
|
+
-x, --executable make the output executable (implied by --launcher/--prefix)
|
|
51
|
+
--hash <alg> digest for the whole-file hash and member digests (default: sha256)
|
|
52
|
+
--sign <alg> digest the signature over that hash uses (default: sha256)
|
|
53
|
+
|
|
54
|
+
by default this signs through sigstore, taking the identity from CI when
|
|
55
|
+
there is one and otherwise opening a GitHub sign-in:
|
|
56
|
+
--flow <how> auto | ci | browser | device (default: auto)
|
|
57
|
+
--token <jwt> use this OIDC token instead of signing in
|
|
58
|
+
--oidc-issuer <url> OIDC issuer (default: sigstore's dex)
|
|
59
|
+
--connector <name> identity provider to jump to (default: github)
|
|
60
|
+
--fulcio <url> certificate authority (default: fulcio.sigstore.dev)
|
|
61
|
+
--rekor <url> transparency log; empty string to skip it
|
|
62
|
+
--tsa <url> timestamp authority; empty string to skip it
|
|
63
|
+
|
|
64
|
+
or, to sign against a certificate authority of your own:
|
|
65
|
+
-k, --key <file> leaf private key (PEM)
|
|
66
|
+
-c, --chain <file> full certificate chain (PEM, leaf first)
|
|
67
|
+
|
|
68
|
+
audit options: usage: audit [options] <archive>
|
|
69
|
+
-b, --baseline <file> a previously approved archive to review against, so the
|
|
70
|
+
review is of what changed rather than of everything
|
|
71
|
+
-v, --verdict <file> where the verdict is (default: <archive>.audit.json)
|
|
72
|
+
--check exit non-zero unless a clean verdict pins these bytes
|
|
73
|
+
--approve record a clean verdict you reached by reading it yourself
|
|
74
|
+
-n, --note <text> what you checked, recorded with --approve
|
|
75
|
+
|
|
76
|
+
with none of those it reports what is about to be reviewed and how. The review
|
|
77
|
+
itself needs judgement, so no command performs it: install the skill with
|
|
78
|
+
'bundle skill' and run /audit-bundle, or read the archive yourself and
|
|
79
|
+
--approve. Signing is not gated unless you run --check before it.
|
|
80
|
+
|
|
81
|
+
verify options: usage: verify [options] <archive>
|
|
82
|
+
-a, --archive <file> archive to verify (or pass it as a positional argument)
|
|
83
|
+
-r, --root <file> extra trusted root certificate (PEM); repeatable
|
|
84
|
+
--identity <san> require this sigstore signing identity
|
|
85
|
+
--issuer <url> require this sigstore OIDC issuer
|
|
86
|
+
--sigstore-root <file> sigstore trust root (default: the cache 'trust' fills)
|
|
87
|
+
--json print the result as JSON
|
|
88
|
+
|
|
89
|
+
run options: usage: run [options] <archive> [-- <app args>]
|
|
90
|
+
-r, --root <file> extra trusted root certificate (PEM); repeatable
|
|
91
|
+
--identity <san> require this sigstore signing identity
|
|
92
|
+
--issuer <url> require this sigstore OIDC issuer
|
|
93
|
+
--untrusted run an archive whose signature is good but untrusted
|
|
94
|
+
|
|
95
|
+
sea options: usage: sea [options] [archive]
|
|
96
|
+
-o, --output <file> write the executable here (required)
|
|
97
|
+
--node <file> node binary to embed (default: the running one)
|
|
98
|
+
--base <file> reuse a SEA base built earlier instead of building one
|
|
99
|
+
--no-sigstore leave the sigstore libraries out of the embedded verifier
|
|
100
|
+
--untrusted let the finished executable run an archive whose
|
|
101
|
+
signature is good but unanchored
|
|
102
|
+
-r, --root <file> trusted root the executable checks against; repeatable
|
|
103
|
+
--identity <san> identity the executable requires of a signature
|
|
104
|
+
--issuer <url> issuer the executable requires of a signature
|
|
105
|
+
|
|
106
|
+
with an archive, the result is that application: one file that verifies
|
|
107
|
+
itself and runs what is inside it. without one, the result is a verifying
|
|
108
|
+
node — a runtime that takes an archive on its own command line:
|
|
109
|
+
|
|
110
|
+
bundle sea -o node-verifying
|
|
111
|
+
./node-verifying ./my-app.zip --args --for --the --app
|
|
112
|
+
|
|
113
|
+
a runtime built with a policy (-r, --identity, --issuer) is sealed: it
|
|
114
|
+
accepts no policy from its command line, because a binary that demands a
|
|
115
|
+
signing identity is not one whose user can ask it to stop.
|
|
116
|
+
|
|
117
|
+
the signing options are the same as 'sign': sigstore by default, or --key
|
|
118
|
+
with --chain against a certificate authority of your own
|
|
119
|
+
|
|
120
|
+
trust options:
|
|
121
|
+
--mirror <url> TUF repository to refresh from (default: sigstore's)
|
|
122
|
+
|
|
123
|
+
skill options: usage: skill [options] [name]
|
|
124
|
+
-d, --dir <dir> where to install (default: .claude/skills)
|
|
125
|
+
-f, --force overwrite files that are already there
|
|
126
|
+
-l, --list list the skills this package carries and stop
|
|
127
|
+
|
|
128
|
+
-h, --help show this help`;
|
|
129
|
+
|
|
130
|
+
/** Where a command's output goes. Swappable so tests need no subprocess. */
|
|
131
|
+
export interface Console {
|
|
132
|
+
out(line: string): void;
|
|
133
|
+
err(line: string): void;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const CONSOLE: Console = {
|
|
137
|
+
out: (line) => { process.stdout.write(`${line}\n`); },
|
|
138
|
+
err: (line) => { process.stderr.write(`${line}\n`); },
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The commands, in the order the usage text lists them. Keeping the dispatch
|
|
143
|
+
* table and the help in one place is what stops the two drifting apart.
|
|
144
|
+
*/
|
|
145
|
+
export const COMMANDS: Record<string, (args: string[], io: Console) => number | Promise<number>> = {
|
|
146
|
+
create, sign, audit, verify: check, run, sea, trust, skill,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Run one CLI invocation. `argv` is the user's arguments — everything after the
|
|
151
|
+
* runtime and the entry point — and the return value is the process exit code,
|
|
152
|
+
* so a caller decides what to do with it rather than being exited out from
|
|
153
|
+
* under.
|
|
154
|
+
*/
|
|
155
|
+
export async function main(argv: string[], io: Console = CONSOLE): Promise<number> {
|
|
156
|
+
const [cmd, ...rest] = argv;
|
|
157
|
+
try {
|
|
158
|
+
if (cmd === undefined) {
|
|
159
|
+
io.out(USAGE);
|
|
160
|
+
return 64;
|
|
161
|
+
}
|
|
162
|
+
if (cmd === '-h' || cmd === '--help' || cmd === 'help') {
|
|
163
|
+
io.out(USAGE);
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
166
|
+
const command = Object.hasOwn(COMMANDS, cmd) ? COMMANDS[cmd] : undefined;
|
|
167
|
+
if (!command) throw new Error(`unknown command: ${cmd}`);
|
|
168
|
+
return await command(rest, io);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
io.err(`error: ${message(err)}`);
|
|
171
|
+
return 70;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function create(args: string[], io: Console): Promise<number> {
|
|
176
|
+
const { values } = parseArgs({
|
|
177
|
+
args,
|
|
178
|
+
options: {
|
|
179
|
+
base: { type: 'string', short: 'b', default: '.' },
|
|
180
|
+
prefix: { type: 'string', short: 'p' },
|
|
181
|
+
files: { type: 'string', short: 'f' },
|
|
182
|
+
output: { type: 'string', short: 'o' },
|
|
183
|
+
key: { type: 'string', short: 'k' },
|
|
184
|
+
chain: { type: 'string', short: 'c' },
|
|
185
|
+
hash: { type: 'string', default: 'sha256' },
|
|
186
|
+
sign: { type: 'string', default: 'sha256' },
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
if (Boolean(values.key) !== Boolean(values.chain)) throw new Error('create: --key and --chain must be given together');
|
|
190
|
+
|
|
191
|
+
const listing = values.files ? FS.readFileSync(values.files, 'utf-8') : await readStdin();
|
|
192
|
+
const files = [...new Set(listing.split(/\r?\n/).filter(Boolean))].sort();
|
|
193
|
+
if (!files.length) throw new Error('create: the file list is empty');
|
|
194
|
+
|
|
195
|
+
for (const file of files) io.err(`+ ${file}`);
|
|
196
|
+
io.err(values.key
|
|
197
|
+
? `* signed archive (${files.length} members, ${values.hash} digests, ${values.sign} signature)`
|
|
198
|
+
: `* unsigned archive (${files.length} members, ${values.hash} digests)`);
|
|
199
|
+
|
|
200
|
+
await createBundle({
|
|
201
|
+
base: values.base, files, prefix: values.prefix, output: values.output,
|
|
202
|
+
hashAlg: values.hash, signAlg: values.sign,
|
|
203
|
+
key: values.key ? FS.readFileSync(values.key) : undefined,
|
|
204
|
+
chain: values.chain ? FS.readFileSync(values.chain, 'utf-8') : undefined,
|
|
205
|
+
});
|
|
206
|
+
return 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function sign(args: string[], io: Console): Promise<number> {
|
|
210
|
+
const { values, positionals } = parseArgs({
|
|
211
|
+
args,
|
|
212
|
+
allowPositionals: true,
|
|
213
|
+
options: {
|
|
214
|
+
output: { type: 'string', short: 'o' },
|
|
215
|
+
launcher: { type: 'boolean', short: 'l' },
|
|
216
|
+
prefix: { type: 'string', short: 'p' },
|
|
217
|
+
executable: { type: 'boolean', short: 'x' },
|
|
218
|
+
key: { type: 'string', short: 'k' },
|
|
219
|
+
chain: { type: 'string', short: 'c' },
|
|
220
|
+
hash: { type: 'string', default: 'sha256' },
|
|
221
|
+
sign: { type: 'string', default: 'sha256' },
|
|
222
|
+
flow: { type: 'string', default: 'auto' },
|
|
223
|
+
token: { type: 'string' },
|
|
224
|
+
'oidc-issuer': { type: 'string' },
|
|
225
|
+
connector: { type: 'string' },
|
|
226
|
+
fulcio: { type: 'string' },
|
|
227
|
+
rekor: { type: 'string' },
|
|
228
|
+
tsa: { type: 'string' },
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
const source = positionals[0];
|
|
232
|
+
if (!source) throw new Error('sign: an archive path is required');
|
|
233
|
+
if (Boolean(values.key) !== Boolean(values.chain)) throw new Error('sign: --key and --chain must be given together');
|
|
234
|
+
if (values.launcher && values.prefix) throw new Error('sign: --launcher and --prefix are alternatives');
|
|
235
|
+
|
|
236
|
+
// `--launcher` is `--prefix <this package's shell-base>`, spelled so that
|
|
237
|
+
// nobody has to know the prefix ships inside node_modules.
|
|
238
|
+
const prefix = values.launcher ? launcherPath() : values.prefix;
|
|
239
|
+
|
|
240
|
+
for (const name of members(source)) io.err(`+ ${name}`);
|
|
241
|
+
if (prefix) io.err(`* prefix ${prefix} (${FS.statSync(prefix).size} bytes)`);
|
|
242
|
+
|
|
243
|
+
const signer = await chooseSigner(values, io);
|
|
244
|
+
|
|
245
|
+
const res = await signBundle({
|
|
246
|
+
source, output: values.output, prefix, executable: values.executable,
|
|
247
|
+
hashAlg: values.hash, signAlg: values.sign, signer,
|
|
248
|
+
});
|
|
249
|
+
io.err(`* signed: ${res.hash}`);
|
|
250
|
+
if (res.output) io.err(`* wrote ${res.output} (${res.size} bytes)`);
|
|
251
|
+
return 0;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function check(args: string[], io: Console): Promise<number> {
|
|
255
|
+
const { values, positionals } = parseArgs({
|
|
256
|
+
args,
|
|
257
|
+
allowPositionals: true,
|
|
258
|
+
options: {
|
|
259
|
+
archive: { type: 'string', short: 'a' },
|
|
260
|
+
root: { type: 'string', short: 'r', multiple: true },
|
|
261
|
+
identity: { type: 'string' },
|
|
262
|
+
issuer: { type: 'string' },
|
|
263
|
+
'sigstore-root': { type: 'string' },
|
|
264
|
+
json: { type: 'boolean' },
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
const archive = values.archive ?? positionals[0];
|
|
268
|
+
if (!archive) throw new Error('verify: an archive path is required');
|
|
269
|
+
|
|
270
|
+
const res = await verifyBundle(archive, {
|
|
271
|
+
roots: values.root ?? [],
|
|
272
|
+
identity: values.identity,
|
|
273
|
+
issuer: values.issuer,
|
|
274
|
+
trustedRoot: values['sigstore-root'],
|
|
275
|
+
});
|
|
276
|
+
report(res, Boolean(values.json), io);
|
|
277
|
+
return STATES[res.state].code;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Print a verification result, as text or as the JSON `--json` produces. */
|
|
281
|
+
export function report(res: VerificationResult, json: boolean, io: Console): void {
|
|
282
|
+
const state = STATES[res.state];
|
|
283
|
+
if (json) {
|
|
284
|
+
io.out(JSON.stringify({
|
|
285
|
+
state: res.state, reason: res.reason, subject: res.subject,
|
|
286
|
+
signed: res.signed, trusted: res.trusted, sigstore: Boolean(res.sigstore),
|
|
287
|
+
identity: res.identity, issuer: res.issuer,
|
|
288
|
+
signedAt: res.signedAt ? res.signedAt.toISOString() : undefined,
|
|
289
|
+
members: res.digests ? [...res.digests.keys()] : undefined,
|
|
290
|
+
code: state.code,
|
|
291
|
+
}, null, 2));
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
io.out(`${state.label} — ${res.reason ?? state.note}`);
|
|
295
|
+
// For a sigstore signature the identity is the answer to "who signed this";
|
|
296
|
+
// the certificate subject is an ephemeral Fulcio artifact and says nothing
|
|
297
|
+
// useful.
|
|
298
|
+
if (res.identity) io.out(` identity: ${res.identity}`);
|
|
299
|
+
if (res.issuer) io.out(` issuer: ${res.issuer}`);
|
|
300
|
+
if (res.signedAt) io.out(` signed: ${res.signedAt.toISOString()}`);
|
|
301
|
+
if (res.subject && !res.identity) io.out(` certificate: ${res.subject.replace(/\n/g, ', ')}`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Run an archive the way a mount does. Everything after `--` is the
|
|
305
|
+
// application's own argv.
|
|
306
|
+
function run(args: string[], io: Console): number {
|
|
307
|
+
const split = args.indexOf('--');
|
|
308
|
+
const mine = split < 0 ? args : args.slice(0, split);
|
|
309
|
+
const theirs = split < 0 ? [] : args.slice(split + 1);
|
|
310
|
+
const { values, positionals } = parseArgs({
|
|
311
|
+
args: mine,
|
|
312
|
+
allowPositionals: true,
|
|
313
|
+
options: {
|
|
314
|
+
root: { type: 'string', short: 'r', multiple: true },
|
|
315
|
+
identity: { type: 'string' },
|
|
316
|
+
issuer: { type: 'string' },
|
|
317
|
+
untrusted: { type: 'boolean' },
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
const archive = positionals[0];
|
|
321
|
+
if (!archive) throw new Error('run: an archive path is required');
|
|
322
|
+
|
|
323
|
+
let res;
|
|
324
|
+
try {
|
|
325
|
+
res = runBundle(archive, {
|
|
326
|
+
roots: values.root ?? [], identity: values.identity, issuer: values.issuer,
|
|
327
|
+
allowUntrusted: values.untrusted, args: theirs,
|
|
328
|
+
});
|
|
329
|
+
} catch (err) {
|
|
330
|
+
if ((err as { code?: string }).code !== 'ERR_BUNDLE_UNTRUSTED') throw err;
|
|
331
|
+
const state = (err as { state?: VerificationState }).state;
|
|
332
|
+
io.err(`error: ${message(err)}`);
|
|
333
|
+
return state ? STATES[state].code : 2;
|
|
334
|
+
}
|
|
335
|
+
if (res.signal) process.kill(process.pid, res.signal);
|
|
336
|
+
return res.status ?? 70;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// The audit gate. The review itself needs judgement, so this command does the
|
|
340
|
+
// two mechanical halves around it: say what is about to be reviewed, and refuse
|
|
341
|
+
// to let signing proceed without a clean verdict over exactly these bytes.
|
|
342
|
+
function audit(args: string[], io: Console): number {
|
|
343
|
+
const { values, positionals } = parseArgs({
|
|
344
|
+
args,
|
|
345
|
+
allowPositionals: true,
|
|
346
|
+
options: {
|
|
347
|
+
baseline: { type: 'string', short: 'b' },
|
|
348
|
+
verdict: { type: 'string', short: 'v' },
|
|
349
|
+
note: { type: 'string', short: 'n' },
|
|
350
|
+
check: { type: 'boolean' },
|
|
351
|
+
approve: { type: 'boolean' },
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
const bundle = positionals[0];
|
|
355
|
+
if (!bundle) throw new Error('audit: an archive path is required');
|
|
356
|
+
if (values.check && values.approve) throw new Error('audit: --check and --approve are alternatives');
|
|
357
|
+
const options = { bundle, verdict: values.verdict, baseline: values.baseline };
|
|
358
|
+
|
|
359
|
+
if (values.check) {
|
|
360
|
+
const verdict = AUDIT.check(options);
|
|
361
|
+
const notes = (verdict.findings ?? []).length;
|
|
362
|
+
io.err(`* audited: ${verdict.summary ?? 'pass'}`);
|
|
363
|
+
io.err(` ${verdict.reviewed ?? '?'} of ${verdict.members ?? '?'} members reviewed, ` +
|
|
364
|
+
`${notes} finding${notes === 1 ? '' : 's'}`);
|
|
365
|
+
if (verdict.baselineSha256) io.err(` as a diff against ${verdict.baselineSha256.slice(0, 16)}…`);
|
|
366
|
+
return 0;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (values.approve) {
|
|
370
|
+
const verdict = AUDIT.approve({ ...options, note: values.note });
|
|
371
|
+
io.err(`* recorded a pass over ${verdict.sha256!.slice(0, 16)}… in ${AUDIT.verdictPath(bundle, values.verdict)}`);
|
|
372
|
+
return 0;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const found = AUDIT.prepare(options);
|
|
376
|
+
io.out(`${found.bundle}: ${STATES[found.state as VerificationState]?.label ?? found.state}, ${found.members.length} members`);
|
|
377
|
+
io.out(` sha256: ${found.sha256}`);
|
|
378
|
+
if (found.state === 'unsigned') {
|
|
379
|
+
io.out(' unsigned, as an archive that has not been signed yet should be');
|
|
380
|
+
}
|
|
381
|
+
if (found.baseline) {
|
|
382
|
+
io.out(` against ${found.baseline.path} (${found.baseline.sha256.slice(0, 16)}…)`);
|
|
383
|
+
io.out(` ${found.baseline.added.length} added, ${found.baseline.removed.length} removed, ` +
|
|
384
|
+
`${found.baseline.carried} carried over`);
|
|
385
|
+
for (const name of found.baseline.added.slice(0, 10)) io.out(` + ${name}`);
|
|
386
|
+
for (const name of found.baseline.removed.slice(0, 10)) io.out(` - ${name}`);
|
|
387
|
+
} else {
|
|
388
|
+
io.out(' no baseline — the review is of everything, not a diff');
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
io.err('');
|
|
392
|
+
io.err('* review it, then record the verdict:');
|
|
393
|
+
io.err(` BUNDLE_AUDIT_VERDICT=${found.verdict} \\`);
|
|
394
|
+
io.err(found.baseline
|
|
395
|
+
? ` claude "/audit-bundle ${bundle} against ${found.baseline.path}"`
|
|
396
|
+
: ` claude "/audit-bundle ${bundle}"`);
|
|
397
|
+
io.err(' or, having read it yourself:');
|
|
398
|
+
io.err(` bundle audit --approve --note '<what you checked>' ${bundle}`);
|
|
399
|
+
io.err("* then gate signing on it: bundle audit --check ... && bundle sign ...");
|
|
400
|
+
return 0;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Wrap an archive in a node runtime that verifies itself before running it.
|
|
404
|
+
// The signing half is the same as `sign` — the finished executable is one
|
|
405
|
+
// signed file whose hash covers the runtime, the verifier and the application
|
|
406
|
+
// alike, which is what lets it check itself with something inside itself.
|
|
407
|
+
async function sea(args: string[], io: Console): Promise<number> {
|
|
408
|
+
const { values, positionals } = parseArgs({
|
|
409
|
+
args,
|
|
410
|
+
allowPositionals: true,
|
|
411
|
+
options: {
|
|
412
|
+
output: { type: 'string', short: 'o' },
|
|
413
|
+
node: { type: 'string' },
|
|
414
|
+
base: { type: 'string' },
|
|
415
|
+
sigstore: { type: 'boolean', default: true },
|
|
416
|
+
untrusted: { type: 'boolean' },
|
|
417
|
+
root: { type: 'string', short: 'r', multiple: true },
|
|
418
|
+
identity: { type: 'string' },
|
|
419
|
+
issuer: { type: 'string' },
|
|
420
|
+
key: { type: 'string', short: 'k' },
|
|
421
|
+
chain: { type: 'string', short: 'c' },
|
|
422
|
+
hash: { type: 'string', default: 'sha256' },
|
|
423
|
+
sign: { type: 'string', default: 'sha256' },
|
|
424
|
+
flow: { type: 'string', default: 'auto' },
|
|
425
|
+
token: { type: 'string' },
|
|
426
|
+
'oidc-issuer': { type: 'string' },
|
|
427
|
+
connector: { type: 'string' },
|
|
428
|
+
fulcio: { type: 'string' },
|
|
429
|
+
rekor: { type: 'string' },
|
|
430
|
+
tsa: { type: 'string' },
|
|
431
|
+
},
|
|
432
|
+
});
|
|
433
|
+
const app = positionals[0];
|
|
434
|
+
if (!values.output) throw new Error('sea: --output is required');
|
|
435
|
+
if (Boolean(values.key) !== Boolean(values.chain)) throw new Error('sea: --key and --chain must be given together');
|
|
436
|
+
|
|
437
|
+
const SEA = await import('./sea.ts');
|
|
438
|
+
// A policy baked into a runtime is the last word: it would be no policy at
|
|
439
|
+
// all if the command line could drop it. Nothing baked, nothing to seal —
|
|
440
|
+
// that runtime takes its policy from flags and the environment, the way
|
|
441
|
+
// `bundle run` does.
|
|
442
|
+
const bootstrap = {
|
|
443
|
+
roots: values.root,
|
|
444
|
+
identity: values.identity,
|
|
445
|
+
issuer: values.issuer,
|
|
446
|
+
allowUntrusted: values.untrusted,
|
|
447
|
+
sealed: Boolean(values.root?.length || values.identity || values.issuer),
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
if (!app) {
|
|
451
|
+
if (values.key || values.chain) {
|
|
452
|
+
throw new Error('sea: signing options need an archive to sign — a verifying node carries none');
|
|
453
|
+
}
|
|
454
|
+
if (values.base) throw new Error('sea: --base reuses a runtime; without an archive there is nothing to add to it');
|
|
455
|
+
io.err('* building a verifying node (node runtime + verifier, no application)');
|
|
456
|
+
const built = await SEA.createSeaBase({
|
|
457
|
+
output: values.output,
|
|
458
|
+
node: values.node,
|
|
459
|
+
sigstore: values.sigstore,
|
|
460
|
+
bootstrap,
|
|
461
|
+
});
|
|
462
|
+
io.err(`* wrote ${built.output} (${built.size} bytes, ${built.verifier.length} verifier members)`);
|
|
463
|
+
io.err(`* run an archive with it: ${values.output} <archive> [args...]`);
|
|
464
|
+
return 0;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const signer = await chooseSigner(values, io);
|
|
468
|
+
const res = await SEA.buildSea({
|
|
469
|
+
app,
|
|
470
|
+
output: values.output,
|
|
471
|
+
node: values.node,
|
|
472
|
+
base: values.base,
|
|
473
|
+
sigstore: values.sigstore,
|
|
474
|
+
signer,
|
|
475
|
+
hashAlg: values.hash,
|
|
476
|
+
signAlg: values.sign,
|
|
477
|
+
bootstrap,
|
|
478
|
+
log: io.err,
|
|
479
|
+
});
|
|
480
|
+
if (res.output) io.err(`* wrote ${res.output} (${res.size} bytes)`);
|
|
481
|
+
return 0;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Refresh the sigstore trust root. Verification is deliberately offline — it
|
|
485
|
+
// will not reach for the network to decide whether to mount something — so the
|
|
486
|
+
// trust material has to be fetched by an explicit step like this one. It comes
|
|
487
|
+
// over TUF, which is signed metadata with its own root of trust rather than a
|
|
488
|
+
// plain download.
|
|
489
|
+
async function trust(args: string[], io: Console): Promise<number> {
|
|
490
|
+
const { values } = parseArgs({ args, options: { mirror: { type: 'string' } } });
|
|
491
|
+
const SIGSTORE = await import('./sigstore.ts');
|
|
492
|
+
const path = await SIGSTORE.refreshTrustedRoot(values.mirror ? { mirror: values.mirror } : {});
|
|
493
|
+
io.out(`sigstore trust root refreshed: ${path}`);
|
|
494
|
+
return 0;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Install the auditing skill into a project, so whoever is about to run an
|
|
498
|
+
// archive has the review procedure to hand rather than having to find it here.
|
|
499
|
+
function skill(args: string[], io: Console): number {
|
|
500
|
+
const { values, positionals } = parseArgs({
|
|
501
|
+
args,
|
|
502
|
+
allowPositionals: true,
|
|
503
|
+
options: {
|
|
504
|
+
dir: { type: 'string', short: 'd' },
|
|
505
|
+
force: { type: 'boolean', short: 'f' },
|
|
506
|
+
list: { type: 'boolean', short: 'l' },
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
const available = SKILLS.skills();
|
|
511
|
+
if (values.list) {
|
|
512
|
+
if (!available.length) io.out('this package carries no skills');
|
|
513
|
+
for (const entry of available) io.out(`${entry.name}\n ${entry.description}`);
|
|
514
|
+
return 0;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const names = positionals.length ? positionals : available.map((entry) => entry.name);
|
|
518
|
+
if (!names.length) throw new Error('skill: this package carries no skills to install');
|
|
519
|
+
|
|
520
|
+
for (const name of names) {
|
|
521
|
+
const res = SKILLS.install(name, { dir: values.dir, force: values.force });
|
|
522
|
+
for (const file of res.written) io.err(`+ ${PATH.relative(process.cwd(), file)}`);
|
|
523
|
+
for (const file of res.skipped) io.err(`= ${PATH.relative(process.cwd(), file)} (already there; --force to overwrite)`);
|
|
524
|
+
io.out(`installed skill '${res.name}' into ${PATH.relative(process.cwd(), res.path) || res.path}`);
|
|
525
|
+
}
|
|
526
|
+
return 0;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** What `sign` and `sea` both accept to decide how the signature is made. */
|
|
530
|
+
interface SignerChoice {
|
|
531
|
+
key?: string | undefined;
|
|
532
|
+
chain?: string | undefined;
|
|
533
|
+
sign?: string | undefined;
|
|
534
|
+
flow?: string | undefined;
|
|
535
|
+
token?: string | undefined;
|
|
536
|
+
'oidc-issuer'?: string | undefined;
|
|
537
|
+
connector?: string | undefined;
|
|
538
|
+
fulcio?: string | undefined;
|
|
539
|
+
rekor?: string | undefined;
|
|
540
|
+
tsa?: string | undefined;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Two signers, one interface. Either way the certificate has to be in hand
|
|
544
|
+
// before the archive is built, because AUTHORITY.PEM carries it and
|
|
545
|
+
// AUTHORITY.PEM is inside the region the hash covers — so this runs first and
|
|
546
|
+
// the signature itself is made later, over the finished bytes.
|
|
547
|
+
async function chooseSigner(values: SignerChoice, io: Console) {
|
|
548
|
+
if (values.key && values.chain) {
|
|
549
|
+
io.err('* signing against the supplied certificate chain');
|
|
550
|
+
return fileSigner({ key: values.key, chain: values.chain, signAlg: values.sign });
|
|
551
|
+
}
|
|
552
|
+
const SIGSTORE = await import('./sigstore.ts');
|
|
553
|
+
io.err('* signing through sigstore');
|
|
554
|
+
return await SIGSTORE.signer({
|
|
555
|
+
signAlg: values.sign,
|
|
556
|
+
flow: values.flow as 'auto' | 'ci' | 'browser' | 'device' | undefined,
|
|
557
|
+
token: values.token,
|
|
558
|
+
issuer: values['oidc-issuer'],
|
|
559
|
+
connector: values.connector,
|
|
560
|
+
fulcioURL: values.fulcio,
|
|
561
|
+
rekorURL: values.rekor,
|
|
562
|
+
tsaURL: values.tsa,
|
|
563
|
+
log: io.err,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function readStdin(): Promise<string> {
|
|
568
|
+
return new Promise((resolve, reject) => {
|
|
569
|
+
let data = '';
|
|
570
|
+
process.stdin.setEncoding('utf-8');
|
|
571
|
+
process.stdin.on('data', (chunk: string) => { data += chunk; })
|
|
572
|
+
.on('end', () => resolve(data))
|
|
573
|
+
.on('error', reject);
|
|
574
|
+
});
|
|
575
|
+
}
|