@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/src/mock.js CHANGED
@@ -1,52 +1,33 @@
1
- // Imported by src/loader-mock.js (the `stasis run --mock` entry) after the
2
- // @exodus/stasis-core hooks lib is evaluated but before its hooks are installed.
3
- // The goal is to capture the import graph for a bundle by actually running user
4
- // code, but with outbound side-effects denied. Pair with Node's `--permission`
5
- // flag (see bin/stasis.js when run with `--mock`), which fail-closed-denies fs
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
- // Design: FAIL CLOSED, WHOLE MODULE.
11
- // - We replace *every* callable on each network builtin with a thrower, not a
12
- // curated method list. There is no "I forgot net.Socket#connect" gap: if
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: node:test's MockTracker can only redefine *configurable*
21
- // properties. ESM namespace objects (`import * as net from 'node:net'`) freeze
22
- // their bindings, so mocking the ESM namespace throws "Cannot redefine
23
- // property". The CommonJS exports object for the same builtin is mutable, and
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 initialization of process.stdin/stdout/stderr *before* we mock
36
- // the net/tls constructors. Node's process.std{in,out,err} are lazy getters
37
- // that construct an underlying net.Socket (or tty.WriteStream) on first read;
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 apart from
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, so that both `mod.factory()` and
58
- // `new mod.Constructor()` throw our attributable error -- an arrow function
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: returns a rejected promise instead of sync-throwing.
63
- // Used for node:fs/promises, node:dns/promises, and similar. Fire-and-forget
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, one level down, on `mod.promises`)
69
- // with a thrower. The factory picks denyFnAsync for promise-shaped surfaces
70
- // (/promises submodules) so fire-and-forget callers reject quietly through
71
- // the unhandledRejection trap below rather than crashing the script with a
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. The list
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 / worker / debug. Already kernel-denied by --permission; we
120
- // duplicate the deny here for attributable errors and defense in depth
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 (goes through V8);
132
- // setFlagsFromString mutates global V8 state. Serialize/
133
- // deserialize are blocked too -- if a package needs them
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
- // alone -- if user code wants to terminate early, let it; that's not a side
154
- // effect we should hide. The rest mutate global process state, do real I/O,
155
- // or talk to a parent over IPC:
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(filename) writes a diagnostic JSON file to disk
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 them at the kernel level too, but we mock
192
- // at the JS layer for cleaner attributable errors and so that user code can't
193
- // even *try* to mutate files inside the project's cwd (which is on the
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 / node:fs/promises ESM wrappers so destructured/
225
- // namespace ESM imports made *after* this point see the mocked names.
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
- // they need separate handling. (Their transport bottoms out at node:net, which
232
- // is already blocked above -- but intercepting here yields a clean, attributable
233
- // error instead of a confusing low-level socket failure.) These globals are
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 promise-shaped mocks (fs/promises, dns/promises, ...) sync-throw, which
255
- // `await` converts into a rejection. A fire-and-forget caller (no .catch / no
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
- // User code that awaits a denied promise (a forever-pending timer, an
265
- // undelivered fetch) leaves the top-level await unsettled, which Node 24+
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-style timers (setTimeout/setInterval/
276
- // setImmediate) become no-ops -- the callback never runs and the scheduling
277
- // call returns normally so user code keeps going. Promise-style timers
278
- // (node:timers/promises) return *forever-pending* promises: `await
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 also a
304
- // forever-pending promise, so `for await (const _ of setInterval(...))` hangs
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 from disk and parse it into a Lockfile or Bundle,
8
- // reporting which kind it turned out to be. `kind` is one of:
9
- // 'lockfile' -- a `stasis.lock.json` (files recorded as SRI digests)
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
- // Read a stasis artifact from disk and parse it into a Lockfile or Bundle.
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
- // `@exodus/stasis/sbom` — derive an SPDX 2.3 or CycloneDX 1.5 Software Bill of
5
- // Materials from already-parsed stasis artifacts (`Bundle`/`Lockfile`
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
- // Identity stamped into every generated SBOM's tool/creator metadata. The tool
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
- // Per-call UUID/timestamp without importing node:crypto — Web Crypto is global
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 `type`s we know how to mint. A dependency whose recorded ecosystem has
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 a whole artifact's ecosystem from its loader formats. Used for the
26
- // first-party/workspace buckets (which never carry the per-dependency
27
- // `ecosystem` field) and as the fallback for older artifacts that predate the
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 for artifacts predating the per-dependency
42
- // `ecosystem` field: the "." bucket and other non-`node_modules` dirs are
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 its purl/CycloneDX namespace (group) and bare name.
52
- // npm scopes (`@scope/name`), Composer vendors and GitHub owners (`a/b`) all use
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) — the cross-format component identifier SPDX and
65
- // CycloneDX both understand — for a package in a known ecosystem, or null when
66
- // the ecosystem has no purl type. Each path segment is percent-encoded (so an
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 recorded across one or more
91
- // already-parsed stasis artifacts (`Bundle`/`Lockfile` instances). Unlike
92
- // `stasis audit` (which inventories only installed dependencies to query the
93
- // registry), an SBOM is the *full* bill of materials, so first-party/workspace
94
- // packages are included too — tagged `scope: 'workspace'` vs. `'dependency'` so
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 cleanly separates deps (tagged) from
109
- // first-party buckets (untagged). If no bucket carries it, the artifact
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. Its
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). That is the
141
- // lone workspace package when there is exactly one; with zero or several
142
- // workspace roots (a node_modules-only artifact, or several inputs/monorepo
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 / ISO 8601 to whole seconds (SPDX's canonical
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 within the document;
156
- // the purl does when present, and ecosystem+name+version is unique by
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 (as a plain object) for the collected components.
161
- // The document DESCRIBES its primary component and the primary DEPENDS_ON each
162
- // installed dependency (a flat graph — every package in a lockfile/bundle is a
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 (as a plain object) for the collected
214
- // components. The primary (workspace root) becomes `metadata.component`; the
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. `components` is the output of
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: collect the components from already-parsed artifacts and
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
  }