@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.
- package/README.md +23 -3
- package/bin/stasis.js +375 -170
- package/package.json +44 -11
- package/src/audit-corrections.js +68 -0
- package/src/audit.js +243 -59
- package/src/babel-worker.cjs +65 -0
- package/src/babel.js +131 -0
- package/src/bundle-from-lockfile.js +12 -18
- package/src/bundle-reader.js +77 -0
- package/src/cmd/build.js +95 -128
- package/src/cmd/bundle.js +877 -641
- package/src/cmd/diff.js +2 -19
- package/src/cmd/github-bundle.js +39 -0
- package/src/cmd/output.js +36 -0
- package/src/cmd/sbom.js +4 -18
- package/src/diff.js +67 -124
- package/src/json-stream.js +283 -0
- package/src/loader-mock.js +8 -15
- package/src/loaders/bash.js +49 -116
- package/src/loaders/cargo.js +1699 -0
- package/src/loaders/foundry.js +923 -0
- package/src/loaders/paths.js +33 -0
- package/src/loaders/php.js +339 -394
- package/src/loaders/rust.js +3254 -242
- package/src/loaders/solidity-ownership.js +531 -0
- package/src/loaders/solidity.js +412 -162
- package/src/loaders/toml.js +68 -0
- package/src/metro-resolver.js +150 -0
- package/src/mock.js +57 -165
- package/src/parse.js +4 -16
- package/src/resolve-fields.js +331 -0
- package/src/resolve-node.js +416 -0
- package/src/resolve-typescript.js +427 -0
- package/src/rollup.js +5 -0
- package/src/sbom.js +64 -92
- package/src/scan.js +234 -156
- package/src/vfs-bundle/entries.js +140 -0
- package/src/vfs-bundle/github.js +193 -0
- package/src/vfs-bundle/soldeer.js +125 -0
- package/src/vfs-bundle/tree.js +606 -0
- package/src/vfs-bundle.js +74 -0
- package/src/why.js +434 -0
- package/src/apis/npm/index.js +0 -36
- package/src/apis/npm/semver.cjs +0 -44
- package/src/cmd/extract.js +0 -193
package/src/sbom.js
CHANGED
|
@@ -1,33 +1,21 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { compareVersions, valid } from '@preventive/upstream/semver.js'
|
|
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,17 @@ function detectEcosystem(artifact) {
|
|
|
38
26
|
return 'npm'
|
|
39
27
|
}
|
|
40
28
|
|
|
41
|
-
// Legacy scope classification
|
|
42
|
-
//
|
|
43
|
-
//
|
|
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
|
|
52
|
-
//
|
|
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)
|
|
65
|
-
//
|
|
66
|
-
// the
|
|
67
|
-
//
|
|
68
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
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
|
|
109
|
-
//
|
|
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
|
|
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.
|
|
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
|
-
|
|
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)
|
|
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.
|
|
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
|
|
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
|
|
156
|
-
//
|
|
157
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
214
|
-
// components
|
|
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
|
|
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
|
|
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:
|
|
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
|
}
|