@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/archive.ts
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import * as ZLIB from 'node:zlib';
|
|
2
|
+
import * as PATH from 'node:path';
|
|
3
|
+
import * as FS from 'node:fs';
|
|
4
|
+
import * as CRYPTO from 'node:crypto';
|
|
5
|
+
import { Transform, type Writable } from 'node:stream';
|
|
6
|
+
import { buildManifest, formatSignature, AUTHORITY } from './manifest.ts';
|
|
7
|
+
|
|
8
|
+
// Building an archive, in two steps that are deliberately separable.
|
|
9
|
+
//
|
|
10
|
+
// `bundle()` collects files off disk into an unsigned archive. `rebundle()`
|
|
11
|
+
// takes an existing archive and re-emits it — with a different prefix, a
|
|
12
|
+
// different certificate, or both. Signing is only ever `rebundle()`'s job.
|
|
13
|
+
//
|
|
14
|
+
// That split is what makes one build serve every shape. The offsets inside a
|
|
15
|
+
// ZIP central directory are absolute, so an archive that will sit behind a
|
|
16
|
+
// 155 MB node binary is not byte-identical to the same archive behind a 79-byte
|
|
17
|
+
// shebang: the prefix has to be chosen before the offsets are fixed, and
|
|
18
|
+
// therefore before the hash exists. Re-emitting from the members rather than
|
|
19
|
+
// copying bytes is what lets one `app.bundle` become a `#!` launcher, a
|
|
20
|
+
// self-contained executable and a plain mountable archive, each correctly
|
|
21
|
+
// offset and each signed over its own finished bytes:
|
|
22
|
+
//
|
|
23
|
+
// bundle → app.bundle (unsigned, the source of truth)
|
|
24
|
+
// rebundle → app.run (prefix: shell-base) signed
|
|
25
|
+
// rebundle → app.sea (prefix: node-base) signed
|
|
26
|
+
// rebundle → app.signed.bundle (no prefix) signed
|
|
27
|
+
//
|
|
28
|
+
// A signer is `{ chain, signAlg, sign(digest) }`: the chain goes into
|
|
29
|
+
// `AUTHORITY.PEM` *before* hashing, and `sign()` is called *after*, with the
|
|
30
|
+
// finished hash. `keySigner()` below is the offline-CA implementation;
|
|
31
|
+
// `sigstore.ts` provides the other one. Nothing here knows which it has.
|
|
32
|
+
|
|
33
|
+
/** One file about to become an archive member. */
|
|
34
|
+
export interface Member {
|
|
35
|
+
/** The member's name inside the archive; a `/`-separated relative path. */
|
|
36
|
+
name: string;
|
|
37
|
+
data: Buffer;
|
|
38
|
+
mode?: number | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What a signer hands back once the finished hash exists. */
|
|
42
|
+
export interface Signature {
|
|
43
|
+
signature: Buffer | Uint8Array;
|
|
44
|
+
/**
|
|
45
|
+
* Unsigned attributes to record beside the signature in the EOCD comment —
|
|
46
|
+
* anything obtained *after* signing, which therefore cannot be inside what
|
|
47
|
+
* the signature covers.
|
|
48
|
+
*/
|
|
49
|
+
fields?: Record<string, string | undefined> | undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The two-phase signing interface. `chain` is known up front (it goes into
|
|
54
|
+
* `AUTHORITY.PEM`, inside the hashed region); `sign()` is called afterwards
|
|
55
|
+
* with the hash of the finished bytes.
|
|
56
|
+
*/
|
|
57
|
+
export interface Signer {
|
|
58
|
+
kind?: string | undefined;
|
|
59
|
+
/** Full PEM certificate chain, leaf first. */
|
|
60
|
+
chain: string;
|
|
61
|
+
/** Digest the signature over the whole-file hash uses. */
|
|
62
|
+
signAlg: string;
|
|
63
|
+
sign(digest: Buffer): Promise<Signature>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** What `emit()` reports once the file has been fully written. */
|
|
67
|
+
export interface EmitResult {
|
|
68
|
+
/** The whole-file hash, hex — null for an unsigned archive. */
|
|
69
|
+
hash: string | null;
|
|
70
|
+
signed: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface BundleOptions {
|
|
74
|
+
/** Base directory the file list is relative to. */
|
|
75
|
+
base: string;
|
|
76
|
+
/** Member names, relative to `base`. */
|
|
77
|
+
files: string[];
|
|
78
|
+
/** A launcher or binary to prepend, making the result self-running. */
|
|
79
|
+
prefix?: string | undefined;
|
|
80
|
+
/** Digest for the whole-file hash and member digests (default: 'sha256'). */
|
|
81
|
+
hashAlg?: string | undefined;
|
|
82
|
+
/** Digest the signature uses (default: 'sha256'). */
|
|
83
|
+
signAlg?: string | undefined;
|
|
84
|
+
/** Shorthand for `signer: keySigner({ key, chain, signAlg })`. */
|
|
85
|
+
key?: Buffer | string | CRYPTO.KeyObject | undefined;
|
|
86
|
+
chain?: string | undefined;
|
|
87
|
+
signer?: Signer | undefined;
|
|
88
|
+
out: Writable;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface RebundleOptions {
|
|
92
|
+
/** Path to the archive whose members are re-emitted. */
|
|
93
|
+
source: string;
|
|
94
|
+
prefix?: string | undefined;
|
|
95
|
+
hashAlg?: string | undefined;
|
|
96
|
+
signAlg?: string | undefined;
|
|
97
|
+
key?: Buffer | string | CRYPTO.KeyObject | undefined;
|
|
98
|
+
chain?: string | undefined;
|
|
99
|
+
signer?: Signer | undefined;
|
|
100
|
+
out: Writable;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Members, as `{ name, data, mode }`. Two sources: a directory plus a file
|
|
104
|
+
// list, or an existing archive.
|
|
105
|
+
//
|
|
106
|
+
// `AUTHORITY.PEM` is never carried across from a source archive — it describes
|
|
107
|
+
// the signing of the archive it came from, and a re-emitted archive gets a
|
|
108
|
+
// fresh one.
|
|
109
|
+
export async function *fromDirectory(base: string, files: string[]): AsyncGenerator<Member> {
|
|
110
|
+
for (const name of files) {
|
|
111
|
+
yield { name, data: FS.readFileSync(PATH.resolve(base, name)), mode: 0o444 };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function *fromArchive(zip: ZLIB.ZipFile): AsyncGenerator<Member> {
|
|
116
|
+
for (const [name, entry] of zip.entriesSync()) {
|
|
117
|
+
if (name === AUTHORITY || entry.isDirectory) continue;
|
|
118
|
+
yield { name, data: entry.contentSync(), mode: entry.mode || 0o444 };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Yields a ZipEntry per member — each stamped, in its entry comment, with the
|
|
123
|
+
// hex digest of its own content — then a final `AUTHORITY.PEM` manifest entry
|
|
124
|
+
// declaring the algorithms and (when signing) carrying the certificate chain.
|
|
125
|
+
// Members are small (an application's own files; the heavy runtime is the
|
|
126
|
+
// prepended prefix, not an archive member), so each is held whole to hash it
|
|
127
|
+
// before the entry, whose comment must be fixed at creation time, is built.
|
|
128
|
+
async function *entries(
|
|
129
|
+
members: AsyncIterable<Member> | Iterable<Member>,
|
|
130
|
+
{ hashAlg, signAlg, chain }: { hashAlg: string; signAlg?: string | undefined; chain?: string | undefined },
|
|
131
|
+
): AsyncGenerator<ZLIB.ZipEntry> {
|
|
132
|
+
for await (const { name, data, mode } of members) {
|
|
133
|
+
const digest = CRYPTO.createHash(hashAlg).update(data).digest('hex');
|
|
134
|
+
yield await ZLIB.ZipEntry.create(name, data, { mode: mode ?? 0o444, comment: digest });
|
|
135
|
+
}
|
|
136
|
+
yield await ZLIB.ZipEntry.create(AUTHORITY, buildManifest({ hashAlg, signAlg, chain }), { mode: 0o444 });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* A Readable of the ZIP archive over `members`. Its members carry per-file
|
|
141
|
+
* digests and its AUTHORITY.PEM manifest declares the algorithms and chain, but
|
|
142
|
+
* the archive itself is left with an empty EOCD comment: the whole-file hash
|
|
143
|
+
* and its signature are a property of the finished file and are applied by
|
|
144
|
+
* `emit()`. `baseOffset` seeds the archive's internal offsets for when it is
|
|
145
|
+
* appended after a prefix.
|
|
146
|
+
*/
|
|
147
|
+
export function createArchive({ members, base, files, hashAlg = 'sha256', signAlg, chain, baseOffset = 0 }: {
|
|
148
|
+
members?: AsyncIterable<Member> | Iterable<Member> | undefined;
|
|
149
|
+
base?: string | undefined;
|
|
150
|
+
files?: string[] | undefined;
|
|
151
|
+
hashAlg?: string | undefined;
|
|
152
|
+
signAlg?: string | undefined;
|
|
153
|
+
chain?: string | undefined;
|
|
154
|
+
baseOffset?: number | undefined;
|
|
155
|
+
}) {
|
|
156
|
+
const source = members ?? fromDirectory(base ?? '.', files ?? []);
|
|
157
|
+
return ZLIB.createZipArchive(entries(source, { hashAlg, signAlg, chain }), { baseOffset });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A signer backed by a private key and a certificate chain already on disk —
|
|
162
|
+
* the offline-CA path, and the shape `sigstore.ts` implements too.
|
|
163
|
+
*/
|
|
164
|
+
export function keySigner({ key, chain, signAlg = 'sha256' }: {
|
|
165
|
+
key: Buffer | string | CRYPTO.KeyObject;
|
|
166
|
+
chain: Buffer | string;
|
|
167
|
+
signAlg?: string | undefined;
|
|
168
|
+
}): Signer {
|
|
169
|
+
return {
|
|
170
|
+
kind: 'key',
|
|
171
|
+
chain: String(chain),
|
|
172
|
+
signAlg,
|
|
173
|
+
async sign(digest: Buffer): Promise<Signature> {
|
|
174
|
+
return { signature: Buffer.from(CRYPTO.sign(signAlg, digest, key as CRYPTO.KeyLike)) };
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Writes `prefix` (when given) then the archive to `out`, without closing
|
|
180
|
+
// `out`. With no prefix the result is a plain archive — a `.bundle` meant to be
|
|
181
|
+
// run through `--vfs-load`; with one it is a self-running container (a shebang
|
|
182
|
+
// launcher or a SEA binary) that carries the same archive in its tail. When a
|
|
183
|
+
// `signer` is given, the whole file is signed. The hash runs over the prefix
|
|
184
|
+
// and then over the archive up to (but not including) the EOCD comment; that
|
|
185
|
+
// hash is what the signer signs. The EOCD comment records both, so a verifier
|
|
186
|
+
// can validate the hash on its own (a cheap pre-mount integrity gate) and only
|
|
187
|
+
// then check the signature over that hash against the certificate:
|
|
188
|
+
//
|
|
189
|
+
// SIGNED:<hash-of-region-hex>:<signature-hex>[:<NAME>=<value>]*
|
|
190
|
+
async function emit({ members, prefix, hashAlg = 'sha256', signer, out }: {
|
|
191
|
+
members: AsyncIterable<Member> | Iterable<Member>;
|
|
192
|
+
prefix?: string | undefined;
|
|
193
|
+
hashAlg?: string | undefined;
|
|
194
|
+
signer?: Signer | undefined;
|
|
195
|
+
out: Writable;
|
|
196
|
+
}): Promise<EmitResult> {
|
|
197
|
+
const hasher = signer ? CRYPTO.createHash(hashAlg) : null;
|
|
198
|
+
|
|
199
|
+
// 1. Stream the prefix straight to `out`, feeding the whole-file hash.
|
|
200
|
+
if (prefix) await prepend(prefix, out, hasher);
|
|
201
|
+
|
|
202
|
+
// 2. Build the archive (small) with an empty EOCD comment, in memory. The
|
|
203
|
+
// chain has to be embedded here, before anything is hashed — which is
|
|
204
|
+
// why a signer hands over its certificate up front and signs later.
|
|
205
|
+
const archive = await collect(createArchive({
|
|
206
|
+
members, hashAlg,
|
|
207
|
+
signAlg: signer?.signAlg,
|
|
208
|
+
chain: signer?.chain,
|
|
209
|
+
baseOffset: prefix ? FS.statSync(prefix).size : 0,
|
|
210
|
+
}));
|
|
211
|
+
|
|
212
|
+
if (!signer || !hasher) {
|
|
213
|
+
await write(out, archive);
|
|
214
|
+
return { hash: null, signed: false };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 3. The signed region is the prefix plus the archive minus its trailing
|
|
218
|
+
// 2-byte (empty) comment-length field. Hash it, sign the hash, and
|
|
219
|
+
// re-emit the archive with the marker as the EOCD comment. Anything the
|
|
220
|
+
// signer produced *after* signing — a transparency-log entry, a
|
|
221
|
+
// timestamp — comes back as fields and rides in the same comment, since
|
|
222
|
+
// it could not have been inside the hash it postdates.
|
|
223
|
+
const region = archive.subarray(0, archive.length - 2);
|
|
224
|
+
hasher.update(region);
|
|
225
|
+
const digest = hasher.digest();
|
|
226
|
+
const { signature, fields } = await signer.sign(digest);
|
|
227
|
+
|
|
228
|
+
const marker = formatSignature({
|
|
229
|
+
hash: digest.toString('hex'),
|
|
230
|
+
sig: Buffer.from(signature).toString('hex'),
|
|
231
|
+
fields,
|
|
232
|
+
});
|
|
233
|
+
const comment = Buffer.from(marker, 'ascii');
|
|
234
|
+
if (comment.length > 0xffff) {
|
|
235
|
+
throw new Error(`signature marker is ${comment.length} bytes; a ZIP comment holds at most 65535`);
|
|
236
|
+
}
|
|
237
|
+
const length = Buffer.alloc(2);
|
|
238
|
+
length.writeUInt16LE(comment.length, 0);
|
|
239
|
+
await write(out, Buffer.concat([region, length, comment]));
|
|
240
|
+
return { hash: digest.toString('hex'), signed: true };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Build an archive from files on disk. `key`/`chain` are accepted as a
|
|
245
|
+
* shorthand for `signer: keySigner({ key, chain, signAlg })`, so the
|
|
246
|
+
* create-and-sign-in-one-step path stays available.
|
|
247
|
+
*/
|
|
248
|
+
export async function bundle({ base, files, prefix, hashAlg = 'sha256', signAlg = 'sha256', key, chain, signer, out }: BundleOptions): Promise<EmitResult> {
|
|
249
|
+
const active = signer ?? (key && chain ? keySigner({ key, chain, signAlg }) : undefined);
|
|
250
|
+
return emit({ members: fromDirectory(base, files), prefix, hashAlg, signer: active, out });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Re-emit an existing archive: same members, new prefix, new signature. This is
|
|
255
|
+
* what `bundle sign` runs. `source` is a path to an archive (signed or not,
|
|
256
|
+
* prefixed or not) — its members are read out, its old AUTHORITY.PEM is
|
|
257
|
+
* dropped, and everything is laid down again at the offsets the new prefix
|
|
258
|
+
* implies before the result is hashed and signed as a whole.
|
|
259
|
+
*/
|
|
260
|
+
export async function rebundle({ source, prefix, hashAlg = 'sha256', signAlg = 'sha256', key, chain, signer, out }: RebundleOptions): Promise<EmitResult> {
|
|
261
|
+
const active = signer ?? (key && chain ? keySigner({ key, chain, signAlg }) : undefined);
|
|
262
|
+
const zip = ZLIB.ZipFile.openSync(PATH.resolve(source));
|
|
263
|
+
try {
|
|
264
|
+
// The member list is drained into memory before writing starts: the
|
|
265
|
+
// source archive may be the file being overwritten, and in any case the
|
|
266
|
+
// entries have to outlive the ZipFile handle closed below.
|
|
267
|
+
const members: Member[] = [];
|
|
268
|
+
for await (const member of fromArchive(zip)) members.push(member);
|
|
269
|
+
if (!members.length) throw new Error(`'${source}' contains no members to sign`);
|
|
270
|
+
return await emit({ members, prefix, hashAlg, signer: active, out });
|
|
271
|
+
} finally {
|
|
272
|
+
zip.closeSync();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The member names an archive holds, in order, excluding AUTHORITY.PEM — for
|
|
278
|
+
* reporting what is about to be re-signed without reading every member's bytes.
|
|
279
|
+
*/
|
|
280
|
+
export function members(source: string): string[] {
|
|
281
|
+
const zip = ZLIB.ZipFile.openSync(PATH.resolve(source));
|
|
282
|
+
try {
|
|
283
|
+
return [...zip.entriesSync()]
|
|
284
|
+
.filter(([name, entry]) => name !== AUTHORITY && !entry.isDirectory)
|
|
285
|
+
.map(([name]) => name);
|
|
286
|
+
} finally {
|
|
287
|
+
zip.closeSync();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function prepend(file: string, out: Writable, sink: CRYPTO.Hash | null): Promise<void> {
|
|
292
|
+
return new Promise((resolve, reject) => {
|
|
293
|
+
const tap = new Transform({
|
|
294
|
+
transform(chunk: Buffer, _enc, cb) { if (sink) sink.update(chunk); cb(null, chunk); },
|
|
295
|
+
});
|
|
296
|
+
FS.createReadStream(file).on('error', reject)
|
|
297
|
+
.pipe(tap).on('error', reject).on('end', () => resolve())
|
|
298
|
+
.pipe(out, { end: false });
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function collect(readable: AsyncIterable<Buffer>): Promise<Buffer> {
|
|
303
|
+
const chunks: Buffer[] = [];
|
|
304
|
+
for await (const chunk of readable) chunks.push(chunk);
|
|
305
|
+
return Buffer.concat(chunks);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function write(out: Writable, buffer: Buffer): Promise<void> {
|
|
309
|
+
return new Promise((resolve, reject) => {
|
|
310
|
+
out.write(buffer, (err) => (err ? reject(err) : resolve()));
|
|
311
|
+
});
|
|
312
|
+
}
|
package/src/audit.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import * as FS from 'node:fs';
|
|
2
|
+
import * as PATH from 'node:path';
|
|
3
|
+
import * as CRYPTO from 'node:crypto';
|
|
4
|
+
import { verifyBundleSync, inspectBundle } from './api.ts';
|
|
5
|
+
import { STATES, message } from './manifest.ts';
|
|
6
|
+
|
|
7
|
+
// Step 3 of building a bundle: the audit gate.
|
|
8
|
+
//
|
|
9
|
+
// observe → create → AUDIT → sign
|
|
10
|
+
//
|
|
11
|
+
// The review itself needs judgement — it is `skills/audit-bundle`, and a person
|
|
12
|
+
// or an agent performs it. What code can do is the two mechanical halves around
|
|
13
|
+
// it: say what is about to be reviewed and how, and then refuse to let signing
|
|
14
|
+
// proceed on anything but a clean verdict over exactly these bytes.
|
|
15
|
+
//
|
|
16
|
+
// The verdict is a JSON file the skill writes (see its "A verdict something else
|
|
17
|
+
// can gate on" section), and the load-bearing field is the **sha256 of the
|
|
18
|
+
// archive**. Without that pin the gate is theatre: it would approve any later
|
|
19
|
+
// build on the strength of one earlier approval, which is the failure mode of
|
|
20
|
+
// every "security review completed" checkbox. Rebuilding invalidates the
|
|
21
|
+
// approval, and it should.
|
|
22
|
+
//
|
|
23
|
+
// This is a gate you choose to put in your own pipeline, not something the
|
|
24
|
+
// format imposes — the same position `--identity` takes.
|
|
25
|
+
|
|
26
|
+
/** What an audit concluded, as the gate reads it. */
|
|
27
|
+
export interface Verdict {
|
|
28
|
+
/** The archive reviewed, as the auditor referred to it. */
|
|
29
|
+
bundle?: string | undefined;
|
|
30
|
+
/** sha256 of the archive file — what pins the verdict to the bytes. */
|
|
31
|
+
sha256?: string | undefined;
|
|
32
|
+
/** The archive it was reviewed against, when the review was a diff. */
|
|
33
|
+
baseline?: string | undefined;
|
|
34
|
+
baselineSha256?: string | undefined;
|
|
35
|
+
/** Which question was answered: whether to sign it, or whether to run it. */
|
|
36
|
+
mode?: 'sign' | 'run' | undefined;
|
|
37
|
+
state?: string | undefined;
|
|
38
|
+
members?: number | undefined;
|
|
39
|
+
/** How many members were actually read — the changed set, in diff mode. */
|
|
40
|
+
reviewed?: number | undefined;
|
|
41
|
+
verdict?: 'pass' | 'fail' | undefined;
|
|
42
|
+
summary?: string | undefined;
|
|
43
|
+
findings?: Finding[] | undefined;
|
|
44
|
+
/** 'human' when a person recorded it rather than the skill. */
|
|
45
|
+
by?: string | undefined;
|
|
46
|
+
at?: string | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface Finding {
|
|
50
|
+
severity: string;
|
|
51
|
+
file: string;
|
|
52
|
+
line?: number | undefined;
|
|
53
|
+
what: string;
|
|
54
|
+
why?: string | undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface AuditOptions {
|
|
58
|
+
/** The archive under review. */
|
|
59
|
+
bundle: string;
|
|
60
|
+
/** Where the verdict is written and read (default: `<bundle>.audit.json`). */
|
|
61
|
+
verdict?: string | undefined;
|
|
62
|
+
/** A previously approved archive to review against, when there is one. */
|
|
63
|
+
baseline?: string | undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** What `prepare()` found, for a caller that wants to report it itself. */
|
|
67
|
+
export interface Preparation {
|
|
68
|
+
bundle: string;
|
|
69
|
+
sha256: string;
|
|
70
|
+
state: string;
|
|
71
|
+
members: string[];
|
|
72
|
+
verdict: string;
|
|
73
|
+
/** Present when a baseline was supplied and exists. */
|
|
74
|
+
baseline?: {
|
|
75
|
+
path: string;
|
|
76
|
+
sha256: string;
|
|
77
|
+
added: string[];
|
|
78
|
+
removed: string[];
|
|
79
|
+
carried: number;
|
|
80
|
+
} | undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Where the verdict for `bundle` lives, unless the caller says otherwise. */
|
|
84
|
+
export function verdictPath(bundle: string, override?: string | undefined): string {
|
|
85
|
+
return override ? PATH.resolve(override) : `${PATH.resolve(bundle)}.audit.json`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function sha256Of(path: string): string {
|
|
89
|
+
return CRYPTO.createHash('sha256').update(FS.readFileSync(path)).digest('hex');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Describe what is about to be audited: its hash, its members, and — when a
|
|
94
|
+
* baseline is given — what changed since. Throws when the archive does not hold
|
|
95
|
+
* together, because reviewing an archive whose contents are not what it says
|
|
96
|
+
* they are is worse than not reviewing it.
|
|
97
|
+
*/
|
|
98
|
+
export function prepare({ bundle, verdict, baseline }: AuditOptions): Preparation {
|
|
99
|
+
if (!FS.existsSync(bundle)) throw new Error(`there is no archive at ${bundle}`);
|
|
100
|
+
|
|
101
|
+
// Damaged badly enough not to parse as a ZIP throws from inside the reader
|
|
102
|
+
// rather than answering; that is still a refusal and should read as one.
|
|
103
|
+
let state, members;
|
|
104
|
+
try {
|
|
105
|
+
state = verifyBundleSync(bundle).state;
|
|
106
|
+
({ members } = inspectBundle(bundle));
|
|
107
|
+
} catch (err) {
|
|
108
|
+
throw new Error(`${bundle} could not be read as an archive: ${message(err)}`);
|
|
109
|
+
}
|
|
110
|
+
if (state === 'invalid') {
|
|
111
|
+
throw new Error(`${bundle} is ${STATES.invalid.label} — rebuild it rather than reviewing it; ` +
|
|
112
|
+
'what you would review is not what it holds');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const found: Preparation = {
|
|
116
|
+
bundle, sha256: sha256Of(bundle), state, members,
|
|
117
|
+
verdict: verdictPath(bundle, verdict),
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
if (baseline && FS.existsSync(baseline)) {
|
|
121
|
+
const previous = inspectBundle(baseline).members;
|
|
122
|
+
const added = members.filter((name) => !previous.includes(name));
|
|
123
|
+
const removed = previous.filter((name) => !members.includes(name));
|
|
124
|
+
found.baseline = {
|
|
125
|
+
path: baseline, sha256: sha256Of(baseline),
|
|
126
|
+
added, removed, carried: members.length - added.length,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return found;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The gate. Returns the verdict when it passes; throws with the reason when it
|
|
134
|
+
* does not — no verdict, a verdict over different bytes, a verdict against a
|
|
135
|
+
* different baseline, or a verdict that failed.
|
|
136
|
+
*/
|
|
137
|
+
export function check({ bundle, verdict, baseline }: AuditOptions): Verdict {
|
|
138
|
+
const path = verdictPath(bundle, verdict);
|
|
139
|
+
if (!FS.existsSync(path)) {
|
|
140
|
+
throw new Error(`${bundle} has not been audited — there is no verdict at ${path}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let found: Verdict;
|
|
144
|
+
try {
|
|
145
|
+
found = JSON.parse(FS.readFileSync(path, 'utf-8')) as Verdict;
|
|
146
|
+
} catch (err) {
|
|
147
|
+
throw new Error(`${path} is not readable JSON: ${message(err)}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const sha256 = sha256Of(bundle);
|
|
151
|
+
if (found.sha256?.toLowerCase() !== sha256) {
|
|
152
|
+
throw new Error(`${path} approves different bytes — it is stale.\n` +
|
|
153
|
+
` approved: ${found.sha256 ?? '(none)'}\n` +
|
|
154
|
+
` on disk: ${sha256}\n` +
|
|
155
|
+
' re-audit the archive this build actually produced');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// A baseline that was available but reviewed against something else is the
|
|
159
|
+
// one way this gate can be fooled honestly: a verdict claiming a diff it did
|
|
160
|
+
// not do. A verdict recording *no* baseline is a full review, which is fine.
|
|
161
|
+
if (baseline && FS.existsSync(baseline) && found.baselineSha256) {
|
|
162
|
+
const expected = sha256Of(baseline);
|
|
163
|
+
if (found.baselineSha256.toLowerCase() !== expected) {
|
|
164
|
+
throw new Error(`${path} was reached against a different baseline.\n` +
|
|
165
|
+
` audited against: ${found.baselineSha256}\n` +
|
|
166
|
+
` on disk: ${expected}\n` +
|
|
167
|
+
' re-audit against the baseline this build is comparing to');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (found.verdict !== 'pass') {
|
|
172
|
+
const serious = (found.findings ?? []).filter((f) => f.severity !== 'note');
|
|
173
|
+
throw new Error(`the audit of ${bundle} did not pass: ${found.summary ?? '(no summary)'}` +
|
|
174
|
+
serious.map((f) => `\n [${f.severity}] ${f.file}${f.line ? `:${f.line}` : ''} — ${f.what}`).join(''));
|
|
175
|
+
}
|
|
176
|
+
return found;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Record a clean verdict reached by reading the archive yourself. It writes the
|
|
181
|
+
* same file the skill would, marked `by: 'human'`, so the gate treats both
|
|
182
|
+
* identically while the record still says which happened.
|
|
183
|
+
*/
|
|
184
|
+
export function approve({ bundle, verdict, baseline, note }: AuditOptions & {
|
|
185
|
+
/** What you checked — it becomes the verdict's summary. */
|
|
186
|
+
note?: string | undefined;
|
|
187
|
+
}): Verdict {
|
|
188
|
+
const found = prepare({ bundle, verdict, baseline });
|
|
189
|
+
const recorded: Verdict = {
|
|
190
|
+
bundle,
|
|
191
|
+
sha256: found.sha256,
|
|
192
|
+
...(found.baseline ? { baseline: found.baseline.path, baselineSha256: found.baseline.sha256 } : {}),
|
|
193
|
+
mode: 'sign',
|
|
194
|
+
state: found.state,
|
|
195
|
+
members: found.members.length,
|
|
196
|
+
reviewed: found.members.length,
|
|
197
|
+
verdict: 'pass',
|
|
198
|
+
summary: note ?? 'approved by hand; no note given',
|
|
199
|
+
findings: [],
|
|
200
|
+
by: 'human',
|
|
201
|
+
at: new Date().toISOString(),
|
|
202
|
+
};
|
|
203
|
+
FS.mkdirSync(PATH.dirname(found.verdict), { recursive: true });
|
|
204
|
+
FS.writeFileSync(found.verdict, `${JSON.stringify(recorded, null, 2)}\n`);
|
|
205
|
+
return recorded;
|
|
206
|
+
}
|