@exodus/stasis 1.0.0-beta.3 → 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 +7 -1
- package/bin/stasis.js +236 -17
- package/package.json +36 -9
- package/src/audit-corrections.js +68 -0
- package/src/audit.js +153 -108
- package/src/bundle-reader.js +77 -0
- package/src/cmd/build.js +3 -5
- package/src/cmd/bundle.js +571 -376
- package/src/cmd/github-bundle.js +39 -0
- package/src/cmd/output.js +36 -0
- package/src/cmd/sbom.js +3 -11
- package/src/diff.js +6 -6
- package/src/json-stream.js +283 -0
- package/src/loaders/bash.js +17 -36
- 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 +208 -124
- package/src/loaders/rust.js +3234 -166
- package/src/loaders/solidity-ownership.js +531 -0
- package/src/loaders/solidity.js +412 -137
- package/src/loaders/toml.js +68 -0
- package/src/metro-resolver.js +8 -23
- package/src/mock.js +3 -5
- 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 +32 -18
- package/src/scan.js +79 -43
- 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 -42
package/src/audit.js
CHANGED
|
@@ -1,77 +1,111 @@
|
|
|
1
|
-
import { moduleFileKey } from '@exodus/stasis-core/util'
|
|
2
|
-
import { advisories } from '
|
|
1
|
+
import { hasNodeModulesSegment, moduleFileKey } from '@exodus/stasis-core/util'
|
|
2
|
+
import { advisories } from '@preventive/upstream/advisories.js'
|
|
3
|
+
import { compareVersions, valid } from '@preventive/upstream/semver.js'
|
|
3
4
|
import { isEvidenceFile } from './audit-corrections.js'
|
|
4
|
-
import semver from './apis/npm/semver.cjs'
|
|
5
5
|
import { parseFile } from './parse.js'
|
|
6
|
-
import { collectWhy } from './why.js'
|
|
6
|
+
import { collectWhy, invertReason } from './why.js'
|
|
7
7
|
|
|
8
|
-
//
|
|
9
|
-
//
|
|
8
|
+
// Where advisories() asks for each ecosystem's advisories: npm's registry; OSV for crates and
|
|
9
|
+
// Composer packages; for Soldeer packages and GitHub repos (Foundry's lib/ submodules), the
|
|
10
|
+
// advisories their GitHub repository publishes, a Soldeer package's as Soldeer names it.
|
|
11
|
+
const SOURCES = { npm: ['npm'], cargo: ['OSV'], composer: ['OSV'], soldeer: ['Soldeer', 'GitHub'], github: ['GitHub'] }
|
|
12
|
+
|
|
13
|
+
// A bundle's dependency's ecosystem: its bucket's `ecosystem` tag, or npm for an untagged bucket
|
|
14
|
+
// under node_modules (an artifact from before the tag); undefined for first-party code, which must
|
|
15
|
+
// not be sent to a public registry (leaks names, adds noise).
|
|
16
|
+
const ecosystemOf = (dir, ecosystem) => ecosystem ?? (hasNodeModulesSegment(dir) ? 'npm' : undefined)
|
|
17
|
+
|
|
18
|
+
// A dependency's version as it is audited: a GitHub repo's `.gitmodules` branch `.`, git's for the
|
|
19
|
+
// superproject's own branch, which the bundle does not know and advisories() takes for no branch, is
|
|
20
|
+
// the 0.0.0 stasis versions a repo with no version of its own by. Every advisory range covers both,
|
|
21
|
+
// as it does a branch name.
|
|
22
|
+
const versionOf = (ecosystem, version) => (ecosystem === 'github' && version === '.' ? '0.0.0' : version)
|
|
23
|
+
|
|
24
|
+
// A package's key across the audit: `name@version` for npm, as collectWhy keys its chains, and
|
|
25
|
+
// prefixed by the ecosystem for the others, whose names may be npm's too.
|
|
26
|
+
const keyOf = (ecosystem, name, version) => `${ecosystem === 'npm' ? '' : `${ecosystem}:`}${name}@${version}`
|
|
27
|
+
|
|
28
|
+
// Composer's dev versions (`dev-main`, `1.x-dev`), as Composer tells them, a `#ref` dropped: no
|
|
29
|
+
// advisory database lists those, and advisories() takes releases alone.
|
|
30
|
+
const isComposerDev = (version) => /^dev-|-dev$/iu.test(version.replace(/#.*$/su, ''))
|
|
31
|
+
|
|
32
|
+
// Why `pkg` cannot be audited, or undefined where it can.
|
|
33
|
+
function unaudited({ ecosystem, version }) {
|
|
34
|
+
if (!Object.hasOwn(SOURCES, ecosystem)) return `no advisories are looked up for ${ecosystem}`
|
|
35
|
+
if (ecosystem === 'composer' && isComposerDev(version)) return 'a Composer dev version, which no advisory database lists'
|
|
36
|
+
return undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Only audit installed dependencies, `{ ecosystem, name, version }`: an npm package, a vendored
|
|
40
|
+
// crate, a Composer package, a Soldeer package or a GitHub repo, as the bundle tags it.
|
|
10
41
|
//
|
|
11
42
|
// A package counts as present only when its REAL code is, and recorded = present:
|
|
12
43
|
// an artifact records exactly the files it ships or attested -- imported, entry,
|
|
13
44
|
// or `add`-ed (the last two have no in-edge in the resolution graph, so presence
|
|
14
45
|
// must NOT be derived from edges). The package is audited only if some recorded
|
|
15
46
|
// file is code evidence (see audit-corrections.js) -- not a corrected file (ws's
|
|
16
|
-
// noop browser.js stub), not a package.json
|
|
17
|
-
// metadata reads). So a ws shipped only as browser.js (+ manifest) is
|
|
18
|
-
// while one whose real code was bundled stays. Which consumers and import
|
|
19
|
-
// reach that code is the reason column's concern -- collectReasons and
|
|
20
|
-
// apply the same evidence rule per file/edge there.
|
|
47
|
+
// noop browser.js stub), not a manifest (package.json, recorded for resolver /
|
|
48
|
+
// metadata reads; Cargo.toml). So a ws shipped only as browser.js (+ manifest) is
|
|
49
|
+
// skipped, while one whose real code was bundled stays. Which consumers and import
|
|
50
|
+
// EDGES reach that code is the reason column's concern -- collectReasons and
|
|
51
|
+
// why.js apply the same evidence rule per file/edge there.
|
|
21
52
|
export function collectPackagesFromFile(file) {
|
|
22
53
|
const out = []
|
|
23
|
-
for (const [dir, { name, version, files }] of parseFile(file).modules) {
|
|
24
|
-
|
|
54
|
+
for (const [dir, { name, version, ecosystem: tagged, files }] of parseFile(file).modules) {
|
|
55
|
+
const ecosystem = ecosystemOf(dir, tagged)
|
|
56
|
+
if (ecosystem === undefined) continue
|
|
25
57
|
if (!name || !version) continue
|
|
26
|
-
if (!Object.keys(files).some((rel) => isEvidenceFile(name, version, rel))) continue
|
|
27
|
-
out.push({ name, version })
|
|
58
|
+
if (!Object.keys(files).some((rel) => isEvidenceFile(name, version, rel, ecosystem))) continue
|
|
59
|
+
out.push({ ecosystem, name, version: versionOf(ecosystem, version) })
|
|
28
60
|
}
|
|
29
61
|
return out
|
|
30
62
|
}
|
|
31
63
|
|
|
64
|
+
// Versions in semver's order where both are semver's, else as numbers in text compare.
|
|
65
|
+
const byNumbers = new Intl.Collator('en', { numeric: true }).compare
|
|
66
|
+
const byVersion = (a, b) => (valid(a) && valid(b) ? compareVersions(a, b) : byNumbers(a, b))
|
|
67
|
+
|
|
32
68
|
export function collectPackages(files) {
|
|
33
69
|
const seen = new Set()
|
|
34
70
|
const out = []
|
|
35
71
|
for (const file of files) {
|
|
36
|
-
for (const
|
|
37
|
-
const key =
|
|
72
|
+
for (const pkg of collectPackagesFromFile(file)) {
|
|
73
|
+
const key = keyOf(pkg.ecosystem, pkg.name, pkg.version)
|
|
38
74
|
if (seen.has(key)) continue
|
|
39
75
|
seen.add(key)
|
|
40
|
-
out.push(
|
|
76
|
+
out.push(pkg)
|
|
41
77
|
}
|
|
42
78
|
}
|
|
43
|
-
return out.toSorted((a, b) => a.name.localeCompare(b.name) ||
|
|
79
|
+
return out.toSorted((a, b) => a.ecosystem.localeCompare(b.ecosystem) || a.name.localeCompare(b.name) || byVersion(a.version, b.version))
|
|
44
80
|
}
|
|
45
81
|
|
|
46
|
-
// Map each audited
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
82
|
+
// Map each audited package (keyOf it) to the bundle consumers ("reasons") that recorded its files.
|
|
83
|
+
// Only bundles carry a reason map ({ consumer: [file, ...] }); lockfiles omit it. Each file is
|
|
84
|
+
// resolved back to its owning package via the module file listing. Only evidence files attribute
|
|
85
|
+
// (see audit-corrections.js): a consumer that recorded nothing of a package but a corrected file or
|
|
86
|
+
// its manifest carries none of its real code and is NOT a reason -- e.g. webpack shipping only ws's
|
|
87
|
+
// noop browser.js stub, while `run` bundles the real ws.
|
|
52
88
|
export function collectReasons(files) {
|
|
53
89
|
const byPkg = new Map()
|
|
54
90
|
for (const file of files) {
|
|
55
91
|
const artifact = parseFile(file)
|
|
56
|
-
const
|
|
57
|
-
if (!
|
|
92
|
+
const fileReasons = invertReason(artifact.reason)
|
|
93
|
+
if (!fileReasons) continue
|
|
58
94
|
const fileToPkg = new Map()
|
|
59
|
-
for (const [dir, { name, version, files: modFiles }] of artifact.modules) {
|
|
60
|
-
|
|
95
|
+
for (const [dir, { name, version, ecosystem: tagged, files: modFiles }] of artifact.modules) {
|
|
96
|
+
const ecosystem = ecosystemOf(dir, tagged)
|
|
97
|
+
if (ecosystem === undefined || !name || !version) continue
|
|
61
98
|
for (const rel of Object.keys(modFiles)) {
|
|
62
|
-
if (!isEvidenceFile(name, version, rel)) continue
|
|
63
|
-
fileToPkg.set(moduleFileKey(dir, rel),
|
|
99
|
+
if (!isEvidenceFile(name, version, rel, ecosystem)) continue
|
|
100
|
+
fileToPkg.set(moduleFileKey(dir, rel), keyOf(ecosystem, name, versionOf(ecosystem, version)))
|
|
64
101
|
}
|
|
65
102
|
}
|
|
66
|
-
for (const [
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (set === undefined) byPkg.set(key, (set = new Set()))
|
|
73
|
-
set.add(consumer)
|
|
74
|
-
}
|
|
103
|
+
for (const [f, consumers] of fileReasons) {
|
|
104
|
+
const key = fileToPkg.get(f)
|
|
105
|
+
if (key === undefined) continue
|
|
106
|
+
let set = byPkg.get(key)
|
|
107
|
+
if (set === undefined) byPkg.set(key, (set = new Set()))
|
|
108
|
+
for (const consumer of consumers) set.add(consumer)
|
|
75
109
|
}
|
|
76
110
|
}
|
|
77
111
|
return byPkg
|
|
@@ -91,69 +125,57 @@ const byConsumerOrder = (a, b) => consumerRank(a) - consumerRank(b) || a.localeC
|
|
|
91
125
|
// (which REPLACE the consumer list). Consumers are ordered plugins -> run -> add.
|
|
92
126
|
// `reasonFilter`, when set, narrows the cell to a single consumer: the `--why`
|
|
93
127
|
// chains are already filtered upstream (see collectWhy), so only the consumer
|
|
94
|
-
// list needs pruning here.
|
|
95
|
-
|
|
128
|
+
// list needs pruning here. collectWhy follows npm's import graph alone, so an
|
|
129
|
+
// advisory of another ecosystem keeps its consumer list under `--why` too.
|
|
130
|
+
function reasonCell({ ecosystem, name, versions: affected }, reasonsByPkg, whyByPkg, reasonFilter) {
|
|
131
|
+
const chains = whyByPkg !== null && ecosystem === 'npm'
|
|
96
132
|
const parts = new Set()
|
|
97
|
-
const source = whyByPkg
|
|
133
|
+
const source = chains ? whyByPkg : reasonsByPkg
|
|
98
134
|
for (const v of affected) {
|
|
99
|
-
for (const p of source.get(
|
|
135
|
+
for (const p of source.get(keyOf(ecosystem, name, v)) ?? []) parts.add(p)
|
|
100
136
|
}
|
|
101
137
|
// --why: group `consumer: path` lines by consumer and order the groups
|
|
102
138
|
// plugins -> run -> add (this also re-unites a consumer's lines when they were
|
|
103
139
|
// split across affected versions); within a group collectWhy's compressed order
|
|
104
140
|
// is preserved. Otherwise: the `, `-joined consumer set in the same order.
|
|
105
|
-
if (
|
|
106
|
-
const byConsumer =
|
|
107
|
-
for (const line of parts) {
|
|
141
|
+
if (chains) {
|
|
142
|
+
const byConsumer = Map.groupBy(parts, (line) => {
|
|
108
143
|
const i = line.indexOf(': ')
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
byConsumer.get(consumer).push(line)
|
|
112
|
-
}
|
|
144
|
+
return i === -1 ? '' : line.slice(0, i)
|
|
145
|
+
})
|
|
113
146
|
return [...byConsumer.keys()].toSorted(byConsumerOrder).flatMap((c) => byConsumer.get(c)).join('\n')
|
|
114
147
|
}
|
|
115
148
|
const consumers = reasonFilter ? [...parts].filter((c) => c === reasonFilter) : [...parts]
|
|
116
149
|
return consumers.toSorted(byConsumerOrder).join(', ')
|
|
117
150
|
}
|
|
118
151
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
installed.get(name).push(version)
|
|
124
|
-
}
|
|
152
|
+
// `found` is what @preventive/upstream's advisories() answers: one row per advisory range, whose
|
|
153
|
+
// `versions` are the audited versions it covers (never empty -- a range covering none is dropped
|
|
154
|
+
// there, so the table and exit code reflect only real hits).
|
|
155
|
+
export function flattenAdvisories(found, reasonsByPkg = new Map(), whyByPkg = null, reasonFilter = null) {
|
|
125
156
|
const rows = []
|
|
126
|
-
for (const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
rows.push({
|
|
142
|
-
package: pkg,
|
|
143
|
-
installed: affected.join(', '),
|
|
144
|
-
vulnerable: range,
|
|
145
|
-
severity: adv.severity ?? '',
|
|
146
|
-
title: adv.title ?? '',
|
|
147
|
-
url: adv.url ?? '',
|
|
148
|
-
reason,
|
|
149
|
-
})
|
|
150
|
-
}
|
|
157
|
+
for (const adv of found) {
|
|
158
|
+
const reason = reasonCell(adv, reasonsByPkg, whyByPkg, reasonFilter)
|
|
159
|
+
// --reason keeps only advisories tied to that consumer: once the cell is
|
|
160
|
+
// narrowed to it, an empty cell means this package isn't related to it.
|
|
161
|
+
if (reasonFilter && reason === '') continue
|
|
162
|
+
rows.push({
|
|
163
|
+
ecosystem: adv.ecosystem,
|
|
164
|
+
package: adv.name,
|
|
165
|
+
installed: adv.versions.join(', '),
|
|
166
|
+
vulnerable: adv.range ?? '',
|
|
167
|
+
severity: adv.severity ?? '',
|
|
168
|
+
title: adv.title ?? '',
|
|
169
|
+
id: adv.id,
|
|
170
|
+
reason,
|
|
171
|
+
})
|
|
151
172
|
}
|
|
152
173
|
rows.sort((a, b) => {
|
|
153
174
|
const sa = SEVERITY_ORDER[a.severity] ?? 99
|
|
154
175
|
const sb = SEVERITY_ORDER[b.severity] ?? 99
|
|
155
176
|
if (sa !== sb) return sa - sb
|
|
156
177
|
if (a.package !== b.package) return a.package < b.package ? -1 : 1
|
|
178
|
+
if (a.ecosystem !== b.ecosystem) return a.ecosystem < b.ecosystem ? -1 : 1
|
|
157
179
|
return a.title < b.title ? -1 : 1
|
|
158
180
|
})
|
|
159
181
|
return rows
|
|
@@ -199,52 +221,75 @@ export function formatTable(rows, columns, { multiline = [] } = {}) {
|
|
|
199
221
|
].join('\n')
|
|
200
222
|
}
|
|
201
223
|
|
|
202
|
-
|
|
224
|
+
// `repoAdvisories` and `github` (a @preventive/upstream/github.js client) go to advisories() as they
|
|
225
|
+
// are; a Soldeer package or a GitHub repo needs the client, their repository being their only source.
|
|
226
|
+
// A package that cannot be audited is `skipped`, with why (`because`), and asked for nothing.
|
|
227
|
+
export async function audit(files, { why = false, whyDeep = false, whyFull = false, reason = null, repoAdvisories = false, github } = {}) {
|
|
203
228
|
// --why-deep and --why-full imply --why. Deep keeps every chain instead of the
|
|
204
229
|
// default pruning of chains whose full suffix is already a chain (see
|
|
205
230
|
// collectWhy/dropSuffixed); full spells chains out with no `...` collapse.
|
|
206
231
|
why = why || whyDeep || whyFull
|
|
207
232
|
const packages = collectPackages(files)
|
|
208
|
-
|
|
209
|
-
|
|
233
|
+
const skipped = packages.flatMap((pkg) => {
|
|
234
|
+
const because = unaudited(pkg)
|
|
235
|
+
return because === undefined ? [] : [{ ...pkg, because }]
|
|
236
|
+
})
|
|
237
|
+
const asked = packages.filter((pkg) => unaudited(pkg) === undefined)
|
|
238
|
+
if (asked.length === 0) {
|
|
239
|
+
return { packages, skipped, advisories: [], rows: [], why }
|
|
240
|
+
}
|
|
241
|
+
let result
|
|
242
|
+
try {
|
|
243
|
+
result = await advisories(asked.map(({ ecosystem, name, version }) => ({ ecosystem, name, versions: [version] })), { repoAdvisories, github })
|
|
244
|
+
} catch (cause) {
|
|
245
|
+
// Refused input or a malformed answer is an assertion that says so itself; a transport or
|
|
246
|
+
// HTTP failure gets the context of which request it was.
|
|
247
|
+
if (cause?.code === 'ERR_ASSERTION') throw cause
|
|
248
|
+
const sources = new Set([...asked.flatMap((pkg) => SOURCES[pkg.ecosystem]), ...(repoAdvisories ? ['GitHub'] : [])])
|
|
249
|
+
throw new Error(`${[...sources].join('/')} advisories request failed: ${cause.message}`, { cause })
|
|
210
250
|
}
|
|
211
|
-
const result = await advisories(packages)
|
|
212
251
|
// `--why` REPLACES the consumer list with per-consumer import paths, so only
|
|
213
|
-
// one of the two is computed
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
252
|
+
// one of the two is computed for npm's advisories, the only ones collectWhy has
|
|
253
|
+
// paths for: another ecosystem's keep their consumers. Restrict the (potentially
|
|
254
|
+
// expensive) path search to the packages that actually carry an advisory.
|
|
255
|
+
// `reason` (--reason) narrows both the paths (in collectWhy) and the consumer
|
|
256
|
+
// list (in flattenAdvisories) to a single consumer, dropping advisories
|
|
257
|
+
// unrelated to it.
|
|
217
258
|
let rows
|
|
218
259
|
if (why) {
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
)
|
|
222
|
-
|
|
223
|
-
packages.filter((p) => vulnerable.has(p.name)).map((p) => `${p.name}@${p.version}`)
|
|
224
|
-
)
|
|
225
|
-
rows = flattenAdvisories(result, packages, undefined, collectWhy(files, targetKeys, reason, { deep: whyDeep, full: whyFull }), reason)
|
|
260
|
+
const npm = result.filter((adv) => adv.ecosystem === 'npm')
|
|
261
|
+
const targetKeys = new Set(npm.flatMap((adv) => adv.versions.map((v) => keyOf(adv.ecosystem, adv.name, v))))
|
|
262
|
+
const reasons = npm.length < result.length ? collectReasons(files) : undefined
|
|
263
|
+
rows = flattenAdvisories(result, reasons, collectWhy(files, targetKeys, reason, { deep: whyDeep, full: whyFull }), reason)
|
|
226
264
|
} else {
|
|
227
|
-
rows = flattenAdvisories(result,
|
|
265
|
+
rows = flattenAdvisories(result, collectReasons(files), null, reason)
|
|
228
266
|
}
|
|
229
|
-
return { packages, advisories: result, rows, why, whyDeep, whyFull, reason }
|
|
267
|
+
return { packages, skipped, advisories: result, rows, why, whyDeep, whyFull, reason }
|
|
230
268
|
}
|
|
231
269
|
|
|
232
|
-
|
|
233
|
-
|
|
270
|
+
// `10 alerts, 1 critical, 5 high, 3 moderate, 1 low`: the rows, then each severity present, in the
|
|
271
|
+
// rows' own order (most severe first); a row without one is `unrated`, so the parts add up.
|
|
272
|
+
function alertStats(rows) {
|
|
273
|
+
const bySeverity = Map.groupBy(rows, (r) => r.severity || 'unrated')
|
|
274
|
+
return [`${rows.length} alert${rows.length === 1 ? '' : 's'}`, ...[...bySeverity].map(([severity, list]) => `${list.length} ${severity}`)].join(', ')
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function printAuditReport({ packages, skipped = [], rows, why = false, reason = null }, { out = process.stdout, err = process.stderr } = {}) {
|
|
278
|
+
const scanned = `Scanned ${packages.length} package${packages.length === 1 ? '' : 's'}`
|
|
234
279
|
if (packages.length === 0) {
|
|
235
|
-
err.write(
|
|
236
|
-
return
|
|
237
|
-
}
|
|
238
|
-
if (rows.length === 0) {
|
|
239
|
-
err.write('No advisories found\n')
|
|
280
|
+
err.write(`${scanned}\nNo dependencies found in the input files\n`)
|
|
240
281
|
return
|
|
241
282
|
}
|
|
242
|
-
|
|
283
|
+
err.write(`${scanned}: ${alertStats(rows)}\n`)
|
|
284
|
+
for (const pkg of skipped) err.write(`Not audited: ${pkg.ecosystem} ${pkg.name}@${pkg.version}, ${pkg.because}\n`)
|
|
285
|
+
if (rows.length === 0) return
|
|
286
|
+
// The ecosystem column only where one is not npm's, which the others were all once.
|
|
287
|
+
const columns = ['severity', ...(rows.some((r) => r.ecosystem !== 'npm') ? ['ecosystem'] : []), 'package', 'installed', 'vulnerable', 'title', 'id']
|
|
243
288
|
// Surface the reason column only when some advisory has provenance -- bundle
|
|
244
289
|
// consumers, or (with --why) import paths. Under --reason WITHOUT --why every
|
|
245
290
|
// cell is just the filter value repeated, so drop the column then; --why still
|
|
246
291
|
// earns it (the import chains differ per row).
|
|
247
|
-
if (rows.some((r) => r.reason) && !(reason && !why)) columns.splice(
|
|
292
|
+
if (rows.some((r) => r.reason) && !(reason && !why)) columns.splice(columns.indexOf('installed') + 1, 0, 'reason')
|
|
248
293
|
// Under --why the reason cell is a list of `consumer: path` lines; let it span
|
|
249
294
|
// multiple physical rows instead of collapsing to one.
|
|
250
295
|
out.write(formatTable(rows, columns, { multiline: why ? ['reason'] : [] }) + '\n')
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs'
|
|
2
|
+
import { pipeline } from 'node:stream/promises'
|
|
3
|
+
import { createBrotliDecompress } from 'node:zlib'
|
|
4
|
+
|
|
5
|
+
import { Bundle } from '@exodus/stasis-core/bundle'
|
|
6
|
+
import { assert } from '@exodus/stasis-core/util'
|
|
7
|
+
|
|
8
|
+
import { JsonStreamParser } from './json-stream.js'
|
|
9
|
+
|
|
10
|
+
// Rejects as soon as `signal` aborts, without waiting for `promise` (onFile gets the signal to stop itself).
|
|
11
|
+
const untilAborted = (promise, signal) => (signal === undefined ? promise : new Promise((resolve, reject) => {
|
|
12
|
+
const abort = () => reject(signal.reason)
|
|
13
|
+
if (signal.aborted) abort()
|
|
14
|
+
else signal.addEventListener('abort', abort, { once: true })
|
|
15
|
+
Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener('abort', abort))
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
// Left in place of each streamed file; fromJSON's contents-free mode requires one per file.
|
|
19
|
+
const STREAMED = Symbol('streamed')
|
|
20
|
+
|
|
21
|
+
// Bundle.parse(brotliDecompressSync(source)), without holding the decompressed bytes or the JSON text whole.
|
|
22
|
+
// onFile gets each file in stream order, wherever the bundle puts its files, and before the bundle is
|
|
23
|
+
// validated: what it receives (format included) is provisional until readBundle resolves. See doc/file-formats.md.
|
|
24
|
+
export async function readBundle(source, { onFile, signal } = {}) {
|
|
25
|
+
assert(onFile === undefined || typeof onFile === 'function', 'readBundle: onFile must be a function')
|
|
26
|
+
let input = source
|
|
27
|
+
if (typeof source === 'string' || source instanceof URL) input = createReadStream(source)
|
|
28
|
+
else if (source instanceof ArrayBuffer) input = [new Uint8Array(source)]
|
|
29
|
+
else if (ArrayBuffer.isView(source)) input = [new Uint8Array(source.buffer, source.byteOffset, source.byteLength)]
|
|
30
|
+
|
|
31
|
+
const formats = new Map() // as far as they have streamed: newer bundles put them before any file
|
|
32
|
+
const delivered = new Map() // file -> the format onFile was given
|
|
33
|
+
const pending = []
|
|
34
|
+
const parser = new JsonStreamParser(onFile && {
|
|
35
|
+
onString(contents, path) {
|
|
36
|
+
if (path.length === 2 && path[0] === 'formats') formats.set(path[1] === '' ? '.' : path[1], contents)
|
|
37
|
+
const file = Bundle.fileKeyAt(path)
|
|
38
|
+
if (file === undefined) return contents
|
|
39
|
+
assert(!delivered.has(file), `bundle carries file '${file}' twice`)
|
|
40
|
+
const format = formats.get(file)
|
|
41
|
+
delivered.set(file, format)
|
|
42
|
+
pending.push([file, contents, format])
|
|
43
|
+
return STREAMED
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// Big chunks let zlib's thread pool decompress ahead of the parser; zlib's 16 KiB default runs about 2x slower.
|
|
48
|
+
const decompress = createBrotliDecompress({ chunkSize: 1024 * 1024, readableHighWaterMark: 4 * 1024 * 1024 })
|
|
49
|
+
let drained = false
|
|
50
|
+
await pipeline(input, decompress, async (chunks) => {
|
|
51
|
+
for await (const chunk of chunks) {
|
|
52
|
+
parser.write(chunk)
|
|
53
|
+
for (const [file, contents, format] of pending.splice(0)) {
|
|
54
|
+
signal?.throwIfAborted() // one chunk can queue many files
|
|
55
|
+
// eslint-disable-next-line no-await-in-loop -- in stream order, with backpressure on the decompressor
|
|
56
|
+
await untilAborted(onFile(file, contents, { signal, format }), signal)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
drained = true
|
|
60
|
+
}, { signal }).catch((error) => {
|
|
61
|
+
// Once drained, the stream is whole: pipeline reports unread bytes after it as an abort.
|
|
62
|
+
if (!drained || signal?.aborted) throw error
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const bundle = Bundle.fromJSON(parser.end(), { contents: !onFile })
|
|
66
|
+
if (onFile) {
|
|
67
|
+
// fromJSON required a placeholder per file, so onFile got each one; it must have got nothing else.
|
|
68
|
+
let files = 0
|
|
69
|
+
for (const [, info] of bundle.modules) files += Object.keys(info.files).length
|
|
70
|
+
assert(delivered.size === files, 'bundle carries file contents outside its file list')
|
|
71
|
+
// A later `formats` key replaces the one onFile was told about.
|
|
72
|
+
for (const [file, format] of delivered) {
|
|
73
|
+
if (format !== undefined && bundle.formats.get(file) !== format) assert(false, `bundle file '${file}' changed format after onFile got it`)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return bundle
|
|
77
|
+
}
|
package/src/cmd/build.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
2
|
import { createRequire } from 'node:module'
|
|
3
3
|
import { dirname, extname, join, relative, resolve, sep } from 'node:path'
|
|
4
4
|
import { pathToFileURL } from 'node:url'
|
|
@@ -10,6 +10,7 @@ import { StasisEsbuild } from '@exodus/stasis-plugins/esbuild'
|
|
|
10
10
|
import { HERMES_UNSUPPORTED, createBabelConfigTransform, createHermesTransform } from '../babel.js'
|
|
11
11
|
import { bundleFromLockfile } from '../bundle-from-lockfile.js'
|
|
12
12
|
import { parseFileWithKind } from '../parse.js'
|
|
13
|
+
import { writeFile } from './output.js'
|
|
13
14
|
|
|
14
15
|
export { bundleFromLockfile }
|
|
15
16
|
|
|
@@ -281,10 +282,7 @@ export async function buildCommand({
|
|
|
281
282
|
for (const line of formatted) process.stderr.write(line)
|
|
282
283
|
}
|
|
283
284
|
|
|
284
|
-
for (const file of result.outputFiles)
|
|
285
|
-
mkdirSync(dirname(file.path), { recursive: true })
|
|
286
|
-
writeFileSync(file.path, file.contents)
|
|
287
|
-
}
|
|
285
|
+
for (const file of result.outputFiles) writeFile(file.path, file.contents)
|
|
288
286
|
|
|
289
287
|
console.warn(`[stasis] Built ${selectedEntry} from ${kind} ${artifact} to ${output}`)
|
|
290
288
|
return { entry: selectedEntry, dir: asFile ? undefined : outAbs, outfile: asFile ? outAbs : undefined }
|