@exodus/stasis 1.0.0-beta.1 → 1.0.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -2
- package/bin/stasis.js +173 -161
- package/package.json +11 -5
- package/src/apis/npm/semver.cjs +0 -2
- package/src/audit.js +161 -22
- package/src/babel-worker.cjs +65 -0
- package/src/babel.js +131 -0
- package/src/bundle-from-lockfile.js +12 -18
- package/src/cmd/build.js +93 -123
- package/src/cmd/bundle.js +470 -427
- package/src/cmd/diff.js +2 -19
- package/src/cmd/sbom.js +1 -7
- package/src/diff.js +61 -118
- package/src/loader-mock.js +8 -15
- package/src/loaders/bash.js +33 -81
- package/src/loaders/php.js +143 -282
- package/src/loaders/rust.js +37 -93
- package/src/loaders/solidity.js +27 -52
- package/src/metro-resolver.js +165 -0
- package/src/metro-transformer.js +3 -2
- package/src/mock.js +54 -160
- package/src/parse.js +4 -16
- package/src/sbom.js +37 -79
- package/src/scan.js +166 -124
- package/src/cmd/extract.js +0 -193
package/src/mock.js
CHANGED
|
@@ -1,52 +1,33 @@
|
|
|
1
|
-
// Imported by src/loader-mock.js (the `stasis run --mock` entry) after the
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// writes, child processes, worker threads, native addons, and the inspector at
|
|
7
|
-
// the runtime boundary. This file covers what `--permission` cannot: there is no
|
|
8
|
-
// `--allow-net`, so the network builtins are neutralized here in JS.
|
|
1
|
+
// Imported by src/loader-mock.js (the `stasis run --mock` entry) after the core hooks lib is
|
|
2
|
+
// evaluated but before its hooks are installed. Captures the import graph by running user code
|
|
3
|
+
// with side-effects denied. Pairs with Node's `--permission` (see bin/stasis.js), which denies fs
|
|
4
|
+
// writes/child procs/workers/addons/inspector at the runtime boundary; this file covers what
|
|
5
|
+
// `--permission` can't -- there is no `--allow-net`, so the network builtins are neutralized in JS.
|
|
9
6
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// it's a function on a denied module (factory, constructor, or helper),
|
|
14
|
-
// invoking it is denied. This also closes higher-level HTTP clients
|
|
15
|
-
// (axios/got/node-fetch/undici/ws) for free -- they all bottom out at
|
|
16
|
-
// node:net / node:tls / node:dns.
|
|
17
|
-
// - Anything we don't explicitly model still fails closed, because the call
|
|
18
|
-
// throws rather than silently succeeding.
|
|
7
|
+
// FAIL CLOSED, WHOLE MODULE: replace *every* callable on a denied builtin with a thrower, not a
|
|
8
|
+
// curated method list -- higher-level clients (axios/undici/ws) fall closed too since they bottom
|
|
9
|
+
// out at node:net/tls/dns, and anything unmodelled still throws rather than silently succeeding.
|
|
19
10
|
//
|
|
20
|
-
// Mechanism:
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
// ESM live-bindings track CJS mutation for builtins -- so we go through
|
|
25
|
-
// `createRequire` to grab the mutable object and let live bindings propagate to
|
|
26
|
-
// any importer that loads *after* us. Modules linked before this file runs keep
|
|
27
|
-
// their original bindings -- which is exactly why the loader's own state.js
|
|
28
|
-
// (node:fs/path/url/module/zlib/buffer, none of them network) keeps working.
|
|
11
|
+
// Mechanism: go through `createRequire` to grab the *mutable* CJS exports object -- ESM namespace
|
|
12
|
+
// objects freeze their bindings, so mocking them throws "Cannot redefine property". ESM live-bindings
|
|
13
|
+
// track CJS mutation for builtins, so importers loading *after* us see the mocks; modules linked
|
|
14
|
+
// before (the loader's own state.js) keep their real bindings.
|
|
29
15
|
|
|
30
16
|
import { mock } from 'node:test'
|
|
31
17
|
import { createRequire, syncBuiltinESMExports } from 'node:module'
|
|
32
18
|
|
|
33
19
|
const require = createRequire(import.meta.url)
|
|
34
20
|
|
|
35
|
-
// Force lazy
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
// syncBuiltinESMExports() below would otherwise trigger those getters as it
|
|
39
|
-
// materializes node:process's ESM wrapper, and the constructor would hit our
|
|
40
|
-
// mock and throw mid-setup. Reading the property once here caches the real
|
|
41
|
-
// Socket/TTY in Node's closure, so the later sync just reads the cached value.
|
|
21
|
+
// Force lazy init of process.std{in,out,err} before mocking net/tls: they're lazy getters that
|
|
22
|
+
// build a net.Socket/tty.WriteStream on first read, which syncBuiltinESMExports() below would
|
|
23
|
+
// otherwise trigger through our mock mid-setup. Reading once here caches the real Socket/TTY.
|
|
42
24
|
void process.stdin
|
|
43
25
|
void process.stdout
|
|
44
26
|
void process.stderr
|
|
45
27
|
|
|
46
28
|
const MARKER = '[stasis --mock]'
|
|
47
29
|
|
|
48
|
-
// Tagged so the unhandledRejection trap below can tell our denials
|
|
49
|
-
// genuine application bugs.
|
|
30
|
+
// Tagged (__stasisMock) so the unhandledRejection trap below can tell our denials from real bugs.
|
|
50
31
|
const blocked = (label) => {
|
|
51
32
|
const err = new Error(`${MARKER} ${label} is blocked while capturing imports`)
|
|
52
33
|
err.code = 'ERR_STASIS_MOCK_BLOCKED'
|
|
@@ -54,29 +35,18 @@ const blocked = (label) => {
|
|
|
54
35
|
return err
|
|
55
36
|
}
|
|
56
37
|
|
|
57
|
-
// A *regular* (constructable) function
|
|
58
|
-
// `new
|
|
59
|
-
// would make `new` fail with a confusing "is not a constructor" TypeError.
|
|
38
|
+
// A *regular* (constructable) function so both `mod.factory()` and `new mod.Constructor()` throw
|
|
39
|
+
// our error; an arrow would make `new` fail with a confusing "is not a constructor".
|
|
60
40
|
const denyFn = (label) => function () { throw blocked(label) }
|
|
61
41
|
|
|
62
|
-
// Promise-shaped version:
|
|
63
|
-
//
|
|
64
|
-
// `void fsPromises.writeFile(...)` then surfaces as an unhandledRejection
|
|
65
|
-
// (caught by the trap below) rather than crashing the script synchronously.
|
|
42
|
+
// Promise-shaped version: rejects instead of sync-throwing, so fire-and-forget callers surface via
|
|
43
|
+
// the unhandledRejection trap below rather than crashing the script synchronously.
|
|
66
44
|
const denyFnAsync = (label) => function () { return Promise.reject(blocked(label)) }
|
|
67
45
|
|
|
68
|
-
// Replace every own function on `mod` (and
|
|
69
|
-
//
|
|
70
|
-
// (
|
|
71
|
-
//
|
|
72
|
-
// synchronous throw -- *except* for class-shaped exports (PascalCase),
|
|
73
|
-
// because `new Class()` on a function that returns a Promise yields the
|
|
74
|
-
// Promise as the constructed value (per the `new` semantics), and the caller
|
|
75
|
-
// then sees a confused `r.resolve is not a function` instead of our
|
|
76
|
-
// attributable error. Sync-throw classes give the clean error in both
|
|
77
|
-
// `new dnsp.Resolver()` and `dns.lookup()` cases. Functions are configurable
|
|
78
|
-
// on the builtins we target; fall back to direct assignment on the off
|
|
79
|
-
// chance one isn't.
|
|
46
|
+
// Replace every own function on `mod` (and one level down on `mod.promises`) with a thrower.
|
|
47
|
+
// Promise-shaped surfaces use denyFnAsync so fire-and-forget callers reject quietly -- except
|
|
48
|
+
// class-shaped exports (PascalCase): `new Class()` returning a Promise yields the Promise as the
|
|
49
|
+
// constructed value, so classes sync-throw to give a clean error in both `new` and call form.
|
|
80
50
|
const isClassName = (key) => /^[A-Z]/.test(key)
|
|
81
51
|
const denyModuleSurface = (specifier, mod, opts = {}) => {
|
|
82
52
|
const { prefix = specifier, async: isAsync = specifier.endsWith('/promises') } = opts
|
|
@@ -96,29 +66,14 @@ const denyModuleSurface = (specifier, mod, opts = {}) => {
|
|
|
96
66
|
}
|
|
97
67
|
}
|
|
98
68
|
|
|
99
|
-
// Every callable on these builtins is replaced with a thrower.
|
|
100
|
-
// covers two categories:
|
|
101
|
-
//
|
|
102
|
-
// 1) Network surface (http/https/http2/net/dgram/tls/dns) -- the permission
|
|
103
|
-
// system has no --allow-net counterpart, so JS-mock is the *only* layer.
|
|
104
|
-
//
|
|
105
|
-
// 2) Process / worker surface (child_process/worker_threads/cluster/
|
|
106
|
-
// inspector) -- already denied at the kernel level by --permission (no
|
|
107
|
-
// --allow-child-process / --allow-worker / --allow-inspector and no
|
|
108
|
-
// --allow-addons). We duplicate the deny here on purpose: a JS-level
|
|
109
|
-
// thrower surfaces a clean, attributable ERR_STASIS_MOCK_BLOCKED instead
|
|
110
|
-
// of the kernel's ERR_ACCESS_DENIED, and it's defense in depth against
|
|
111
|
-
// anything that might quietly slip the permission rule (a future
|
|
112
|
-
// bypass, a misconfigured --allow flag, code that grabs primitives via
|
|
113
|
-
// process.binding before --permission's check fires, ...).
|
|
69
|
+
// Every callable on these builtins is replaced with a thrower.
|
|
114
70
|
const DENY_ALL = [
|
|
115
71
|
// Network surface (no --allow-net counterpart -- JS-mock is the only layer).
|
|
116
72
|
'node:http', 'node:https', 'node:http2',
|
|
117
73
|
'node:net', 'node:dgram', 'node:tls',
|
|
118
74
|
'node:dns', 'node:dns/promises',
|
|
119
|
-
// Process
|
|
120
|
-
//
|
|
121
|
-
// against a future --allow-* slip-up or a process.binding bypass.
|
|
75
|
+
// Process/worker/debug: already --permission-denied; duplicated for attributable errors and
|
|
76
|
+
// defense in depth against a future --allow-* slip-up or a process.binding bypass.
|
|
122
77
|
'node:child_process',
|
|
123
78
|
'node:worker_threads',
|
|
124
79
|
'node:cluster',
|
|
@@ -128,15 +83,9 @@ const DENY_ALL = [
|
|
|
128
83
|
'node:repl', // repl.start() opens an interactive stdin loop
|
|
129
84
|
'node:sqlite', // DatabaseSync can open/create SQLite files (Node 22+)
|
|
130
85
|
'node:trace_events', // createTracing() can write trace files
|
|
131
|
-
'node:v8', // writeHeapSnapshot bypasses node:fs
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
// it can't run under --mock, which is consistent with
|
|
135
|
-
// the "deny whole module" stance.
|
|
136
|
-
// node:wasi is *not* in this list: just require('node:wasi') emits an
|
|
137
|
-
// ExperimentalWarning to stderr on every --mock run, and --permission
|
|
138
|
-
// without --allow-wasi already denies WASI construction at the kernel
|
|
139
|
-
// level. The duplicate-deny isn't worth the warning noise.
|
|
86
|
+
'node:v8', // writeHeapSnapshot bypasses node:fs; setFlagsFromString mutates global V8 state
|
|
87
|
+
// node:wasi is deliberately omitted: require('node:wasi') emits an ExperimentalWarning, and
|
|
88
|
+
// --permission already denies WASI construction at the kernel level.
|
|
140
89
|
]
|
|
141
90
|
|
|
142
91
|
for (const specifier of DENY_ALL) {
|
|
@@ -149,24 +98,10 @@ for (const specifier of DENY_ALL) {
|
|
|
149
98
|
denyModuleSurface(specifier, mod)
|
|
150
99
|
}
|
|
151
100
|
|
|
152
|
-
// process method denials. process.exit / process.abort are deliberately left
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
// - chdir affects fs resolution for everything that runs after
|
|
157
|
-
// - kill signals other processes
|
|
158
|
-
// - umask affects every future file create
|
|
159
|
-
// - dlopen loads a native addon (--permission also denies)
|
|
160
|
-
// - binding returns Node's internal C++ bindings (--permission also denies);
|
|
161
|
-
// we duplicate to match the "process.binding bypass" claim in the header
|
|
162
|
-
// - setUid/setGid/setEuid/setEgid/setgroups/initgroups change privileges
|
|
163
|
-
// (--permission also denies these). Duplicated for attributable errors
|
|
164
|
-
// - send / disconnect talk to the parent over IPC (when the process was
|
|
165
|
-
// forked with stdio:'ipc'); not blocked by --permission
|
|
166
|
-
// - setSourceMapsEnabled / loadEnvFile mutate global Node state and (in
|
|
167
|
-
// loadEnvFile's case) read+mutate process.env from a file
|
|
168
|
-
// - setUncaughtExceptionCaptureCallback replaces Node's exception
|
|
169
|
-
// handling, which would also disable our exitCode=13 cleanup
|
|
101
|
+
// process method denials. process.exit / process.abort are deliberately left alone -- early
|
|
102
|
+
// termination isn't a side effect we should hide. The rest mutate global process state, do real
|
|
103
|
+
// I/O, or talk to a parent over IPC; note setUncaughtExceptionCaptureCallback would also disable
|
|
104
|
+
// our exitCode=13 cleanup below.
|
|
170
105
|
const PROCESS_DENY = [
|
|
171
106
|
'chdir', 'kill', 'umask', 'dlopen', 'binding',
|
|
172
107
|
'setUid', 'setGid', 'setEuid', 'setEgid', 'setgroups', 'initgroups',
|
|
@@ -180,24 +115,14 @@ for (const name of PROCESS_DENY) {
|
|
|
180
115
|
}
|
|
181
116
|
}
|
|
182
117
|
|
|
183
|
-
// process.report.writeReport
|
|
184
|
-
// (under --report-dir or cwd by default). Deny it so user code can't drop
|
|
185
|
-
// reports into the project tree. process.report is an object, present on all
|
|
186
|
-
// Node 12+ builds; the methods inside it are configurable.
|
|
118
|
+
// process.report.writeReport writes a diagnostic JSON file to disk; deny it too.
|
|
187
119
|
if (process.report && typeof process.report.writeReport === 'function') {
|
|
188
120
|
mock.method(process.report, 'writeReport', denyFn('process.report.writeReport()'))
|
|
189
121
|
}
|
|
190
122
|
|
|
191
|
-
// fs writers: --permission blocks
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
// --allow-fs-write list so stasis can emit its bundle/lockfile). Reads stay
|
|
195
|
-
// real so node_modules resolution and config discovery keep working. This
|
|
196
|
-
// works because loader-mock.js evaluates the core hooks lib *before* importing
|
|
197
|
-
// us, so stasis's own destructured `import { writeFileSync } from 'node:fs'`
|
|
198
|
-
// snapshots are already taken and it keeps real fs; the syncBuiltinESMExports()
|
|
199
|
-
// call below then refreshes the node:fs ESM wrapper so the user's subsequent
|
|
200
|
-
// ESM imports see the mocked names.
|
|
123
|
+
// fs writers: --permission blocks these at the kernel level too, but JS-mocking gives cleaner
|
|
124
|
+
// errors and stops user code mutating even cwd files (cwd is on --allow-fs-write for the bundle).
|
|
125
|
+
// Reads stay real so node_modules resolution keeps working.
|
|
201
126
|
const fs = require('node:fs')
|
|
202
127
|
const fsPromises = require('node:fs/promises')
|
|
203
128
|
const FS_WRITERS = [
|
|
@@ -221,25 +146,14 @@ for (const name of FS_WRITERS) {
|
|
|
221
146
|
}
|
|
222
147
|
}
|
|
223
148
|
|
|
224
|
-
// Refresh the node:fs
|
|
225
|
-
//
|
|
226
|
-
// Bindings already captured by stasis's own modules (linked before this file
|
|
227
|
-
// ran) keep their snapshot of the real functions.
|
|
149
|
+
// Refresh the node:fs ESM wrappers so ESM imports after this point see the mocked names;
|
|
150
|
+
// bindings stasis already captured (linked before this file ran) keep their real snapshot.
|
|
228
151
|
syncBuiltinESMExports()
|
|
229
152
|
|
|
230
|
-
// Global fetch / WebSocket / EventSource come from undici, not node:http, so
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
// writable + configurable, so assign directly.
|
|
235
|
-
//
|
|
236
|
-
// fetch returns a *forever-pending* promise rather than rejecting -- same
|
|
237
|
-
// "silently ignored" semantics as the promise-style timers. An awaiter hangs,
|
|
238
|
-
// fire-and-forget `void fetch(url)` quietly sits as a pending promise, and we
|
|
239
|
-
// don't need an unhandledRejection trap (no rejection is produced). The event
|
|
240
|
-
// loop is unaffected by a pending promise on its own, so the capture still
|
|
241
|
-
// drains and the bundle gets written on beforeExit. WebSocket/EventSource
|
|
242
|
-
// throw from their constructors so the synchronous shape is preserved.
|
|
153
|
+
// Global fetch / WebSocket / EventSource come from undici, not node:http, so handle separately.
|
|
154
|
+
// fetch returns a *forever-pending* promise (not a rejection) so an awaiter hangs and the capture
|
|
155
|
+
// still drains to write the bundle on beforeExit; WebSocket/EventSource throw from their
|
|
156
|
+
// constructors to preserve the synchronous shape.
|
|
243
157
|
if (typeof globalThis.fetch === 'function') {
|
|
244
158
|
globalThis.fetch = () => new Promise(() => {})
|
|
245
159
|
}
|
|
@@ -251,42 +165,23 @@ for (const name of ['WebSocket', 'EventSource']) {
|
|
|
251
165
|
}
|
|
252
166
|
}
|
|
253
167
|
|
|
254
|
-
// Our
|
|
255
|
-
//
|
|
256
|
-
// try-catch) would otherwise surface as unhandledRejection and tear the
|
|
257
|
-
// process down before the loader writes the bundle on beforeExit. Swallow
|
|
258
|
-
// *only* our own denials; rethrow everything else so real bugs still crash.
|
|
168
|
+
// Our denials become unhandledRejections for fire-and-forget callers, which would tear the process
|
|
169
|
+
// down before the loader writes the bundle. Swallow *only* our own denials; rethrow everything else.
|
|
259
170
|
process.on('unhandledRejection', (reason) => {
|
|
260
171
|
if (reason?.__stasisMock) return
|
|
261
172
|
throw reason
|
|
262
173
|
})
|
|
263
174
|
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
// flags by setting exitCode=13 at exit time. The bundle has already been
|
|
267
|
-
// written by then (state.write() ran at beforeExit, before this), so the
|
|
268
|
-
// capture itself succeeded -- override the 13 back to 0 so the CLI doesn't
|
|
269
|
-
// report failure. Anything other than 13 (real assertion failure, syntax
|
|
270
|
-
// error, ...) is preserved.
|
|
175
|
+
// Node 24+ sets exitCode=13 when a top-level await stays unsettled (e.g. an awaited denied
|
|
176
|
+
// promise). The bundle already wrote at beforeExit, so reset 13 -> 0; any other code is preserved.
|
|
271
177
|
process.on('exit', () => {
|
|
272
178
|
if (process.exitCode === 13) process.exitCode = 0
|
|
273
179
|
})
|
|
274
180
|
|
|
275
|
-
// Timers: silently ignored. Callback
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
// setTimeout(...)` suspends forever and any code past the await never runs.
|
|
280
|
-
// That's the "silently ignored" semantics for promise timers -- the awaiter
|
|
281
|
-
// is dropped, not given a synthetic value. The process still exits cleanly
|
|
282
|
-
// because a pending promise on its own doesn't keep the event loop alive, so
|
|
283
|
-
// once everything else drains, Node tears down (logging an unsettled-
|
|
284
|
-
// top-level-await warning) and the loader's beforeExit hook writes the
|
|
285
|
-
// bundle.
|
|
286
|
-
//
|
|
287
|
-
// Stasis itself does not import node:timers anywhere, so unlike node:fs the
|
|
288
|
-
// ESM wrapper for node:timers/timers-promises is not pre-materialized; the
|
|
289
|
-
// CJS exports mutation below propagates to the user's first ESM import.
|
|
181
|
+
// Timers: silently ignored. Callback timers (setTimeout/setInterval/setImmediate) become no-ops;
|
|
182
|
+
// promise timers (node:timers/promises) return *forever-pending* promises so `await setTimeout()`
|
|
183
|
+
// suspends forever. A pending promise doesn't keep the event loop alive, so the process still
|
|
184
|
+
// exits cleanly and the loader's beforeExit hook writes the bundle.
|
|
290
185
|
const noop = () => undefined
|
|
291
186
|
const pending = () => new Promise(() => {})
|
|
292
187
|
for (const name of ['setTimeout', 'setInterval', 'setImmediate']) globalThis[name] = noop
|
|
@@ -300,9 +195,8 @@ for (const name of ['setTimeout', 'setInterval', 'setImmediate', 'clearTimeout',
|
|
|
300
195
|
const timersPromises = require('node:timers/promises')
|
|
301
196
|
mock.method(timersPromises, 'setTimeout', pending)
|
|
302
197
|
mock.method(timersPromises, 'setImmediate', pending)
|
|
303
|
-
// setInterval returns an async iterable; each `await iter.next()` is
|
|
304
|
-
//
|
|
305
|
-
// at the first iteration -- the loop body never runs.
|
|
198
|
+
// setInterval returns an async iterable; each `await iter.next()` is forever-pending, so
|
|
199
|
+
// `for await (const _ of setInterval(...))` hangs at the first iteration.
|
|
306
200
|
mock.method(timersPromises, 'setInterval', () => ({
|
|
307
201
|
[Symbol.asyncIterator]() {
|
|
308
202
|
return { next: pending }
|
package/src/parse.js
CHANGED
|
@@ -4,18 +4,9 @@ import { brotliDecompressSync } from 'node:zlib'
|
|
|
4
4
|
import { Bundle } from '@exodus/stasis-core/bundle'
|
|
5
5
|
import { Lockfile } from '@exodus/stasis-core/lockfile'
|
|
6
6
|
|
|
7
|
-
// Read a stasis artifact
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// 'bundle' -- a `stasis.code.br` (one unified format holding both code and
|
|
11
|
-
// resources; per-file encoding is read from its `formats` map)
|
|
12
|
-
// The bundle is one format: code files hash as UTF-8 text and 'resource:base64'
|
|
13
|
-
// files hash as their decoded bytes, decided per-file by `stasis diff` from the
|
|
14
|
-
// bundle's formats rather than from a whole-bundle kind.
|
|
15
|
-
//
|
|
16
|
-
// Lockfiles are JSON text starting with `{`; bundles are brotli-compressed JSON.
|
|
17
|
-
// Sniff the leading byte to route to the right parser so its diagnostic surfaces
|
|
18
|
-
// on failure.
|
|
7
|
+
// Read a stasis artifact and parse it into a Lockfile or Bundle, reporting its `kind`
|
|
8
|
+
// ('lockfile' | 'bundle'). Lockfiles are JSON starting with `{`; bundles are brotli-compressed
|
|
9
|
+
// JSON — sniff the leading byte to route to the right parser so its diagnostic surfaces on failure.
|
|
19
10
|
export function parseFileWithKind(file) {
|
|
20
11
|
let buf
|
|
21
12
|
try {
|
|
@@ -44,10 +35,7 @@ export function parseFileWithKind(file) {
|
|
|
44
35
|
}
|
|
45
36
|
}
|
|
46
37
|
|
|
47
|
-
//
|
|
48
|
-
// Shared by `stasis audit` and `stasis sbom`, which both inventory the packages
|
|
49
|
-
// a lockfile/bundle records and don't care which kind it is. Thin wrapper over
|
|
50
|
-
// `parseFileWithKind` that drops the `kind`.
|
|
38
|
+
// Thin wrapper over parseFileWithKind that drops the `kind` (for callers like audit/sbom).
|
|
51
39
|
export function parseFile(file) {
|
|
52
40
|
return parseFileWithKind(file).artifact
|
|
53
41
|
}
|
package/src/sbom.js
CHANGED
|
@@ -1,33 +1,21 @@
|
|
|
1
1
|
import semver from './apis/npm/semver.cjs'
|
|
2
2
|
import pkg from '../package.json' with { type: 'json' }
|
|
3
3
|
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// instances). This module is deliberately free of file/transport concerns: it
|
|
7
|
-
// never reads disk and never touches brotli (`node:zlib`), so it can run
|
|
8
|
-
// wherever an artifact instance can be handed to it. The CLI glue that reads
|
|
9
|
-
// `stasis.code.br`/`stasis.lock.json` off disk (and so pulls in brotli) lives
|
|
10
|
-
// in `src/cmd/sbom.js`.
|
|
4
|
+
// Derive an SPDX 2.3 or CycloneDX 1.5 SBOM from already-parsed stasis artifacts (`Bundle`/`Lockfile`).
|
|
5
|
+
// Deliberately free of file/transport concerns (no disk, no brotli); the disk-reading CLI glue is src/cmd/sbom.js.
|
|
11
6
|
|
|
12
|
-
//
|
|
13
|
-
// name is the published package name, per the command's spec.
|
|
7
|
+
// Tool/creator identity stamped into every generated SBOM.
|
|
14
8
|
const DEFAULT_TOOL = { name: '@exodus/stasis', version: pkg.version, vendor: 'Exodus Movement, Inc.' }
|
|
15
9
|
|
|
16
|
-
//
|
|
17
|
-
// in modern Node, keeping this module dependency-light.
|
|
10
|
+
// Web Crypto (global) instead of node:crypto, keeping this module dependency-light.
|
|
18
11
|
const newUuid = () => globalThis.crypto.randomUUID()
|
|
19
12
|
|
|
20
|
-
// purl
|
|
21
|
-
// no registered purl type (`soldeer`) gets no purl — better than a fabricated
|
|
22
|
-
// one — while npm/composer/cargo/github all map to official types.
|
|
13
|
+
// purl types we can mint; an ecosystem with no registered type (`soldeer`) gets no purl rather than a fabricated one.
|
|
23
14
|
const PURL_TYPES = new Set(['npm', 'composer', 'cargo', 'github'])
|
|
24
15
|
|
|
25
|
-
// Infer
|
|
26
|
-
// first-party
|
|
27
|
-
// `ecosystem` field
|
|
28
|
-
// field entirely. PHP bundles tag files with the `php` format and map to
|
|
29
|
-
// Composer; everything else (JS/TS, and the Solidity/Rust/Bash source bundles,
|
|
30
|
-
// which all bucket by `package.json`) uses npm-style names.
|
|
16
|
+
// Infer an artifact's ecosystem from its loader formats: PHP bundles map to Composer, everything
|
|
17
|
+
// else to npm. Used for untagged (first-party) buckets and as the fallback for legacy artifacts
|
|
18
|
+
// predating the per-dependency `ecosystem` field.
|
|
31
19
|
function detectEcosystem(artifact) {
|
|
32
20
|
const formats = artifact.formats
|
|
33
21
|
if (formats && typeof formats.values === 'function') {
|
|
@@ -38,19 +26,16 @@ function detectEcosystem(artifact) {
|
|
|
38
26
|
return 'npm'
|
|
39
27
|
}
|
|
40
28
|
|
|
41
|
-
// Legacy scope classification
|
|
42
|
-
//
|
|
43
|
-
// first-party, except in a Composer bundle where vendor packages live outside
|
|
44
|
-
// `node_modules`.
|
|
29
|
+
// Legacy scope classification (pre-`ecosystem` artifacts): non-`node_modules` dirs are first-party,
|
|
30
|
+
// except in a Composer bundle where vendor packages live outside `node_modules`.
|
|
45
31
|
function classifyScope(ecosystem, dir) {
|
|
46
32
|
if (dir === '.') return 'workspace'
|
|
47
33
|
if (ecosystem === 'composer') return 'dependency'
|
|
48
34
|
return dir.includes('node_modules') ? 'dependency' : 'workspace'
|
|
49
35
|
}
|
|
50
36
|
|
|
51
|
-
// Split a package name into
|
|
52
|
-
//
|
|
53
|
-
// the segment before the first slash; cargo crates and bare names have none.
|
|
37
|
+
// Split a package name into purl/CycloneDX namespace (group) + bare name: npm scopes, Composer
|
|
38
|
+
// vendors and GitHub owners use the segment before the first slash; cargo and bare names have none.
|
|
54
39
|
function splitName(ecosystem, name) {
|
|
55
40
|
const namespaced = ecosystem === 'composer' || ecosystem === 'github' ||
|
|
56
41
|
(ecosystem === 'npm' && name.startsWith('@'))
|
|
@@ -61,12 +46,9 @@ function splitName(ecosystem, name) {
|
|
|
61
46
|
return { group: undefined, name }
|
|
62
47
|
}
|
|
63
48
|
|
|
64
|
-
// Build a Package URL (purl)
|
|
65
|
-
//
|
|
66
|
-
// the
|
|
67
|
-
// npm scope's `@` becomes `%40`), while the `/` namespace separator and the
|
|
68
|
-
// `@` before the version stay literal, per the purl spec. Composer and GitHub
|
|
69
|
-
// names are case-insensitive, so they're lowercased as the spec requires.
|
|
49
|
+
// Build a Package URL (purl) for a known ecosystem, or null when it has no purl type. Each path
|
|
50
|
+
// segment is percent-encoded (npm scope's `@` -> `%40`) while the `/` namespace separator and the
|
|
51
|
+
// `@` before the version stay literal; Composer and GitHub names are lowercased, per the purl spec.
|
|
70
52
|
export function buildPurl(ecosystem, rawName, version) {
|
|
71
53
|
if (!PURL_TYPES.has(ecosystem)) return null
|
|
72
54
|
let { group, name } = splitName(ecosystem, rawName)
|
|
@@ -87,27 +69,17 @@ function compareComponents(a, b) {
|
|
|
87
69
|
return a.ecosystem < b.ecosystem ? -1 : a.ecosystem > b.ecosystem ? 1 : 0
|
|
88
70
|
}
|
|
89
71
|
|
|
90
|
-
// Collect the deduplicated, sorted set of packages
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
// the formatters can pick a primary component. Buckets without a name+version
|
|
96
|
-
// (legacy v0 bundles record neither) are skipped: there is nothing to attest.
|
|
97
|
-
//
|
|
98
|
-
// Each dependency's `ecosystem` (npm/composer/cargo/github/soldeer) comes from
|
|
99
|
-
// the per-bucket field newer artifacts record; older artifacts predate it, so
|
|
100
|
-
// we fall back to inferring it from the file's loader formats and install path.
|
|
101
|
-
// Dedup is by ecosystem+name+version; a package seen as first-party in any
|
|
102
|
-
// artifact stays workspace regardless of argument order, so the output doesn't
|
|
103
|
-
// depend on how the artifacts are listed.
|
|
72
|
+
// Collect the deduplicated, sorted set of packages across parsed artifacts. Unlike `stasis audit`
|
|
73
|
+
// (installed deps only), an SBOM is the *full* bill of materials, so workspace/first-party packages
|
|
74
|
+
// are included too, tagged scope 'workspace' vs 'dependency'. Buckets without a name+version are
|
|
75
|
+
// skipped. Dedup is by ecosystem+name+version; a package seen as workspace in any artifact stays
|
|
76
|
+
// workspace regardless of argument order, so output doesn't depend on how artifacts are listed.
|
|
104
77
|
export function collectComponents(artifacts) {
|
|
105
78
|
const seen = new Map()
|
|
106
79
|
for (const artifact of artifacts) {
|
|
107
80
|
const records = [...artifact.modules]
|
|
108
|
-
// The per-dependency `ecosystem` field
|
|
109
|
-
//
|
|
110
|
-
// predates the field and we use the format/path heuristic for everything.
|
|
81
|
+
// The per-dependency `ecosystem` field separates deps (tagged) from first-party buckets (untagged);
|
|
82
|
+
// if no bucket carries it, the artifact predates the field and we use the heuristic for everything.
|
|
111
83
|
const tagged = records.some(([, m]) => m.ecosystem !== undefined)
|
|
112
84
|
const fileEcosystem = detectEcosystem(artifact)
|
|
113
85
|
for (const [dir, { name, version, ecosystem }] of records) {
|
|
@@ -117,8 +89,7 @@ export function collectComponents(artifacts) {
|
|
|
117
89
|
scope = 'dependency'
|
|
118
90
|
eco = ecosystem
|
|
119
91
|
} else if (tagged) {
|
|
120
|
-
// Untagged bucket in a modern artifact: first-party workspace code.
|
|
121
|
-
// purl ecosystem is inferred (npm, or composer for a PHP project).
|
|
92
|
+
// Untagged bucket in a modern artifact: first-party workspace code; ecosystem inferred.
|
|
122
93
|
scope = 'workspace'
|
|
123
94
|
eco = fileEcosystem
|
|
124
95
|
} else {
|
|
@@ -137,31 +108,25 @@ export function collectComponents(artifacts) {
|
|
|
137
108
|
return [...seen.values()].toSorted(compareComponents)
|
|
138
109
|
}
|
|
139
110
|
|
|
140
|
-
// Pick the single component the SBOM is *about* (its primary/root)
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
// packages) the BOM has no single subject and every package is listed as a
|
|
144
|
-
// plain component. `rest` is everything that is not the primary.
|
|
111
|
+
// Pick the single component the SBOM is *about* (its primary/root): the lone workspace package when
|
|
112
|
+
// there is exactly one; with zero or several workspace roots there's no single subject, so every
|
|
113
|
+
// package is a plain component. `rest` is everything that is not the primary.
|
|
145
114
|
function selectPrimary(components) {
|
|
146
115
|
const workspace = components.filter((c) => c.scope === 'workspace')
|
|
147
116
|
const primary = workspace.length === 1 ? workspace[0] : null
|
|
148
117
|
return { primary, rest: primary ? components.filter((c) => c !== primary) : components }
|
|
149
118
|
}
|
|
150
119
|
|
|
151
|
-
// SBOM timestamps are RFC 3339
|
|
152
|
-
// `YYYY-MM-DDThh:mm:ssZ`); drop the milliseconds Date.toISOString includes.
|
|
120
|
+
// SBOM timestamps are RFC 3339 to whole seconds; drop the milliseconds Date.toISOString includes.
|
|
153
121
|
const isoSeconds = (date) => date.toISOString().replace(/\.\d{3}Z$/u, 'Z')
|
|
154
122
|
|
|
155
|
-
// A CycloneDX bom-ref must uniquely identify a component
|
|
156
|
-
//
|
|
157
|
-
// construction (it is the dedup key) otherwise.
|
|
123
|
+
// A CycloneDX bom-ref must uniquely identify a component; use the purl when present, else
|
|
124
|
+
// ecosystem+name+version (unique by construction -- it's the dedup key).
|
|
158
125
|
const bomRef = (c) => c.purl ?? `${c.ecosystem}:${c.name}@${c.version}`
|
|
159
126
|
|
|
160
|
-
// Render an SPDX 2.3 document
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
// transitive dependency of the root). With no single primary the document
|
|
164
|
-
// DESCRIBES the workspace packages, or every package when there are none.
|
|
127
|
+
// Render an SPDX 2.3 document. It DESCRIBES its primary component, which DEPENDS_ON each installed
|
|
128
|
+
// dependency (flat graph). With no single primary, the document DESCRIBES the workspace packages,
|
|
129
|
+
// or every package when there are none.
|
|
165
130
|
export function toSpdx(components, { tool = DEFAULT_TOOL, now = new Date(), uuid = newUuid() } = {}) {
|
|
166
131
|
const { primary, rest } = selectPrimary(components)
|
|
167
132
|
const ordered = primary ? [primary, ...rest] : components
|
|
@@ -210,10 +175,8 @@ export function toSpdx(components, { tool = DEFAULT_TOOL, now = new Date(), uuid
|
|
|
210
175
|
}
|
|
211
176
|
}
|
|
212
177
|
|
|
213
|
-
// Render a CycloneDX 1.5 document
|
|
214
|
-
// components
|
|
215
|
-
// rest are listed in `components`, with a flat `dependencies` edge from the
|
|
216
|
-
// primary to each installed dependency.
|
|
178
|
+
// Render a CycloneDX 1.5 document. The primary (workspace root) becomes metadata.component; the
|
|
179
|
+
// rest go in `components`, with a flat `dependencies` edge from primary to each installed dependency.
|
|
217
180
|
export function toCyclonedx(components, { tool = DEFAULT_TOOL, now = new Date(), uuid = newUuid() } = {}) {
|
|
218
181
|
const { primary, rest } = selectPrimary(components)
|
|
219
182
|
const toComponent = (c) => {
|
|
@@ -235,8 +198,7 @@ export function toCyclonedx(components, { tool = DEFAULT_TOOL, now = new Date(),
|
|
|
235
198
|
version: 1,
|
|
236
199
|
metadata: {
|
|
237
200
|
timestamp: isoSeconds(now),
|
|
238
|
-
// CycloneDX 1.5 `tools.components` shape: the flat tools array is
|
|
239
|
-
// deprecated in 1.5 (removed in 1.6), and newer consumers read this form.
|
|
201
|
+
// CycloneDX 1.5 `tools.components` shape: the flat tools array is deprecated in 1.5 (removed in 1.6).
|
|
240
202
|
tools: { components: [{ type: 'application', publisher: tool.vendor, name: tool.name, version: tool.version }] },
|
|
241
203
|
...(primary && { component: toComponent(primary) }),
|
|
242
204
|
},
|
|
@@ -249,18 +211,14 @@ export function toCyclonedx(components, { tool = DEFAULT_TOOL, now = new Date(),
|
|
|
249
211
|
return doc
|
|
250
212
|
}
|
|
251
213
|
|
|
252
|
-
// Dispatch to the requested format
|
|
253
|
-
// `collectComponents`; `options` accepts `{ tool, now, uuid }` for custom
|
|
254
|
-
// tool identity / deterministic output.
|
|
214
|
+
// Dispatch to the requested format; options: { tool, now, uuid } for custom identity / deterministic output.
|
|
255
215
|
export function generateSbom(format, components, options) {
|
|
256
216
|
if (format === 'spdx') return toSpdx(components, options)
|
|
257
217
|
if (format === 'cyclonedx') return toCyclonedx(components, options)
|
|
258
218
|
throw new Error(`sbom: format must be spdx or cyclonedx, got ${format}`)
|
|
259
219
|
}
|
|
260
220
|
|
|
261
|
-
// Convenience one-shot:
|
|
262
|
-
// render them in `format`. Equivalent to
|
|
263
|
-
// `generateSbom(format, collectComponents(artifacts), options)`.
|
|
221
|
+
// Convenience one-shot: collectComponents then generateSbom.
|
|
264
222
|
export function sbom(format, artifacts, options) {
|
|
265
223
|
return generateSbom(format, collectComponents(artifacts), options)
|
|
266
224
|
}
|