@exodus/stasis 1.0.0-beta.2 → 1.0.0-beta.5

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.
Files changed (45) hide show
  1. package/README.md +23 -3
  2. package/bin/stasis.js +375 -170
  3. package/package.json +44 -11
  4. package/src/audit-corrections.js +68 -0
  5. package/src/audit.js +243 -59
  6. package/src/babel-worker.cjs +65 -0
  7. package/src/babel.js +131 -0
  8. package/src/bundle-from-lockfile.js +12 -18
  9. package/src/bundle-reader.js +77 -0
  10. package/src/cmd/build.js +95 -128
  11. package/src/cmd/bundle.js +877 -641
  12. package/src/cmd/diff.js +2 -19
  13. package/src/cmd/github-bundle.js +39 -0
  14. package/src/cmd/output.js +36 -0
  15. package/src/cmd/sbom.js +4 -18
  16. package/src/diff.js +67 -124
  17. package/src/json-stream.js +283 -0
  18. package/src/loader-mock.js +8 -15
  19. package/src/loaders/bash.js +49 -116
  20. package/src/loaders/cargo.js +1699 -0
  21. package/src/loaders/foundry.js +923 -0
  22. package/src/loaders/paths.js +33 -0
  23. package/src/loaders/php.js +339 -394
  24. package/src/loaders/rust.js +3254 -242
  25. package/src/loaders/solidity-ownership.js +531 -0
  26. package/src/loaders/solidity.js +412 -162
  27. package/src/loaders/toml.js +68 -0
  28. package/src/metro-resolver.js +150 -0
  29. package/src/mock.js +57 -165
  30. package/src/parse.js +4 -16
  31. package/src/resolve-fields.js +331 -0
  32. package/src/resolve-node.js +416 -0
  33. package/src/resolve-typescript.js +427 -0
  34. package/src/rollup.js +5 -0
  35. package/src/sbom.js +64 -92
  36. package/src/scan.js +234 -156
  37. package/src/vfs-bundle/entries.js +140 -0
  38. package/src/vfs-bundle/github.js +193 -0
  39. package/src/vfs-bundle/soldeer.js +125 -0
  40. package/src/vfs-bundle/tree.js +606 -0
  41. package/src/vfs-bundle.js +74 -0
  42. package/src/why.js +434 -0
  43. package/src/apis/npm/index.js +0 -36
  44. package/src/apis/npm/semver.cjs +0 -44
  45. package/src/cmd/extract.js +0 -193
package/src/sbom.js CHANGED
@@ -1,33 +1,21 @@
1
- import semver from './apis/npm/semver.cjs'
1
+ import { compareVersions, valid } from '@preventive/upstream/semver.js'
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,17 @@ 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`. `node_modules` is
31
+ // a path segment, as stasis-core's hasNodeModulesSegment has it: `foo_node_modules/x` is first-party.
45
32
  function classifyScope(ecosystem, dir) {
46
33
  if (dir === '.') return 'workspace'
47
34
  if (ecosystem === 'composer') return 'dependency'
48
- return dir.includes('node_modules') ? 'dependency' : 'workspace'
35
+ return dir.split('/').includes('node_modules') ? 'dependency' : 'workspace'
49
36
  }
50
37
 
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.
38
+ // Split a package name into purl/CycloneDX namespace (group) + bare name: npm scopes, Composer
39
+ // vendors and GitHub owners use the segment before the first slash; cargo and bare names have none.
54
40
  function splitName(ecosystem, name) {
55
41
  const namespaced = ecosystem === 'composer' || ecosystem === 'github' ||
56
42
  (ecosystem === 'npm' && name.startsWith('@'))
@@ -61,12 +47,11 @@ function splitName(ecosystem, name) {
61
47
  return { group: undefined, name }
62
48
  }
63
49
 
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.
50
+ // Build a Package URL (purl) for a known ecosystem, or null when it has no purl type. Each path
51
+ // segment is percent-encoded (npm scope's `@` -> `%40`) while the `/` namespace separator and the
52
+ // `@` before the version stay literal; Composer and GitHub names are lowercased, per the purl spec.
53
+ // The version qualifier is optional in the purl spec, so a versionless workspace package mints a
54
+ // bare-name purl rather than a fabricated version.
70
55
  export function buildPurl(ecosystem, rawName, version) {
71
56
  if (!PURL_TYPES.has(ecosystem)) return null
72
57
  let { group, name } = splitName(ecosystem, rawName)
@@ -75,93 +60,83 @@ export function buildPurl(ecosystem, rawName, version) {
75
60
  name = name.toLowerCase()
76
61
  }
77
62
  const namespace = group ? `${encodeURIComponent(group)}/` : ''
78
- return `pkg:${ecosystem}/${namespace}${encodeURIComponent(name)}@${encodeURIComponent(version)}`
63
+ const at = version == null ? '' : `@${encodeURIComponent(version)}`
64
+ return `pkg:${ecosystem}/${namespace}${encodeURIComponent(name)}${at}`
79
65
  }
80
66
 
81
67
  function compareComponents(a, b) {
82
68
  if (a.name !== b.name) return a.name < b.name ? -1 : 1
83
- if (a.version !== b.version) {
84
- if (semver.valid(a.version) && semver.valid(b.version)) return semver.compare(a.version, b.version)
85
- return a.version < b.version ? -1 : 1
69
+ // A missing version sorts first, as '' (comparing raw undefined would be order-unstable).
70
+ const [av, bv] = [a.version ?? '', b.version ?? '']
71
+ if (av !== bv) {
72
+ if (valid(av) && valid(bv)) return compareVersions(av, bv)
73
+ return av < bv ? -1 : 1
86
74
  }
87
75
  return a.ecosystem < b.ecosystem ? -1 : a.ecosystem > b.ecosystem ? 1 : 0
88
76
  }
89
77
 
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.
78
+ // Collect the deduplicated, sorted set of packages across parsed artifacts. Unlike `stasis audit`
79
+ // (installed deps only), an SBOM is the *full* bill of materials, so workspace/first-party packages
80
+ // are included too, tagged scope 'workspace' vs 'dependency'. Buckets without a name are skipped,
81
+ // and so are DEPENDENCY buckets without a version (v0 partial metadata: unverifiable identity) --
82
+ // but a workspace package may legitimately omit version (private/unpublished) and is kept, since
83
+ // dropping it would silently lose the document's own first-party subject. Dedup is by
84
+ // ecosystem+name+version; a package seen as workspace in any artifact stays workspace regardless
85
+ // of argument order, so output doesn't depend on how artifacts are listed.
104
86
  export function collectComponents(artifacts) {
105
87
  const seen = new Map()
106
88
  for (const artifact of artifacts) {
107
89
  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.
90
+ // The per-dependency `ecosystem` field separates deps (tagged) from first-party buckets (untagged);
91
+ // if no bucket carries it, the artifact predates the field and we use the heuristic for everything.
111
92
  const tagged = records.some(([, m]) => m.ecosystem !== undefined)
112
93
  const fileEcosystem = detectEcosystem(artifact)
113
94
  for (const [dir, { name, version, ecosystem }] of records) {
114
- if (!name || !version) continue
95
+ if (!name) continue
115
96
  let scope, eco
116
97
  if (ecosystem !== undefined) {
117
98
  scope = 'dependency'
118
99
  eco = ecosystem
119
100
  } 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).
101
+ // Untagged bucket in a modern artifact: first-party workspace code; ecosystem inferred.
122
102
  scope = 'workspace'
123
103
  eco = fileEcosystem
124
104
  } else {
125
105
  scope = classifyScope(fileEcosystem, dir)
126
106
  eco = fileEcosystem
127
107
  }
128
- const key = `${eco} ${name} ${version}`
108
+ if (!version && scope !== 'workspace') continue
109
+ const key = `${eco} ${name} ${version ?? ''}`
129
110
  const existing = seen.get(key)
130
111
  if (existing) {
131
112
  if (scope === 'workspace') existing.scope = 'workspace'
132
113
  continue
133
114
  }
134
- seen.set(key, { name, version, scope, ecosystem: eco, purl: buildPurl(eco, name, version) })
115
+ seen.set(key, { name, version: version ?? undefined, scope, ecosystem: eco, purl: buildPurl(eco, name, version) })
135
116
  }
136
117
  }
137
118
  return [...seen.values()].toSorted(compareComponents)
138
119
  }
139
120
 
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.
121
+ // Pick the single component the SBOM is *about* (its primary/root): the lone workspace package when
122
+ // there is exactly one; with zero or several workspace roots there's no single subject, so every
123
+ // package is a plain component. `rest` is everything that is not the primary.
145
124
  function selectPrimary(components) {
146
125
  const workspace = components.filter((c) => c.scope === 'workspace')
147
126
  const primary = workspace.length === 1 ? workspace[0] : null
148
127
  return { primary, rest: primary ? components.filter((c) => c !== primary) : components }
149
128
  }
150
129
 
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.
130
+ // SBOM timestamps are RFC 3339 to whole seconds; drop the milliseconds Date.toISOString includes.
153
131
  const isoSeconds = (date) => date.toISOString().replace(/\.\d{3}Z$/u, 'Z')
154
132
 
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.
158
- const bomRef = (c) => c.purl ?? `${c.ecosystem}:${c.name}@${c.version}`
133
+ // A CycloneDX bom-ref must uniquely identify a component; use the purl when present, else
134
+ // ecosystem+name(+version) (unique by construction -- it's the dedup key).
135
+ const bomRef = (c) => c.purl ?? `${c.ecosystem}:${c.name}${c.version == null ? '' : `@${c.version}`}`
159
136
 
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.
137
+ // Render an SPDX 2.3 document. It DESCRIBES its primary component, which DEPENDS_ON each installed
138
+ // dependency (flat graph). With no single primary, the document DESCRIBES the workspace packages,
139
+ // or every package when there are none.
165
140
  export function toSpdx(components, { tool = DEFAULT_TOOL, now = new Date(), uuid = newUuid() } = {}) {
166
141
  const { primary, rest } = selectPrimary(components)
167
142
  const ordered = primary ? [primary, ...rest] : components
@@ -170,7 +145,8 @@ export function toSpdx(components, { tool = DEFAULT_TOOL, now = new Date(), uuid
170
145
  const packages = ordered.map((c) => ({
171
146
  SPDXID: ids.get(c),
172
147
  name: c.name,
173
- versionInfo: c.version,
148
+ // SPDX versionInfo is optional; omit it for a versionless workspace package.
149
+ ...(c.version == null ? {} : { versionInfo: c.version }),
174
150
  downloadLocation: 'NOASSERTION',
175
151
  filesAnalyzed: false,
176
152
  ...(c.purl && {
@@ -194,7 +170,9 @@ export function toSpdx(components, { tool = DEFAULT_TOOL, now = new Date(), uuid
194
170
  }
195
171
  }
196
172
 
197
- const docName = primary ? `${primary.name}@${primary.version}` : 'stasis-sbom'
173
+ const docName = primary
174
+ ? (primary.version == null ? primary.name : `${primary.name}@${primary.version}`)
175
+ : 'stasis-sbom'
198
176
  return {
199
177
  spdxVersion: 'SPDX-2.3',
200
178
  dataLicense: 'CC0-1.0',
@@ -210,10 +188,8 @@ export function toSpdx(components, { tool = DEFAULT_TOOL, now = new Date(), uuid
210
188
  }
211
189
  }
212
190
 
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.
191
+ // Render a CycloneDX 1.5 document. The primary (workspace root) becomes metadata.component; the
192
+ // rest go in `components`, with a flat `dependencies` edge from primary to each installed dependency.
217
193
  export function toCyclonedx(components, { tool = DEFAULT_TOOL, now = new Date(), uuid = newUuid() } = {}) {
218
194
  const { primary, rest } = selectPrimary(components)
219
195
  const toComponent = (c) => {
@@ -223,7 +199,8 @@ export function toCyclonedx(components, { tool = DEFAULT_TOOL, now = new Date(),
223
199
  'bom-ref': bomRef(c),
224
200
  ...(group && { group }),
225
201
  name,
226
- version: c.version,
202
+ // CycloneDX 1.5 `version` is optional; omit it for a versionless workspace package.
203
+ ...(c.version == null ? {} : { version: c.version }),
227
204
  ...(c.purl && { purl: c.purl }),
228
205
  }
229
206
  }
@@ -235,8 +212,7 @@ export function toCyclonedx(components, { tool = DEFAULT_TOOL, now = new Date(),
235
212
  version: 1,
236
213
  metadata: {
237
214
  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.
215
+ // CycloneDX 1.5 `tools.components` shape: the flat tools array is deprecated in 1.5 (removed in 1.6).
240
216
  tools: { components: [{ type: 'application', publisher: tool.vendor, name: tool.name, version: tool.version }] },
241
217
  ...(primary && { component: toComponent(primary) }),
242
218
  },
@@ -249,18 +225,14 @@ export function toCyclonedx(components, { tool = DEFAULT_TOOL, now = new Date(),
249
225
  return doc
250
226
  }
251
227
 
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.
228
+ // Dispatch to the requested format; options: { tool, now, uuid } for custom identity / deterministic output.
255
229
  export function generateSbom(format, components, options) {
256
230
  if (format === 'spdx') return toSpdx(components, options)
257
231
  if (format === 'cyclonedx') return toCyclonedx(components, options)
258
232
  throw new Error(`sbom: format must be spdx or cyclonedx, got ${format}`)
259
233
  }
260
234
 
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)`.
235
+ // Convenience one-shot: collectComponents then generateSbom.
264
236
  export function sbom(format, artifacts, options) {
265
237
  return generateSbom(format, collectComponents(artifacts), options)
266
238
  }