@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/files.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import * as FS from 'node:fs';
|
|
2
|
+
import * as PATH from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
// Working out what belongs in an archive, for the cases where observing a run
|
|
6
|
+
// is not the right tool.
|
|
7
|
+
//
|
|
8
|
+
// `record` — run the application and write down what it read — is the primary
|
|
9
|
+
// answer and the honest one: it produces exactly the set that was used. It has
|
|
10
|
+
// one blind spot, and it is the one that matters for a *verifier*: code loaded
|
|
11
|
+
// lazily on a path the observation run never took. `sigstore.ts` requires
|
|
12
|
+
// `@sigstore/verify` only when it meets an archive with a `SIGSTORE=` field, so
|
|
13
|
+
// a build that signs with a local key never touches it — and the resulting
|
|
14
|
+
// bundle would then be unable to check a sigstore signature it later met.
|
|
15
|
+
//
|
|
16
|
+
// So the two are used together. A dependency closure computed from
|
|
17
|
+
// `node_modules` gives completeness, and the observation run is kept as the
|
|
18
|
+
// check: anything read that the computed list did not contain is a real gap,
|
|
19
|
+
// and the build says so.
|
|
20
|
+
|
|
21
|
+
export interface WalkOptions {
|
|
22
|
+
/** Directory names skipped wherever they appear (default: ['node_modules']). */
|
|
23
|
+
exclude?: string[] | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Every file under `dir`, as `/`-separated paths relative to it, sorted.
|
|
28
|
+
*
|
|
29
|
+
* A nested `node_modules` is skipped by default: its contents belong to the
|
|
30
|
+
* packages inside it, which `dependencyFiles` reaches through the dependency
|
|
31
|
+
* graph instead — so a hoisted tree and a nested one produce the same set.
|
|
32
|
+
*/
|
|
33
|
+
export function walk(dir: string, { exclude = ['node_modules'] }: WalkOptions = {}, prefix = ''): string[] {
|
|
34
|
+
const out: string[] = [];
|
|
35
|
+
for (const entry of FS.readdirSync(dir, { withFileTypes: true })) {
|
|
36
|
+
if (exclude.includes(entry.name)) continue;
|
|
37
|
+
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
38
|
+
if (entry.isDirectory()) out.push(...walk(PATH.join(dir, entry.name), { exclude }, relative));
|
|
39
|
+
else if (entry.isFile()) out.push(relative);
|
|
40
|
+
}
|
|
41
|
+
return out.sort();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Every file of `names` and of everything they depend on, as paths relative to
|
|
46
|
+
* `base`, sorted. Packages are located the way node locates them, so the answer
|
|
47
|
+
* describes the tree that is actually installed rather than what a lock file
|
|
48
|
+
* says should be.
|
|
49
|
+
*
|
|
50
|
+
* A dependency that is not installed under `base` — missing, or hoisted above
|
|
51
|
+
* it, where no member path can name it — is an error rather than a silent
|
|
52
|
+
* omission, because a bundle whose closure is incomplete is a bundle that
|
|
53
|
+
* fails at runtime on a machine other than the one that built it. Only an
|
|
54
|
+
* optional dependency may be absent.
|
|
55
|
+
*/
|
|
56
|
+
export function dependencyFiles(names: string[], base: string): string[] {
|
|
57
|
+
const root = PATH.resolve(base);
|
|
58
|
+
const files: string[] = [];
|
|
59
|
+
const seen = new Set<string>();
|
|
60
|
+
// Each dependency is looked up from the package that depends on it, not from
|
|
61
|
+
// the root: a tree can hold several versions of one package, and which one
|
|
62
|
+
// a `require` gets depends on where it is asked from.
|
|
63
|
+
const queue = names.map((name) => ({ name, from: root, optional: false }));
|
|
64
|
+
|
|
65
|
+
while (queue.length) {
|
|
66
|
+
const { name, from, optional } = queue.shift()!;
|
|
67
|
+
const dir = locate(name, from, root);
|
|
68
|
+
if (!dir) {
|
|
69
|
+
if (optional) continue; // optional, and not installed
|
|
70
|
+
throw new Error(`'${name}', needed by ${PATH.relative(root, from) || 'the root'}, is not installed ` +
|
|
71
|
+
`under ${root} — a bundle without it fails wherever the code that needs it runs`);
|
|
72
|
+
}
|
|
73
|
+
if (seen.has(dir)) continue;
|
|
74
|
+
seen.add(dir);
|
|
75
|
+
|
|
76
|
+
const relative = PATH.relative(root, dir);
|
|
77
|
+
for (const file of walk(dir)) files.push(posix(relative, file));
|
|
78
|
+
|
|
79
|
+
const manifest = JSON.parse(FS.readFileSync(PATH.join(dir, 'package.json'), 'utf-8')) as {
|
|
80
|
+
dependencies?: Record<string, string>;
|
|
81
|
+
optionalDependencies?: Record<string, string>;
|
|
82
|
+
};
|
|
83
|
+
const optionals = manifest.optionalDependencies ?? {};
|
|
84
|
+
for (const dep of Object.keys(manifest.dependencies ?? {})) {
|
|
85
|
+
queue.push({ name: dep, from: dir, optional: dep in optionals });
|
|
86
|
+
}
|
|
87
|
+
for (const dep of Object.keys(optionals)) queue.push({ name: dep, from: dir, optional: true });
|
|
88
|
+
}
|
|
89
|
+
return files.sort();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Where `name` is installed as seen from `from`: the nearest `node_modules/name`
|
|
93
|
+
// on the way up, the search node's resolver makes. It looks for the directory
|
|
94
|
+
// rather than resolving `name/package.json`, because a package whose `exports`
|
|
95
|
+
// does not list its manifest would refuse that — and be mistaken for missing.
|
|
96
|
+
// The search stops at `root`: a package above it cannot become a member of an
|
|
97
|
+
// archive rooted there, so finding one is the same as not finding one.
|
|
98
|
+
function locate(name: string, from: string, root: string): string | null {
|
|
99
|
+
for (let dir = from; ; dir = PATH.dirname(dir)) {
|
|
100
|
+
if (PATH.basename(dir) !== 'node_modules') {
|
|
101
|
+
const candidate = PATH.join(dir, 'node_modules', name);
|
|
102
|
+
if (FS.existsSync(PATH.join(candidate, 'package.json'))) return candidate;
|
|
103
|
+
}
|
|
104
|
+
if (dir === root || PATH.dirname(dir) === dir) return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface ModuleFilesOptions {
|
|
109
|
+
/** Directory the result is relative to. */
|
|
110
|
+
base: string;
|
|
111
|
+
/** Individual files to include, relative to `base`. */
|
|
112
|
+
files?: string[] | undefined;
|
|
113
|
+
/** Directories to include whole, relative to `base`. */
|
|
114
|
+
dirs?: string[] | undefined;
|
|
115
|
+
/** Package names whose dependency closure to include. */
|
|
116
|
+
dependencies?: string[] | undefined;
|
|
117
|
+
/** Keep only files matching one of these; a bare extension counts. */
|
|
118
|
+
filter?: ((name: string) => boolean) | undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The three sources above, combined and de-duplicated. */
|
|
122
|
+
export function moduleFiles({ base, files = [], dirs = [], dependencies = [], filter }: ModuleFilesOptions): string[] {
|
|
123
|
+
const all = [
|
|
124
|
+
...files,
|
|
125
|
+
...dirs.flatMap((dir) => walk(PATH.join(base, dir)).map((name) => posix(dir, name))),
|
|
126
|
+
...dependencyFiles(dependencies, base),
|
|
127
|
+
];
|
|
128
|
+
const kept = filter ? all.filter(filter) : all;
|
|
129
|
+
return [...new Set(kept)].sort();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The shell launcher this package ships: the prefix that turns an archive into
|
|
134
|
+
* a file you can run by name. `bundle sign --launcher` uses it, so nobody has
|
|
135
|
+
* to know it lives inside `node_modules`.
|
|
136
|
+
*/
|
|
137
|
+
export function launcherPath(): string {
|
|
138
|
+
return PATH.join(packageRoot(), 'shell-base');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** This package's own root — the directory its `package.json` sits in. */
|
|
142
|
+
export function packageRoot(): string {
|
|
143
|
+
return PATH.resolve(PATH.dirname(fileURLToPath(import.meta.url)), '..');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The directory this package's runnable modules are in: `dist` once compiled,
|
|
148
|
+
* `src` when the source is being run directly under node's type stripping.
|
|
149
|
+
*/
|
|
150
|
+
export function moduleDir(): string {
|
|
151
|
+
return PATH.basename(PATH.dirname(fileURLToPath(import.meta.url)));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function posix(dir: string, name: string): string {
|
|
155
|
+
return `${dir.split(PATH.sep).join('/')}/${name}`;
|
|
156
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// The package root: everything needed to build, sign, verify and run an archive
|
|
2
|
+
// from code, and nothing that needs a flag to import.
|
|
3
|
+
//
|
|
4
|
+
// The two VFS providers are deliberately *not* re-exported here. Importing
|
|
5
|
+
// either needs `node:vfs`, which only exists under `--experimental-vfs`, and
|
|
6
|
+
// creating or verifying an archive does not — so a plain `import
|
|
7
|
+
// '@pipobscure/bundle'` must not drag that requirement in. They have their own
|
|
8
|
+
// entry points:
|
|
9
|
+
//
|
|
10
|
+
// @pipobscure/bundle/provider the verifying provider, and register()
|
|
11
|
+
// @pipobscure/bundle/register a preload that registers it and nothing else
|
|
12
|
+
// @pipobscure/bundle/recorder the recording provider, and register()
|
|
13
|
+
// @pipobscure/bundle/record a preload that registers it and nothing else
|
|
14
|
+
|
|
15
|
+
// The high-level drive: create, sign, verify, inspect, run.
|
|
16
|
+
export {
|
|
17
|
+
createBundle,
|
|
18
|
+
signBundle,
|
|
19
|
+
verifyBundle,
|
|
20
|
+
verifyBundleSync,
|
|
21
|
+
inspectBundle,
|
|
22
|
+
runBundle,
|
|
23
|
+
fileSigner,
|
|
24
|
+
mountArgv,
|
|
25
|
+
registerPath,
|
|
26
|
+
type BuildResult,
|
|
27
|
+
type CreateOptions,
|
|
28
|
+
type SignOptions,
|
|
29
|
+
type VerifyBundleOptions,
|
|
30
|
+
type RunOptions,
|
|
31
|
+
type RunResult,
|
|
32
|
+
type Inspection,
|
|
33
|
+
} from './api.ts';
|
|
34
|
+
|
|
35
|
+
// The archive layer, for callers assembling members themselves.
|
|
36
|
+
export {
|
|
37
|
+
bundle,
|
|
38
|
+
rebundle,
|
|
39
|
+
createArchive,
|
|
40
|
+
keySigner,
|
|
41
|
+
members,
|
|
42
|
+
fromDirectory,
|
|
43
|
+
fromArchive,
|
|
44
|
+
type Member,
|
|
45
|
+
type Signer,
|
|
46
|
+
type Signature,
|
|
47
|
+
type EmitResult,
|
|
48
|
+
type BundleOptions,
|
|
49
|
+
type RebundleOptions,
|
|
50
|
+
} from './archive.ts';
|
|
51
|
+
|
|
52
|
+
// The format layer: the manifest, the signature marker, and verification.
|
|
53
|
+
export {
|
|
54
|
+
AUTHORITY,
|
|
55
|
+
buildManifest,
|
|
56
|
+
parseManifest,
|
|
57
|
+
parseSignature,
|
|
58
|
+
formatSignature,
|
|
59
|
+
signatureOf,
|
|
60
|
+
verify,
|
|
61
|
+
verifySync,
|
|
62
|
+
STATES,
|
|
63
|
+
type ArchiveSource,
|
|
64
|
+
type ManifestFields,
|
|
65
|
+
type SignatureMarker,
|
|
66
|
+
type VerificationResult,
|
|
67
|
+
type VerificationState,
|
|
68
|
+
type VerifyOptions,
|
|
69
|
+
} from './manifest.ts';
|
|
70
|
+
|
|
71
|
+
// Working out what belongs in an archive, for what observing a run cannot see.
|
|
72
|
+
export {
|
|
73
|
+
walk,
|
|
74
|
+
moduleFiles,
|
|
75
|
+
dependencyFiles,
|
|
76
|
+
launcherPath,
|
|
77
|
+
packageRoot,
|
|
78
|
+
moduleDir,
|
|
79
|
+
type WalkOptions,
|
|
80
|
+
type ModuleFilesOptions,
|
|
81
|
+
} from './files.ts';
|
|
82
|
+
|
|
83
|
+
// The audit gate — step 3 of building a bundle, and the thing that makes it a
|
|
84
|
+
// step rather than a suggestion.
|
|
85
|
+
export {
|
|
86
|
+
prepare as prepareAudit,
|
|
87
|
+
check as checkAudit,
|
|
88
|
+
approve as approveAudit,
|
|
89
|
+
verdictPath,
|
|
90
|
+
type Verdict,
|
|
91
|
+
type Finding,
|
|
92
|
+
type AuditOptions,
|
|
93
|
+
type Preparation,
|
|
94
|
+
} from './audit.ts';
|
|
95
|
+
|
|
96
|
+
// Installing the auditing skill into a project.
|
|
97
|
+
export {
|
|
98
|
+
skills,
|
|
99
|
+
skill,
|
|
100
|
+
install as installSkill,
|
|
101
|
+
DEFAULT_SKILLS_DIR,
|
|
102
|
+
type SkillInfo,
|
|
103
|
+
type InstallResult,
|
|
104
|
+
} from './skill.ts';
|
|
105
|
+
|
|
106
|
+
// The CLI, as a function — so a host can offer the same commands without
|
|
107
|
+
// spawning anything.
|
|
108
|
+
export { main as cli, USAGE, type Console as CliConsole } from './cli.ts';
|
|
109
|
+
|
|
110
|
+
// Signing through sigstore, and the OIDC flows that identity comes from. Both
|
|
111
|
+
// are namespaced: they are a signer implementation and its plumbing, not part
|
|
112
|
+
// of the archive format, and naming them that way keeps that visible.
|
|
113
|
+
export * as sigstore from './sigstore.ts';
|
|
114
|
+
export * as oidc from './oidc.ts';
|
package/src/launch.ts
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import * as VFS from 'node:vfs';
|
|
2
|
+
import * as FS from 'node:fs';
|
|
3
|
+
import * as PATH from 'node:path';
|
|
4
|
+
import * as ZLIB from 'node:zlib';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import { pathToFileURL } from 'node:url';
|
|
7
|
+
import { open as openBundle, type ProviderOptions } from './provider.ts';
|
|
8
|
+
import { message, signatureOf, verifySync, type VerificationResult } from './manifest.ts';
|
|
9
|
+
|
|
10
|
+
// Verifying a container, mounting it, and running what is inside — the one path
|
|
11
|
+
// every shape of this tool ends up taking, factored out of the SEA it used to
|
|
12
|
+
// live in.
|
|
13
|
+
//
|
|
14
|
+
// Three callers reach it, and the differences between them are smaller than
|
|
15
|
+
// they look:
|
|
16
|
+
//
|
|
17
|
+
// * **A self-validating executable.** The archive is appended to the running
|
|
18
|
+
// binary, so the container is `process.execPath` and the signature covers
|
|
19
|
+
// the runtime and the verifier along with the application.
|
|
20
|
+
// * **A verifying node.** The same binary with no archive appended: it takes
|
|
21
|
+
// one on its command line, checks it, and runs it —
|
|
22
|
+
// `node-verifying ./my-app.zip --some --app --args`. One runtime, any
|
|
23
|
+
// number of applications, none of them trusted until they verify.
|
|
24
|
+
// * **A library.** `run()` does the same thing in a process you already have.
|
|
25
|
+
//
|
|
26
|
+
// In every case the mount is the *verifying* provider rather than a plain
|
|
27
|
+
// `ZipProvider`, so this is not a signature check at startup and nothing more:
|
|
28
|
+
// every member is re-hashed against its signed digest as it is first read, for
|
|
29
|
+
// the whole life of the process.
|
|
30
|
+
|
|
31
|
+
/** What a caller can decide about a container before it is allowed to run. */
|
|
32
|
+
export interface LaunchOptions {
|
|
33
|
+
/** Extra trusted roots, as PEM text or paths to PEM files. */
|
|
34
|
+
roots?: string[] | undefined;
|
|
35
|
+
/** Require this sigstore signing identity. */
|
|
36
|
+
identity?: string | undefined;
|
|
37
|
+
/** Require this sigstore OIDC issuer. */
|
|
38
|
+
issuer?: string | undefined;
|
|
39
|
+
/** Path to the sigstore trust root to check against. */
|
|
40
|
+
trustedRoot?: string | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* Run a container whose signature is good but whose chain is not anchored
|
|
43
|
+
* in the trust store (default: false).
|
|
44
|
+
*/
|
|
45
|
+
allowUntrusted?: boolean | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* Recompute every member digest at mount rather than on first read
|
|
48
|
+
* (default: false — reads check them anyway, and this is startup latency).
|
|
49
|
+
*/
|
|
50
|
+
deep?: boolean | undefined;
|
|
51
|
+
/** Entry point inside the archive, overriding its package.json `main`. */
|
|
52
|
+
entry?: string | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* What to do when the container does not verify. The default prints the
|
|
55
|
+
* reason and exits 1; nothing from the archive has run at that point.
|
|
56
|
+
*/
|
|
57
|
+
onRefuse?: ((reason: string) => void) | undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Where a mounted container ended up, and what it is. */
|
|
61
|
+
export interface Mounted {
|
|
62
|
+
/** The generated mount point the archive is visible at. */
|
|
63
|
+
root: string;
|
|
64
|
+
/** The mount, so a caller can unmount it. */
|
|
65
|
+
vfs: VFS.VirtualFileSystem;
|
|
66
|
+
/** The resolved entry point, as an absolute path under `root`. */
|
|
67
|
+
entry: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Verify `container` and mount it. Returns where it landed; nothing has been
|
|
72
|
+
* executed out of it yet.
|
|
73
|
+
*/
|
|
74
|
+
export function mount(container: string, options: LaunchOptions = {}): Mounted {
|
|
75
|
+
const settings: ProviderOptions = {
|
|
76
|
+
roots: options.roots,
|
|
77
|
+
identity: options.identity,
|
|
78
|
+
issuer: options.issuer,
|
|
79
|
+
trustedRoot: options.trustedRoot,
|
|
80
|
+
allowUntrusted: options.allowUntrusted,
|
|
81
|
+
deep: options.deep,
|
|
82
|
+
name: 'bundle-launch',
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
let provider;
|
|
86
|
+
try {
|
|
87
|
+
provider = openBundle(container, settings);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
refuse(options, message(err));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const vfs = VFS.create(provider, { emitExperimentalWarning: false });
|
|
93
|
+
const root = vfs.mount();
|
|
94
|
+
return { root, vfs, entry: entryPoint(root, options.entry) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Run what is at a mount's entry point. A CommonJS entry is `require()`d and an
|
|
99
|
+
* ES module is `import()`ed, chosen the way node itself chooses — the archive's
|
|
100
|
+
* `package.json` `type` and the entry's own extension.
|
|
101
|
+
*/
|
|
102
|
+
export async function start({ root, entry }: Mounted): Promise<void> {
|
|
103
|
+
if (isModule(root, entry)) await import(pathToFileURL(entry).href);
|
|
104
|
+
else createRequire(PATH.join(root, 'package.json'))(entry);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Verify a container, mount it, and run the application inside. */
|
|
108
|
+
export async function run(container: string, options: LaunchOptions = {}): Promise<void> {
|
|
109
|
+
await start(mount(container, options));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Verify the running executable and run the archive appended to it — the
|
|
114
|
+
* self-validating shape, where the signature covers the runtime, the verifier
|
|
115
|
+
* and the application as one file.
|
|
116
|
+
*/
|
|
117
|
+
export async function runSelf(options: LaunchOptions = {}): Promise<void> {
|
|
118
|
+
await run(process.execPath, options);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Verify a container without mounting or running it. */
|
|
122
|
+
export function verify(container: string, options: LaunchOptions = {}): VerificationResult {
|
|
123
|
+
const roots = (options.roots ?? []).map((root) => (root.includes('-----BEGIN') ? root : FS.readFileSync(root, 'utf-8')));
|
|
124
|
+
return verifySync(container, {
|
|
125
|
+
extraRoots: roots, deep: options.deep ?? false,
|
|
126
|
+
identity: options.identity, issuer: options.issuer, trustedRoot: options.trustedRoot,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* What, if anything, is appended to a container.
|
|
132
|
+
*
|
|
133
|
+
* This is how one binary can be both shapes: an executable with a signed
|
|
134
|
+
* archive behind it runs *that*, and the same executable with nothing appended
|
|
135
|
+
* takes an archive from its command line instead. The discriminator is the
|
|
136
|
+
* signature marker rather than the presence of a ZIP, because a SEA carries an
|
|
137
|
+
* archive of its own inside its blob — one that is not at the tail, and never
|
|
138
|
+
* carries a marker.
|
|
139
|
+
*/
|
|
140
|
+
export function appended(container: string): 'signed' | 'unsigned' | 'none' {
|
|
141
|
+
if (signatureOf(container) !== null) return 'signed';
|
|
142
|
+
try {
|
|
143
|
+
// An archive at the tail with no marker is the one case worth naming:
|
|
144
|
+
// somebody appended an application and never signed it.
|
|
145
|
+
ZLIB.ZipFile.openSync(container).closeSync();
|
|
146
|
+
return 'unsigned';
|
|
147
|
+
} catch {
|
|
148
|
+
return 'none';
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// --------------------------------------------------------- the command line ---
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The command line of a verifying node.
|
|
156
|
+
*
|
|
157
|
+
* Options are read up to the first non-option argument, which is the archive;
|
|
158
|
+
* everything after it belongs to the application, untouched. That is the shape
|
|
159
|
+
* of every command that wraps another one — `env`, `nice`, `time` — and it is
|
|
160
|
+
* what lets an application have flags of its own that collide with these.
|
|
161
|
+
*/
|
|
162
|
+
export const USAGE = `usage: <runtime> [options] <archive> [args...]
|
|
163
|
+
|
|
164
|
+
Verifies an archive and runs the application inside it. Nothing from the archive
|
|
165
|
+
runs until its signature, its certificate chain and its member digests check out,
|
|
166
|
+
and every member is re-hashed as it is read for the life of the process.
|
|
167
|
+
|
|
168
|
+
options:
|
|
169
|
+
-r, --root <file> extra trusted root certificate (PEM); repeatable
|
|
170
|
+
--identity <san> require this sigstore signing identity
|
|
171
|
+
--issuer <url> require this sigstore OIDC issuer
|
|
172
|
+
--untrusted run an archive whose signature is good but unanchored
|
|
173
|
+
--deep check every member digest at mount, not on first read
|
|
174
|
+
--entry <path> entry point inside the archive, overriding its main
|
|
175
|
+
--verify report the archive's trust state and stop
|
|
176
|
+
-h, --help this
|
|
177
|
+
--version the verifier's version, and the runtime's
|
|
178
|
+
|
|
179
|
+
The same policy can come from the environment — BUNDLE_ROOTS, BUNDLE_IDENTITY,
|
|
180
|
+
BUNDLE_ISSUER, BUNDLE_SIGSTORE_ROOT, BUNDLE_ALLOW_UNTRUSTED — which is what a
|
|
181
|
+
container built with no policy of its own falls back to.
|
|
182
|
+
`;
|
|
183
|
+
|
|
184
|
+
/** Options baked into a runtime at build time, and whether they are the last word. */
|
|
185
|
+
export interface Baked extends LaunchOptions {
|
|
186
|
+
/**
|
|
187
|
+
* A runtime built with a policy of its own accepts no policy from its
|
|
188
|
+
* command line: a binary that demands a signing identity is not one whose
|
|
189
|
+
* user can ask it to stop. Adding a root, requiring a different identity
|
|
190
|
+
* and `--untrusted` are all refused. What remains — `--entry`, `--verify`,
|
|
191
|
+
* `--help` — cannot loosen anything.
|
|
192
|
+
*/
|
|
193
|
+
sealed?: boolean | undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Run a verifying node's command line. Returns the exit code; the application's
|
|
198
|
+
* own exit code is its business, set the ordinary way from inside it.
|
|
199
|
+
*/
|
|
200
|
+
export async function main(argv: string[], baked: Baked = {}): Promise<number> {
|
|
201
|
+
const flags: LaunchOptions & { roots: string[] } = { roots: [...(baked.roots ?? [])] };
|
|
202
|
+
let verifyOnly = false;
|
|
203
|
+
let i = 0;
|
|
204
|
+
|
|
205
|
+
const sealedRefusal = (flag: string): number => {
|
|
206
|
+
process.stderr.write(`${flag} is not accepted: this runtime was built with a policy of its own\n`);
|
|
207
|
+
return 64;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
for (; i < argv.length; i++) {
|
|
211
|
+
const arg = argv[i]!;
|
|
212
|
+
if (arg === '--') { i++; break; }
|
|
213
|
+
if (!arg.startsWith('-') || arg === '-') break;
|
|
214
|
+
|
|
215
|
+
// `--flag=value` and `--flag value` both, because both are written.
|
|
216
|
+
const eq = arg.indexOf('=');
|
|
217
|
+
const name = eq === -1 ? arg : arg.slice(0, eq);
|
|
218
|
+
const inline = eq === -1 ? undefined : arg.slice(eq + 1);
|
|
219
|
+
const value = (): string => {
|
|
220
|
+
const next = inline ?? argv[++i];
|
|
221
|
+
if (next === undefined) throw new Error(`${name} needs a value`);
|
|
222
|
+
return next;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
switch (name) {
|
|
226
|
+
case '-h': case '--help':
|
|
227
|
+
process.stdout.write(USAGE);
|
|
228
|
+
return 0;
|
|
229
|
+
case '--version':
|
|
230
|
+
process.stdout.write(`${version()} (node ${process.versions.node})\n`);
|
|
231
|
+
return 0;
|
|
232
|
+
case '--verify':
|
|
233
|
+
verifyOnly = true;
|
|
234
|
+
break;
|
|
235
|
+
case '--deep':
|
|
236
|
+
flags.deep = true;
|
|
237
|
+
break;
|
|
238
|
+
case '--entry':
|
|
239
|
+
flags.entry = value();
|
|
240
|
+
break;
|
|
241
|
+
case '-r': case '--root':
|
|
242
|
+
if (baked.sealed) return sealedRefusal(name);
|
|
243
|
+
flags.roots.push(value());
|
|
244
|
+
break;
|
|
245
|
+
case '--identity':
|
|
246
|
+
if (baked.sealed) return sealedRefusal(name);
|
|
247
|
+
flags.identity = value();
|
|
248
|
+
break;
|
|
249
|
+
case '--issuer':
|
|
250
|
+
if (baked.sealed) return sealedRefusal(name);
|
|
251
|
+
flags.issuer = value();
|
|
252
|
+
break;
|
|
253
|
+
case '--untrusted':
|
|
254
|
+
if (baked.sealed) return sealedRefusal(name);
|
|
255
|
+
flags.allowUntrusted = true;
|
|
256
|
+
break;
|
|
257
|
+
default:
|
|
258
|
+
process.stderr.write(`unknown option ${name}\n\n${USAGE}`);
|
|
259
|
+
return 64;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const archive = argv[i];
|
|
264
|
+
if (archive === undefined) {
|
|
265
|
+
process.stderr.write(`no archive to run\n\n${USAGE}`);
|
|
266
|
+
return 64;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const options: LaunchOptions = {
|
|
270
|
+
...baked,
|
|
271
|
+
...flags,
|
|
272
|
+
roots: flags.roots.length > 0 ? flags.roots : undefined,
|
|
273
|
+
};
|
|
274
|
+
const container = PATH.resolve(archive);
|
|
275
|
+
|
|
276
|
+
if (verifyOnly) {
|
|
277
|
+
const result = verify(container, options);
|
|
278
|
+
process.stdout.write(`${result.state.toUpperCase()} — ${result.reason}\n`);
|
|
279
|
+
if (result.identity) process.stdout.write(` identity: ${result.identity}\n`);
|
|
280
|
+
return result.state === 'valid' ? 0 : 1;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// The application sees the argv it would have had from `--vfs-load`: the
|
|
284
|
+
// archive's own path where a script path goes, and its arguments from
|
|
285
|
+
// index 2 on. The runtime's own options are gone by then — they were the
|
|
286
|
+
// runtime's, not the program's.
|
|
287
|
+
process.argv = [process.argv[0]!, container, ...argv.slice(i + 1)];
|
|
288
|
+
|
|
289
|
+
await run(container, options);
|
|
290
|
+
return 0;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** This package's version, read out of the mount the verifier is running from. */
|
|
294
|
+
function version(): string {
|
|
295
|
+
try {
|
|
296
|
+
const manifest = JSON.parse(
|
|
297
|
+
FS.readFileSync(PATH.join(import.meta.dirname, '..', 'package.json'), 'utf-8'),
|
|
298
|
+
) as { name?: string; version?: string };
|
|
299
|
+
return `${manifest.name ?? 'bundle'} ${manifest.version ?? '0.0.0'}`;
|
|
300
|
+
} catch {
|
|
301
|
+
return 'bundle';
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ------------------------------------------------------------------ details ---
|
|
306
|
+
|
|
307
|
+
// The archive's entry point: an explicit override, else its package.json
|
|
308
|
+
// `main`, else `index.js` — node's own order for a directory.
|
|
309
|
+
function entryPoint(root: string, override: string | undefined): string {
|
|
310
|
+
if (override) return PATH.resolve(root, override);
|
|
311
|
+
const manifest = readPackage(root);
|
|
312
|
+
return PATH.resolve(root, typeof manifest['main'] === 'string' ? manifest['main'] : 'index.js');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function isModule(root: string, entry: string): boolean {
|
|
316
|
+
if (entry.endsWith('.mjs')) return true;
|
|
317
|
+
if (entry.endsWith('.cjs')) return false;
|
|
318
|
+
return readPackage(root)['type'] === 'module';
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function readPackage(root: string): Record<string, unknown> {
|
|
322
|
+
try {
|
|
323
|
+
return JSON.parse(FS.readFileSync(PATH.join(root, 'package.json'), 'utf-8')) as Record<string, unknown>;
|
|
324
|
+
} catch {
|
|
325
|
+
return {};
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function refuse(options: LaunchOptions, reason: string): never {
|
|
330
|
+
if (options.onRefuse) {
|
|
331
|
+
options.onRefuse(reason);
|
|
332
|
+
throw new Error(reason);
|
|
333
|
+
}
|
|
334
|
+
process.stderr.write(`refusing to run: ${reason}\n`);
|
|
335
|
+
process.exit(1);
|
|
336
|
+
}
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { main } from './cli.ts';
|
|
3
|
+
|
|
4
|
+
// The executable entry point, in every launch mode this tool has:
|
|
5
|
+
//
|
|
6
|
+
// * `bundle …` / `node dist/main.js …` — an ordinary CLI.
|
|
7
|
+
// * `--vfs-load=<archive>` — node runs the mounted package's
|
|
8
|
+
// `main`, which is this file, out of the archive.
|
|
9
|
+
// * the SEA container, whose bootstrap mounts its own tail and then requires
|
|
10
|
+
// the package inside it, landing here.
|
|
11
|
+
//
|
|
12
|
+
// In all three, `process.argv` is [runtime, entry, ...userArgs], so the user's
|
|
13
|
+
// arguments always start at index 2.
|
|
14
|
+
//
|
|
15
|
+
// Nothing but argv handling lives here. `cli.ts` returns an exit code rather
|
|
16
|
+
// than exiting, so it stays callable in-process — by a test, or by a host that
|
|
17
|
+
// wants the commands without a subprocess — and the one place that turns a code
|
|
18
|
+
// into an exit is this file.
|
|
19
|
+
|
|
20
|
+
process.exitCode = await main(process.argv.slice(2));
|