@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/record.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { preload, sibling } from './preload.ts';
|
|
2
|
+
import type * as Recorder from './recorder.ts';
|
|
3
|
+
|
|
4
|
+
// The preload that puts manifest recording back where `--vfs-manifest` used to
|
|
5
|
+
// be: registering a recording provider is all it does, so `--vfs-load` finds it
|
|
6
|
+
// already in place when it picks a provider for a directory.
|
|
7
|
+
//
|
|
8
|
+
// BUNDLE_MANIFEST=app.manifest node --experimental-vfs \
|
|
9
|
+
// -r @pipobscure/bundle/record --vfs-load=./lib
|
|
10
|
+
//
|
|
11
|
+
// `--import @pipobscure/bundle/record` works just as well: mounting is deferred
|
|
12
|
+
// until both `-r` and `--import` preloads have run, and this module imports
|
|
13
|
+
// cleanly from ESM.
|
|
14
|
+
//
|
|
15
|
+
// Configure it through the environment — a preload takes no arguments:
|
|
16
|
+
//
|
|
17
|
+
// BUNDLE_MANIFEST where to write the file list; unset records nothing
|
|
18
|
+
//
|
|
19
|
+
// For anything more (recording an archive mount, several manifests, keeping an
|
|
20
|
+
// existing list), import `@pipobscure/bundle/recorder` and call `register()` or
|
|
21
|
+
// `recording()` from a preload module of your own.
|
|
22
|
+
|
|
23
|
+
preload(() => {
|
|
24
|
+
sibling<typeof Recorder>(import.meta.filename, 'recorder').register();
|
|
25
|
+
});
|
package/src/recorder.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import * as VFS from 'node:vfs';
|
|
2
|
+
import * as FS from 'node:fs';
|
|
3
|
+
import { isMainThread } from 'node:worker_threads';
|
|
4
|
+
|
|
5
|
+
// Manifest recording, in userland: the replacement for the `--vfs-manifest`
|
|
6
|
+
// flag now that mounting from the command line is `--vfs-load` and provider
|
|
7
|
+
// selection is the only place that mount can be influenced.
|
|
8
|
+
//
|
|
9
|
+
// node -r @pipobscure/bundle/record --vfs-load=./lib
|
|
10
|
+
//
|
|
11
|
+
// The idea is unchanged — run the application once and write down every file it
|
|
12
|
+
// actually reads through the mount, which is the exact set an archive of it
|
|
13
|
+
// needs to contain — but it is a *provider* that does it rather than a hook
|
|
14
|
+
// inside `node:vfs`. `recording()` wraps a provider class and records reads on
|
|
15
|
+
// the way through; `register()` installs a recording `RealFSProvider` so a
|
|
16
|
+
// directory mount picks it up.
|
|
17
|
+
//
|
|
18
|
+
// Two differences from the flag it replaces, both deliberate:
|
|
19
|
+
//
|
|
20
|
+
// * The flag hooked `readFile()`/`readFileSync()` only. Those are where the
|
|
21
|
+
// module loader and ordinary `fs.readFile` calls converge, but a
|
|
22
|
+
// `createReadStream()` goes through `open()` and a handle instead, and was
|
|
23
|
+
// never recorded. Here read-only `open()`s are recorded too: a file that
|
|
24
|
+
// was opened but never read is harmless in a bundle, while a streamed file
|
|
25
|
+
// that is missing from one is not.
|
|
26
|
+
//
|
|
27
|
+
// * Recording is a property of a mount rather than of the process. The
|
|
28
|
+
// command line mounts exactly one source — `--vfs-load` is the only flag
|
|
29
|
+
// left that mounts anything — so in the ordinary case it is the same file
|
|
30
|
+
// the flag produced; a program that mounts more directories of its own
|
|
31
|
+
// through a recording provider adds them to the same list.
|
|
32
|
+
|
|
33
|
+
export interface ManifestOptions {
|
|
34
|
+
/**
|
|
35
|
+
* Start a fresh list. Defaults to true only on the main thread: worker
|
|
36
|
+
* threads inherit `execArgv`, so this preload runs in them too, and a
|
|
37
|
+
* worker must append to what the main thread already wrote rather than
|
|
38
|
+
* wipe it mid-run.
|
|
39
|
+
*/
|
|
40
|
+
truncate?: boolean | undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface RecorderOptions extends ManifestOptions {
|
|
44
|
+
/** Where to write the file list (default: the `BUNDLE_MANIFEST` variable). */
|
|
45
|
+
manifest?: string | Manifest | undefined;
|
|
46
|
+
/** Identifier reported in diagnostics (default: 'bundle-manifest'). */
|
|
47
|
+
name?: string | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The manifest file itself: which paths have been written, and the appending.
|
|
52
|
+
* Paths are appended as soon as they are read rather than buffered and flushed
|
|
53
|
+
* at exit, so nothing is lost if the process is killed instead of exiting.
|
|
54
|
+
*/
|
|
55
|
+
export class Manifest {
|
|
56
|
+
#path: string;
|
|
57
|
+
#seen = new Set<string>();
|
|
58
|
+
|
|
59
|
+
// Concurrent appends are safe without any coordination — O_APPEND writes do
|
|
60
|
+
// not interleave — which is what lets worker threads share one manifest.
|
|
61
|
+
constructor(path: string, { truncate = isMainThread }: ManifestOptions = {}) {
|
|
62
|
+
this.#path = path;
|
|
63
|
+
if (truncate) FS.writeFileSync(path, '');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get path(): string { return this.#path; }
|
|
67
|
+
|
|
68
|
+
/** Every path recorded so far, in the order they were first read. */
|
|
69
|
+
get paths(): string[] { return [...this.#seen]; }
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Record one VFS path, once. Best effort: bookkeeping must never break the
|
|
73
|
+
* read that triggered it.
|
|
74
|
+
*/
|
|
75
|
+
record(path: string): void {
|
|
76
|
+
try {
|
|
77
|
+
const relative = String(path).replace(/^\/+/, '');
|
|
78
|
+
if (relative === '' || this.#seen.has(relative)) return;
|
|
79
|
+
this.#seen.add(relative);
|
|
80
|
+
FS.appendFileSync(this.#path, `${relative}\n`);
|
|
81
|
+
} catch {
|
|
82
|
+
// ignored
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The shape `recording()` accepts and returns — any provider class. The rest
|
|
89
|
+
* parameter is what lets the wrapper be a mixin over a class whose constructor
|
|
90
|
+
* takes whatever it likes, and hand back one with the same signature.
|
|
91
|
+
*/
|
|
92
|
+
type ProviderClass = new (...args: any[]) => VFS.VirtualProvider;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Wrap a provider class so every read through it is recorded in `manifest`.
|
|
96
|
+
* Works over any `VirtualProvider` subclass — a `RealFSProvider` for a
|
|
97
|
+
* directory mount, a `ZipProvider` or the signed-archive provider from
|
|
98
|
+
* './provider.ts' for an archive one — and leaves the constructor signature of
|
|
99
|
+
* the class it wraps untouched:
|
|
100
|
+
*
|
|
101
|
+
* const Recording = recording(VFS.RealFSProvider, new Manifest('app.manifest'));
|
|
102
|
+
* const provider = new Recording('/path/to/directory');
|
|
103
|
+
*/
|
|
104
|
+
export function recording<T extends ProviderClass>(Base: T, manifest: Manifest): T {
|
|
105
|
+
return class Recording extends Base {
|
|
106
|
+
override async readFile(path: string, options?: unknown): Promise<Buffer> {
|
|
107
|
+
const content = await super.readFile(path, options);
|
|
108
|
+
manifest.record(path);
|
|
109
|
+
return content;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
override readFileSync(path: string, options?: unknown): Buffer {
|
|
113
|
+
const content = super.readFileSync(path, options);
|
|
114
|
+
manifest.record(path);
|
|
115
|
+
return content;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
override async open(path: string, flags?: string | number, mode?: number) {
|
|
119
|
+
const handle = await super.open(path, flags, mode);
|
|
120
|
+
if (reads(flags)) manifest.record(path);
|
|
121
|
+
return handle;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
override openSync(path: string, flags?: string | number, mode?: number) {
|
|
125
|
+
const handle = super.openSync(path, flags, mode);
|
|
126
|
+
if (reads(flags)) manifest.record(path);
|
|
127
|
+
return handle;
|
|
128
|
+
}
|
|
129
|
+
} as T;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Register a recording `RealFSProvider` so a directory loaded with
|
|
134
|
+
* `--vfs-load` is backed by it. Meant to be preloaded, before the mount is made:
|
|
135
|
+
*
|
|
136
|
+
* BUNDLE_MANIFEST=app.manifest node --experimental-vfs \
|
|
137
|
+
* -r @pipobscure/bundle/record --vfs-load=./lib
|
|
138
|
+
*
|
|
139
|
+
* With no manifest destination there is nothing to record, so nothing is
|
|
140
|
+
* registered and directory mounts are left exactly as node makes them; the
|
|
141
|
+
* return value is then null.
|
|
142
|
+
*/
|
|
143
|
+
export function register(options: RecorderOptions = {}): Manifest | null {
|
|
144
|
+
const configured = options.manifest ?? process.env['BUNDLE_MANIFEST'];
|
|
145
|
+
if (!configured) return null;
|
|
146
|
+
|
|
147
|
+
const manifest = configured instanceof Manifest ? configured : new Manifest(configured, options);
|
|
148
|
+
const Recording = recording(VFS.RealFSProvider, manifest);
|
|
149
|
+
VFS.registerProvider({
|
|
150
|
+
name: options.name ?? 'bundle-manifest',
|
|
151
|
+
// Directory mounts only: an archive mount has its own provider (and,
|
|
152
|
+
// for a `.bundle`, one that verifies what it serves), and quietly
|
|
153
|
+
// replacing it here would be the wrong trade for a file list.
|
|
154
|
+
canHandle: (_resolvedPath, stats) => stats.isDirectory(),
|
|
155
|
+
create: (resolvedPath) => new Recording(resolvedPath),
|
|
156
|
+
});
|
|
157
|
+
return manifest;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Whether `flags` opens for reading. Mirrors how `node:fs` reads them: a string
|
|
161
|
+
// means what it says, a number is a bitmask, anything else is 'r'.
|
|
162
|
+
function reads(flags: string | number | undefined): boolean {
|
|
163
|
+
if (typeof flags === 'string') return flags[0] === 'r' || flags.includes('+');
|
|
164
|
+
if (typeof flags !== 'number') return true;
|
|
165
|
+
return (flags & (FS.constants.O_WRONLY | FS.constants.O_TRUNC)) === 0;
|
|
166
|
+
}
|
package/src/register.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { preload, sibling } from './preload.ts';
|
|
2
|
+
import type * as Provider from './provider.ts';
|
|
3
|
+
|
|
4
|
+
// The preload entry point: registering the signed-archive provider is all this
|
|
5
|
+
// does, so `--vfs-load` finds it already in place when it picks a provider for
|
|
6
|
+
// its source.
|
|
7
|
+
//
|
|
8
|
+
// node --experimental-vfs -r @pipobscure/bundle/register --vfs-load=app.bundle
|
|
9
|
+
//
|
|
10
|
+
// `--import @pipobscure/bundle/register` works just as well: mounting is
|
|
11
|
+
// deferred until both `-r` and `--import` preloads have run, and this module
|
|
12
|
+
// imports cleanly from ESM. Either way the point is that registering is a
|
|
13
|
+
// preload flag and nothing more — no module of your own to write.
|
|
14
|
+
//
|
|
15
|
+
// Configure it through the environment — a preload takes no arguments:
|
|
16
|
+
//
|
|
17
|
+
// BUNDLE_ROOTS extra trusted root certificates, as a
|
|
18
|
+
// path-delimiter-separated list of PEM files
|
|
19
|
+
// BUNDLE_ALLOW_UNTRUSTED accept a good signature whose chain is not anchored
|
|
20
|
+
// in the trust store
|
|
21
|
+
// BUNDLE_IDENTITY require this sigstore signing identity
|
|
22
|
+
// BUNDLE_ISSUER require this sigstore OIDC issuer
|
|
23
|
+
// BUNDLE_SIGSTORE_ROOT path to the sigstore trust root to check against
|
|
24
|
+
//
|
|
25
|
+
// For anything more, import `@pipobscure/bundle/provider` and call `register()`
|
|
26
|
+
// with options from a preload module of your own.
|
|
27
|
+
|
|
28
|
+
preload(() => {
|
|
29
|
+
sibling<typeof Provider>(import.meta.filename, 'provider').register();
|
|
30
|
+
});
|
package/src/sea.ts
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import * as FS from 'node:fs';
|
|
2
|
+
import * as OS from 'node:os';
|
|
3
|
+
import * as PATH from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { mount, start, verify, type Baked, type Mounted } from './launch.ts';
|
|
6
|
+
import { signBundle, createBundle, type BuildResult } from './api.ts';
|
|
7
|
+
import type { VerificationResult } from './manifest.ts';
|
|
8
|
+
import { moduleFiles, packageRoot, moduleDir } from './files.ts';
|
|
9
|
+
import type { Signer } from './archive.ts';
|
|
10
|
+
|
|
11
|
+
// Building the executables: a node runtime with this package inside it, which
|
|
12
|
+
// verifies an archive before running anything out of it.
|
|
13
|
+
//
|
|
14
|
+
// One base binary, two things to do with it:
|
|
15
|
+
//
|
|
16
|
+
// [ node runtime | SEA blob: stub + verifier.bundle ]
|
|
17
|
+
// a *verifying node* — `node-verifying ./my-app.zip` checks that archive
|
|
18
|
+
// and runs it. Any archive, checked every time, none of them baked in.
|
|
19
|
+
//
|
|
20
|
+
// [ node runtime | SEA blob: stub + verifier.bundle ] [ app.bundle ]
|
|
21
|
+
// \______________ the prefix, and part of the app archive's ______/
|
|
22
|
+
// \____________ signed region ____________________/
|
|
23
|
+
// a *self-validating executable* — the same base with an application
|
|
24
|
+
// appended and the whole file signed as one.
|
|
25
|
+
//
|
|
26
|
+
// The second is the first with an archive behind it, which is not a
|
|
27
|
+
// coincidence: appending is `sign --prefix`, the same operation that puts a
|
|
28
|
+
// shebang in front of an archive. What makes it self-validating is that the
|
|
29
|
+
// whole-file hash covers the prefix too, so the runtime and the verifier are
|
|
30
|
+
// signed by the same signature that covers the application. There is nothing to
|
|
31
|
+
// check the checker against because the checker is inside what is checked.
|
|
32
|
+
//
|
|
33
|
+
// Which shape a binary is, it decides at startup by looking at its own tail —
|
|
34
|
+
// see `appended()` in `./launch.ts`. That is why one base serves both, and why
|
|
35
|
+
// a verifying node built today can become a self-validating executable
|
|
36
|
+
// tomorrow with nothing but `bundle sign --prefix`.
|
|
37
|
+
//
|
|
38
|
+
// ## The package rides in the blob as an archive
|
|
39
|
+
//
|
|
40
|
+
// The stub runs before anything is mounted, so it cannot import this package
|
|
41
|
+
// the ordinary way. It does not have to: `"useVfs": true` with
|
|
42
|
+
// `"vfsArchive": <bundle>` (nodejs/node#65675 and the `vfsArchive` that
|
|
43
|
+
// followed it) embeds a ZIP in the executable and mounts it as the file system
|
|
44
|
+
// the main script runs from. So the stub is three lines — require this
|
|
45
|
+
// package's launcher by a relative path and hand over — and the machinery that
|
|
46
|
+
// used to do it by hand, mounting a raw asset through a `ZipBuffer`, is gone.
|
|
47
|
+
//
|
|
48
|
+
// ## What runs before the check
|
|
49
|
+
//
|
|
50
|
+
// The stub and the verifier execute before any signature has been verified.
|
|
51
|
+
// That is not a hole so much as the place where the trust has to start: in a
|
|
52
|
+
// self-validating executable both live inside the prefix, which is inside the
|
|
53
|
+
// hashed region, so tampering with either invalidates the signature over the
|
|
54
|
+
// application — and an attacker who can rewrite the executable's own runtime
|
|
55
|
+
// could equally rewrite a verifier that ran first. The application never runs
|
|
56
|
+
// until the check passes.
|
|
57
|
+
|
|
58
|
+
// ------------------------------------------------------------------ runtime ---
|
|
59
|
+
|
|
60
|
+
// The runtime half moved to `./launch.ts`, where the verifying node's command
|
|
61
|
+
// line lives beside it. These are the names this module has always exported;
|
|
62
|
+
// they are kept because a container built by an older version of this package
|
|
63
|
+
// still calls them, and because "verify myself and run what is in me" reads
|
|
64
|
+
// better than "run the container that happens to be process.execPath".
|
|
65
|
+
|
|
66
|
+
export interface BootstrapOptions extends Baked {
|
|
67
|
+
/** The container to verify and mount (default: `process.execPath`). */
|
|
68
|
+
container?: string | undefined;
|
|
69
|
+
}
|
|
70
|
+
export type { Mounted };
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Verify the running container and mount the archive appended to it. Returns
|
|
74
|
+
* where it landed; nothing has been executed out of it yet.
|
|
75
|
+
*/
|
|
76
|
+
export function mountSelf(options: BootstrapOptions = {}): Mounted {
|
|
77
|
+
return mount(options.container ?? process.execPath, options);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Verify the running container, mount it, and run the application inside. */
|
|
81
|
+
export async function bootstrap(options: BootstrapOptions = {}): Promise<void> {
|
|
82
|
+
await start(mountSelf(options));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Verify the running container without mounting it — for an application that
|
|
87
|
+
* wants to report on its own provenance ("signed by X at Y").
|
|
88
|
+
*/
|
|
89
|
+
export function verifySelf(options: BootstrapOptions = {}): VerificationResult {
|
|
90
|
+
return verify(options.container ?? process.execPath, options);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// -------------------------------------------------------------------- build ---
|
|
94
|
+
|
|
95
|
+
/** The default flags the container runs itself with. */
|
|
96
|
+
export const SEA_EXEC_ARGV = ['--no-warnings', '--experimental-vfs'];
|
|
97
|
+
|
|
98
|
+
export interface VerifierOptions {
|
|
99
|
+
/**
|
|
100
|
+
* Include the sigstore libraries, so the container can check a
|
|
101
|
+
* sigstore-signed archive rather than degrading it to untrusted
|
|
102
|
+
* (default: true). Costs roughly a megabyte of dependency tree.
|
|
103
|
+
*/
|
|
104
|
+
sigstore?: boolean | undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface SeaBaseOptions extends VerifierOptions {
|
|
108
|
+
/** Where to write the base binary. */
|
|
109
|
+
output: string;
|
|
110
|
+
/**
|
|
111
|
+
* The node binary to embed (default: the running one). This is the runtime
|
|
112
|
+
* the finished container ships, so it decides which platform it runs on.
|
|
113
|
+
*/
|
|
114
|
+
node?: string | undefined;
|
|
115
|
+
/** Options baked into the stub and handed to `bootstrap()` at startup. */
|
|
116
|
+
bootstrap?: BootstrapOptions | undefined;
|
|
117
|
+
/** Runtime flags the container applies to itself. */
|
|
118
|
+
execArgv?: string[] | undefined;
|
|
119
|
+
/** A prebuilt verifier bundle to embed, instead of building one. */
|
|
120
|
+
verifier?: string | undefined;
|
|
121
|
+
/** Scratch directory for the generated stub and config. */
|
|
122
|
+
scratch?: string | undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface SeaOptions extends SeaBaseOptions {
|
|
126
|
+
/** The application archive to append. Signed or not; it is re-signed here. */
|
|
127
|
+
app: string;
|
|
128
|
+
/** A base built earlier, instead of building one now. */
|
|
129
|
+
base?: string | undefined;
|
|
130
|
+
/** Sign the finished container. Without one it is built but left unsigned. */
|
|
131
|
+
signer?: Signer | undefined;
|
|
132
|
+
hashAlg?: string | undefined;
|
|
133
|
+
signAlg?: string | undefined;
|
|
134
|
+
/** Progress, one line at a time. */
|
|
135
|
+
log?: ((line: string) => void) | undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface SeaBaseResult {
|
|
139
|
+
output: string;
|
|
140
|
+
size: number;
|
|
141
|
+
/** Members of the verifier bundle embedded in the blob. */
|
|
142
|
+
verifier: string[];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Build the SEA base: a node runtime whose injected main mounts this package
|
|
147
|
+
* out of its own blob and hands over to `bootstrap()`. The result is a binary
|
|
148
|
+
* with no application in it yet — append one with `buildSea` or with
|
|
149
|
+
* `sign --prefix`.
|
|
150
|
+
*/
|
|
151
|
+
export async function createSeaBase(options: SeaBaseOptions): Promise<SeaBaseResult> {
|
|
152
|
+
const scratch = options.scratch ?? FS.mkdtempSync(PATH.join(OS.tmpdir(), 'bundle-sea-'));
|
|
153
|
+
const owned = !options.scratch;
|
|
154
|
+
try {
|
|
155
|
+
let verifier = options.verifier;
|
|
156
|
+
let contents: string[];
|
|
157
|
+
if (verifier) {
|
|
158
|
+
contents = [];
|
|
159
|
+
} else {
|
|
160
|
+
verifier = PATH.join(scratch, 'verifier.bundle');
|
|
161
|
+
const files = verifierFiles(options);
|
|
162
|
+
await createBundle({ base: packageRoot(), files, output: verifier });
|
|
163
|
+
contents = files;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const stub = PATH.join(scratch, 'stub.js');
|
|
167
|
+
FS.writeFileSync(stub, stubSource(anchorPolicy(options.bootstrap ?? {})));
|
|
168
|
+
|
|
169
|
+
const config = PATH.join(scratch, 'sea-config.json');
|
|
170
|
+
FS.writeFileSync(config, `${JSON.stringify({
|
|
171
|
+
main: stub,
|
|
172
|
+
output: PATH.resolve(options.output),
|
|
173
|
+
disableExperimentalSEAWarning: true,
|
|
174
|
+
useSnapshot: false,
|
|
175
|
+
useCodeCache: false,
|
|
176
|
+
// The verifier bundle is the executable's file system rather than
|
|
177
|
+
// an asset it has to unpack: node embeds the ZIP as it is and
|
|
178
|
+
// mounts it, and the stub — injected at the root of that mount —
|
|
179
|
+
// requires this package out of it by relative path.
|
|
180
|
+
useVfs: true,
|
|
181
|
+
vfsArchive: verifier,
|
|
182
|
+
// The stub is injected at the root of that bundle, where this
|
|
183
|
+
// package's own `"type": "module"` decides what a `.js` is — so it
|
|
184
|
+
// is an ES module, and saying so is what makes it one. Note that
|
|
185
|
+
// `"mainFormat": "commonjs"` would not do the opposite: the mount's
|
|
186
|
+
// package.json still wins, and the only way to a CommonJS stub is a
|
|
187
|
+
// `.cjs` extension.
|
|
188
|
+
mainFormat: 'module',
|
|
189
|
+
execArgv: options.execArgv ?? SEA_EXEC_ARGV,
|
|
190
|
+
execArgvExtension: 'none',
|
|
191
|
+
...(options.node ? { executable: PATH.resolve(options.node) } : {}),
|
|
192
|
+
}, null, 2)}\n`);
|
|
193
|
+
|
|
194
|
+
const built = spawnSync(process.execPath, ['--no-warnings', '--build-sea', config],
|
|
195
|
+
{ stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' });
|
|
196
|
+
if (built.error) throw built.error;
|
|
197
|
+
if (built.status !== 0) {
|
|
198
|
+
throw new Error(`--build-sea failed (exit ${built.status}): ${(built.stderr || built.stdout || '').trim()}`);
|
|
199
|
+
}
|
|
200
|
+
FS.chmodSync(options.output, 0o755);
|
|
201
|
+
|
|
202
|
+
// `--build-sea` ignores configuration keys it does not know, so a node
|
|
203
|
+
// without `vfsArchive` would produce a binary that builds cleanly and
|
|
204
|
+
// fails at startup with nothing mounted. Run the result once: it costs
|
|
205
|
+
// milliseconds and it is the difference between finding that out here
|
|
206
|
+
// and finding it out in front of a user. A cross-build cannot be run,
|
|
207
|
+
// so it is not checked — the platform it is for is not this one.
|
|
208
|
+
if (!options.node) selftest(options.output);
|
|
209
|
+
|
|
210
|
+
return { output: options.output, size: FS.statSync(options.output).size, verifier: contents };
|
|
211
|
+
} finally {
|
|
212
|
+
if (owned) FS.rmSync(scratch, { recursive: true, force: true });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Build a self-validating executable: a SEA base with `app` appended and the
|
|
218
|
+
* whole thing signed as one file. Everything the container will check — the
|
|
219
|
+
* runtime, the verifier and the application — is inside what the signature
|
|
220
|
+
* covers.
|
|
221
|
+
*/
|
|
222
|
+
export async function buildSea(options: SeaOptions): Promise<BuildResult> {
|
|
223
|
+
const log = options.log ?? (() => {});
|
|
224
|
+
const scratch = options.scratch ?? FS.mkdtempSync(PATH.join(OS.tmpdir(), 'bundle-sea-'));
|
|
225
|
+
const owned = !options.scratch;
|
|
226
|
+
try {
|
|
227
|
+
let base = options.base;
|
|
228
|
+
if (!base) {
|
|
229
|
+
base = PATH.join(scratch, 'sea-base');
|
|
230
|
+
log('* building the SEA base (node runtime + verifier)');
|
|
231
|
+
const built = await createSeaBase({ ...options, output: base, scratch });
|
|
232
|
+
log(` base: ${built.size} bytes, ${built.verifier.length} verifier members`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
log(`* appending ${options.app} behind ${PATH.basename(base)}`);
|
|
236
|
+
const res = await signBundle({
|
|
237
|
+
source: options.app,
|
|
238
|
+
output: options.output,
|
|
239
|
+
prefix: base,
|
|
240
|
+
executable: true,
|
|
241
|
+
hashAlg: options.hashAlg,
|
|
242
|
+
signAlg: options.signAlg,
|
|
243
|
+
signer: options.signer,
|
|
244
|
+
});
|
|
245
|
+
log(res.signed ? `* signed: ${res.hash}` : '* built unsigned — it will refuse to run until it is signed');
|
|
246
|
+
return res;
|
|
247
|
+
} finally {
|
|
248
|
+
if (owned) FS.rmSync(scratch, { recursive: true, force: true });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The files the embedded verifier needs, as paths relative to this package's
|
|
254
|
+
* root. That is this package's own compiled modules plus, unless turned off,
|
|
255
|
+
* the sigstore libraries — resolved through `node_modules` rather than listed,
|
|
256
|
+
* so the set cannot fall behind the dependency tree.
|
|
257
|
+
*/
|
|
258
|
+
export function verifierFiles({ sigstore = true }: VerifierOptions = {}): string[] {
|
|
259
|
+
return moduleFiles({
|
|
260
|
+
base: packageRoot(),
|
|
261
|
+
files: ['package.json'],
|
|
262
|
+
dirs: [moduleDir()],
|
|
263
|
+
dependencies: sigstore ? SIGSTORE_PACKAGES : [],
|
|
264
|
+
// Source maps and declarations are for reading the code, not running
|
|
265
|
+
// it, and a verifier that ships inside every executable should carry
|
|
266
|
+
// only what it executes.
|
|
267
|
+
filter: (name) => !name.endsWith('.map') && !name.endsWith('.d.ts') && !name.endsWith('.d.cts'),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** The sigstore libraries verification needs; signing pulls in more at runtime. */
|
|
272
|
+
const SIGSTORE_PACKAGES = ['@sigstore/verify', '@sigstore/bundle', '@sigstore/protobuf-specs', '@sigstore/tuf'];
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Resolve the paths in a baked policy, because a policy is baked *here* and read
|
|
276
|
+
* *there*. `--root build/certs/root.pem` is a sentence about the directory the
|
|
277
|
+
* build ran in; the binary that carries it may be run anywhere, by anyone, and a
|
|
278
|
+
* trust root it cannot find is a container that refuses everything. PEM text
|
|
279
|
+
* passes through untouched — it is not a path and has no directory to be
|
|
280
|
+
* relative to.
|
|
281
|
+
*/
|
|
282
|
+
function anchorPolicy(options: BootstrapOptions): BootstrapOptions {
|
|
283
|
+
const anchored = { ...options };
|
|
284
|
+
if (options.roots) {
|
|
285
|
+
anchored.roots = options.roots.map((root) => (root.includes('-----BEGIN') ? root : PATH.resolve(root)));
|
|
286
|
+
}
|
|
287
|
+
if (options.trustedRoot) anchored.trustedRoot = PATH.resolve(options.trustedRoot);
|
|
288
|
+
return anchored;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Run the built binary once, to prove that the file system inside it mounts and
|
|
293
|
+
* this package can be required out of it. `--version` is the cheapest thing
|
|
294
|
+
* that touches all of that, and it works whichever shape the binary is.
|
|
295
|
+
*/
|
|
296
|
+
function selftest(output: string): void {
|
|
297
|
+
const res = spawnSync(PATH.resolve(output), ['--version'],
|
|
298
|
+
{ stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' });
|
|
299
|
+
if (res.error) throw res.error;
|
|
300
|
+
if (res.status !== 0 || !/bundle/.test(res.stdout)) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
`the built executable does not run (exit ${res.status}): ${(res.stderr || res.stdout || '').trim()}\n` +
|
|
303
|
+
'A node whose --build-sea does not understand "vfsArchive" builds exactly this: ' +
|
|
304
|
+
'the configuration key is ignored, nothing is mounted, and the stub has nothing to require.');
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The CommonJS stub injected as the SEA's main script. It runs at the root of
|
|
310
|
+
* the mounted verifier bundle, so requiring this package is a relative path and
|
|
311
|
+
* nothing more; everything else it needs to decide, `launch` decides by looking
|
|
312
|
+
* at the binary's own tail.
|
|
313
|
+
*
|
|
314
|
+
* It stays the one piece no test can exercise from source, so it stays small.
|
|
315
|
+
*/
|
|
316
|
+
export function stubSource(options: BootstrapOptions): string {
|
|
317
|
+
const dir = moduleDir();
|
|
318
|
+
const entry = `./${dir}/launch${dir === 'src' ? '.ts' : '.js'}`;
|
|
319
|
+
return `// Generated by @pipobscure/bundle. The SEA main, running at the root of the
|
|
320
|
+
// archive this executable carries: hand over to the launcher, which verifies
|
|
321
|
+
// either the archive appended to this file or the one named on the command
|
|
322
|
+
// line before running anything out of it.
|
|
323
|
+
import * as launch from ${JSON.stringify(entry)};
|
|
324
|
+
|
|
325
|
+
const OPTIONS = ${JSON.stringify(options, null, 2)};
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
// Only a binary with nothing behind it takes an archive from the command
|
|
329
|
+
// line. One with an *unsigned* archive appended is not a launcher with a
|
|
330
|
+
// stray tail — it is a container somebody forgot to sign, and saying so is
|
|
331
|
+
// worth more than falling back to a usage message.
|
|
332
|
+
const code = launch.appended(process.execPath) === 'none'
|
|
333
|
+
? await launch.main(process.argv.slice(2), OPTIONS)
|
|
334
|
+
: await launch.runSelf(OPTIONS);
|
|
335
|
+
if (typeof code === 'number' && code !== 0) process.exitCode = code;
|
|
336
|
+
} catch (err) {
|
|
337
|
+
process.stderr.write(\`\${err && err.stack || err}\\n\`);
|
|
338
|
+
process.exitCode = 1;
|
|
339
|
+
}
|
|
340
|
+
`;
|
|
341
|
+
}
|