@exodus/stasis 1.0.0-beta.3 → 1.0.0-beta.6

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.
@@ -0,0 +1,72 @@
1
+ import { satisfies, valid } from '@preventive/upstream/semver.js'
2
+
3
+ // Manual corrections to audit findings: files that must NOT count as evidence
4
+ // that a (potentially vulnerable) package's code is present. An import edge whose
5
+ // target is one of these files is not evidence the package is really used, so a
6
+ // package pulled in only through corrected files is skipped by the audit (see
7
+ // collectPackagesFromFile) -- none of its real code ships, so no advisory applies.
8
+ //
9
+ // Each entry names a package, the audit-irrelevant files (relative to the module
10
+ // dir), and `range` -- the semver range VERIFIED to match the rationale. The
11
+ // correction never applies outside it: a release could change the file, so the
12
+ // range is widened only after re-checking the file in the new versions.
13
+ const CORRECTIONS = [
14
+ {
15
+ // ws's browser build is a noop stub -- `module.exports = function () { throw
16
+ // new Error('ws does not work in the browser...') }` -- with none of the
17
+ // WebSocket implementation in it. Verified against the ws 8.21.1 tarball
18
+ // (latest at the time of writing); re-check browser.js before widening.
19
+ name: 'ws',
20
+ files: new Set(['browser.js']),
21
+ range: '<=8.21.1',
22
+ },
23
+ {
24
+ // node-fetch's browser build re-exports the environment's native fetch
25
+ // (`module.exports = globalObject.fetch`, plus Headers/Request/Response) --
26
+ // none of the node-fetch implementation, where its advisories live, is in it.
27
+ // browser.js ships only through 2.7.0 (3.x dropped it); verified 2.6.13/2.7.0.
28
+ name: 'node-fetch',
29
+ files: new Set(['browser.js']),
30
+ range: '<=2.7.0',
31
+ },
32
+ ]
33
+
34
+ // Is `rel` (a file path relative to the module dir) audit-irrelevant for
35
+ // `name@version`? Unknown or unparsable versions are never corrected (fail
36
+ // closed: the package stays audited).
37
+ function isCorrectedFile(name, version, rel) {
38
+ for (const { name: pkg, files, range } of CORRECTIONS) {
39
+ if (pkg !== name || !files.has(rel)) continue
40
+ if (valid(version) && satisfies(version, range)) return true
41
+ }
42
+ return false
43
+ }
44
+
45
+ // A manifest (top-level or nested) is never code: the resolution graph records
46
+ // resolver/metadata reads of `package.json` as edges (react-native scans sibling
47
+ // packages' manifests for Haste/asset resolution) and consumers record them
48
+ // alongside bundled files, but no package code ships through one. So too for the
49
+ // manifests the other ecosystems' bundles carry beside a dependency's code
50
+ // (--cargo-manifests, --manifests): its Cargo.toml and the checksums cargo vendored
51
+ // it with (a registry's copy, a git checkout's, or one whose source isn't known),
52
+ // its composer.json, a Solidity dependency's foundry.toml and remappings.
53
+ const CARGO_MANIFESTS = new Set(['Cargo.toml', 'Cargo.lock', '.cargo-checksum.json'])
54
+ const MANIFESTS = {
55
+ npm: new Set(['package.json']),
56
+ cargo: CARGO_MANIFESTS,
57
+ 'cargo-git': CARGO_MANIFESTS,
58
+ 'cargo-unknown': CARGO_MANIFESTS,
59
+ composer: new Set(['composer.json', 'composer.lock']),
60
+ soldeer: new Set(['package.json', 'foundry.toml', 'remappings.txt', 'soldeer.toml']),
61
+ github: new Set(['package.json', 'foundry.toml', 'remappings.txt', 'soldeer.toml']),
62
+ }
63
+ const isManifest = (rel, ecosystem) => MANIFESTS[ecosystem]?.has(rel.slice(rel.lastIndexOf('/') + 1)) ?? false
64
+
65
+ // Is `rel` evidence that `name@version`'s REAL code is present, of a dependency of
66
+ // `ecosystem` (npm by default)? This is the one rule every audit surface shares --
67
+ // package presence, the reason column, and the --why chain graph all count a file
68
+ // (or an edge targeting it) only when it passes. Manifests never do; corrected
69
+ // files (npm's) don't within their verified range.
70
+ export function isEvidenceFile(name, version, rel, ecosystem = 'npm') {
71
+ return !isManifest(rel, ecosystem) && (ecosystem !== 'npm' || !isCorrectedFile(name, version, rel))
72
+ }
package/src/audit.js CHANGED
@@ -1,77 +1,135 @@
1
- import { moduleFileKey } from '@exodus/stasis-core/util'
2
- import { advisories } from './apis/npm/index.js'
1
+ import { dependencyEcosystem, moduleFileKey, sameGithub } 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
- // Only audit installed dependencies; first-party packages live under non-`node_modules` keys and
9
- // must not be sent to the public registry (leaks names, adds noise).
8
+ // Where advisories() asks for each ecosystem's advisories: npm's registry; OSV for crates (those
9
+ // vendored from a registry, which OSV takes for crates.io's) and Composer packages; for Soldeer
10
+ // packages and GitHub repos (Foundry's lib/ submodules), the advisories their GitHub repository
11
+ // publishes, a Soldeer package's as Soldeer names it.
12
+ const SOURCES = { npm: ['npm'], cargo: ['OSV'], composer: ['OSV'], soldeer: ['Soldeer', 'GitHub'], github: ['GitHub'] }
13
+
14
+ // Why a vendored crate that may not be crates.io's is asked nowhere: by its name, crates.io's
15
+ // advisories would be another crate's, and a private git dependency's name must not reach a public
16
+ // database at all.
17
+ const UNREGISTERED_CRATES = {
18
+ 'cargo-git': 'a crate vendored from a git repository, not crates.io',
19
+ 'cargo-unknown': 'a vendored crate with no .cargo-checksum.json, which may be a git checkout, not crates.io\'s',
20
+ }
21
+
22
+ // A dependency's version as it is audited: a GitHub repo's `.gitmodules` branch `.`, git's for the
23
+ // superproject's own branch, which the bundle does not know and advisories() takes for no branch, is
24
+ // the 0.0.0 stasis versions a repo with no version of its own by. Every advisory range covers both,
25
+ // as it does a branch name.
26
+ const versionOf = (ecosystem, version) => (ecosystem === 'github' && version === '.' ? '0.0.0' : version)
27
+
28
+ // A package's key across the audit: `name@version` for npm, as collectWhy keys its chains, and
29
+ // prefixed by the ecosystem for the others, whose names may be npm's too.
30
+ const keyOf = (ecosystem, name, version) => `${ecosystem === 'npm' ? '' : `${ecosystem}:`}${name}@${version}`
31
+
32
+ // Composer's dev versions (`dev-main`, `1.x-dev`), as Composer tells them, a `#ref` dropped: no
33
+ // advisory database lists those, and advisories() takes releases alone.
34
+ const isComposerDev = (version) => /^dev-|-dev$/iu.test(version.replace(/#.*$/su, ''))
35
+
36
+ // Why `pkg` cannot be audited, or undefined where it can.
37
+ function unaudited({ ecosystem, version }) {
38
+ if (Object.hasOwn(UNREGISTERED_CRATES, ecosystem)) return UNREGISTERED_CRATES[ecosystem]
39
+ if (!Object.hasOwn(SOURCES, ecosystem)) return `no advisories are looked up for ${ecosystem}`
40
+ if (ecosystem === 'composer' && isComposerDev(version)) return 'a Composer dev version, which no advisory database lists'
41
+ return undefined
42
+ }
43
+
44
+ // Only audit installed dependencies, `{ ecosystem, name, version }`: an npm package, a vendored
45
+ // crate, a Composer package, a Soldeer package or a GitHub repo, as the bundle tags it (a crate
46
+ // vendored from git, or from where no `.cargo-checksum.json` tells, is listed but never asked about).
10
47
  //
11
48
  // A package counts as present only when its REAL code is, and recorded = present:
12
49
  // an artifact records exactly the files it ships or attested -- imported, entry,
13
50
  // or `add`-ed (the last two have no in-edge in the resolution graph, so presence
14
51
  // must NOT be derived from edges). The package is audited only if some recorded
15
52
  // file is code evidence (see audit-corrections.js) -- not a corrected file (ws's
16
- // noop browser.js stub), not a package.json manifest (recorded for resolver /
17
- // metadata reads). So a ws shipped only as browser.js (+ manifest) is skipped,
18
- // while one whose real code was bundled stays. Which consumers and import EDGES
19
- // reach that code is the reason column's concern -- collectReasons and why.js
20
- // apply the same evidence rule per file/edge there.
53
+ // noop browser.js stub), not a manifest (package.json, recorded for resolver /
54
+ // metadata reads; Cargo.toml). So a ws shipped only as browser.js (+ manifest) is
55
+ // skipped, while one whose real code was bundled stays. Which consumers and import
56
+ // EDGES reach that code is the reason column's concern -- collectReasons and
57
+ // why.js apply the same evidence rule per file/edge there.
58
+ //
59
+ // A package's `github` is the repository its artifact records for it (its package.json's), where
60
+ // one does; a GitHub repo is its own, and has none.
21
61
  export function collectPackagesFromFile(file) {
22
62
  const out = []
23
- for (const [dir, { name, version, files }] of parseFile(file).modules) {
24
- if (!dir.includes('node_modules')) continue
63
+ for (const [dir, { name, version, ecosystem: tagged, repo, files }] of parseFile(file).modules) {
64
+ const ecosystem = dependencyEcosystem(dir, tagged)
65
+ // First-party code is never sent to a public registry (leaks names, adds noise).
66
+ if (ecosystem === undefined) continue
25
67
  if (!name || !version) continue
26
- if (!Object.keys(files).some((rel) => isEvidenceFile(name, version, rel))) continue
27
- out.push({ name, version })
68
+ if (!Object.keys(files).some((rel) => isEvidenceFile(name, version, rel, ecosystem))) continue
69
+ const github = ecosystem === 'github' ? undefined : repo?.github
70
+ out.push({ ecosystem, name, version: versionOf(ecosystem, version), ...(github === undefined ? {} : { github }) })
28
71
  }
29
72
  return out
30
73
  }
31
74
 
75
+ // Versions in semver's order where both are semver's, else as numbers in text compare.
76
+ const byNumbers = new Intl.Collator('en', { numeric: true }).compare
77
+ const byVersion = (a, b) => (valid(a) && valid(b) ? compareVersions(a, b) : byNumbers(a, b))
78
+
79
+ // Each package once, with the GitHub repository its artifacts record for its name, which
80
+ // advisories() asks instead of looking one up. It takes one repository a name, so a name recorded
81
+ // with two, by two of its versions (a package that moved) or by two artifacts, has none, and is
82
+ // looked up as before.
32
83
  export function collectPackages(files) {
33
- const seen = new Set()
34
- const out = []
84
+ const byKey = new Map()
85
+ const repos = new Map() // `ecosystem:name` -> the one repository recorded for it, or null
35
86
  for (const file of files) {
36
- for (const { name, version } of collectPackagesFromFile(file)) {
37
- const key = `${name}@${version}`
38
- if (seen.has(key)) continue
39
- seen.add(key)
40
- out.push({ name, version })
87
+ for (const { github, ...pkg } of collectPackagesFromFile(file)) {
88
+ const key = keyOf(pkg.ecosystem, pkg.name, pkg.version)
89
+ if (!byKey.has(key)) byKey.set(key, pkg)
90
+ if (github === undefined) continue
91
+ const name = `${pkg.ecosystem}:${pkg.name}`
92
+ const known = repos.get(name)
93
+ // The first spelling stands.
94
+ if (known === undefined) repos.set(name, github)
95
+ else if (known !== null && !sameGithub(known, github)) repos.set(name, null)
41
96
  }
42
97
  }
43
- return out.toSorted((a, b) => a.name.localeCompare(b.name) || semver.compare(a.version, b.version))
98
+ return [...byKey.values()]
99
+ .map((pkg) => {
100
+ const github = repos.get(`${pkg.ecosystem}:${pkg.name}`)
101
+ return github ? { ...pkg, github } : pkg
102
+ })
103
+ .toSorted((a, b) => a.ecosystem.localeCompare(b.ecosystem) || a.name.localeCompare(b.name) || byVersion(a.version, b.version))
44
104
  }
45
105
 
46
- // Map each audited node_modules package (`name@version`) to the bundle consumers ("reasons") that
47
- // recorded its files. Only bundles carry a reason map ({ consumer: [file, ...] }); lockfiles omit
48
- // it. Each file is resolved back to its owning package via the module file listing. Only evidence
49
- // files attribute (see audit-corrections.js): a consumer that recorded nothing of a package but a
50
- // corrected file or its package.json manifest carries none of its real code and is NOT a reason --
51
- // e.g. webpack shipping only ws's noop browser.js stub, while `run` bundles the real ws.
106
+ // Map each audited package (keyOf it) to the bundle consumers ("reasons") that recorded its files.
107
+ // Only bundles carry a reason map ({ consumer: [file, ...] }); lockfiles omit it. Each file is
108
+ // resolved back to its owning package via the module file listing. Only evidence files attribute
109
+ // (see audit-corrections.js): a consumer that recorded nothing of a package but a corrected file or
110
+ // its manifest carries none of its real code and is NOT a reason -- e.g. webpack shipping only ws's
111
+ // noop browser.js stub, while `run` bundles the real ws.
52
112
  export function collectReasons(files) {
53
113
  const byPkg = new Map()
54
114
  for (const file of files) {
55
115
  const artifact = parseFile(file)
56
- const reason = artifact.reason
57
- if (!reason) continue
116
+ const fileReasons = invertReason(artifact.reason)
117
+ if (!fileReasons) continue
58
118
  const fileToPkg = new Map()
59
- for (const [dir, { name, version, files: modFiles }] of artifact.modules) {
60
- if (!dir.includes('node_modules') || !name || !version) continue
119
+ for (const [dir, { name, version, ecosystem: tagged, files: modFiles }] of artifact.modules) {
120
+ const ecosystem = dependencyEcosystem(dir, tagged)
121
+ if (ecosystem === undefined || !name || !version) continue
61
122
  for (const rel of Object.keys(modFiles)) {
62
- if (!isEvidenceFile(name, version, rel)) continue
63
- fileToPkg.set(moduleFileKey(dir, rel), `${name}@${version}`)
123
+ if (!isEvidenceFile(name, version, rel, ecosystem)) continue
124
+ fileToPkg.set(moduleFileKey(dir, rel), keyOf(ecosystem, name, versionOf(ecosystem, version)))
64
125
  }
65
126
  }
66
- for (const [consumer, list] of Object.entries(reason)) {
67
- if (!Array.isArray(list)) continue
68
- for (const f of list) {
69
- const key = fileToPkg.get(f)
70
- if (key === undefined) continue
71
- let set = byPkg.get(key)
72
- if (set === undefined) byPkg.set(key, (set = new Set()))
73
- set.add(consumer)
74
- }
127
+ for (const [f, consumers] of fileReasons) {
128
+ const key = fileToPkg.get(f)
129
+ if (key === undefined) continue
130
+ let set = byPkg.get(key)
131
+ if (set === undefined) byPkg.set(key, (set = new Set()))
132
+ for (const consumer of consumers) set.add(consumer)
75
133
  }
76
134
  }
77
135
  return byPkg
@@ -91,69 +149,57 @@ const byConsumerOrder = (a, b) => consumerRank(a) - consumerRank(b) || a.localeC
91
149
  // (which REPLACE the consumer list). Consumers are ordered plugins -> run -> add.
92
150
  // `reasonFilter`, when set, narrows the cell to a single consumer: the `--why`
93
151
  // chains are already filtered upstream (see collectWhy), so only the consumer
94
- // list needs pruning here.
95
- function reasonCell(pkg, affected, reasonsByPkg, whyByPkg, reasonFilter) {
152
+ // list needs pruning here. collectWhy follows npm's import graph alone, so an
153
+ // advisory of another ecosystem keeps its consumer list under `--why` too.
154
+ function reasonCell({ ecosystem, name, versions: affected }, reasonsByPkg, whyByPkg, reasonFilter) {
155
+ const chains = whyByPkg !== null && ecosystem === 'npm'
96
156
  const parts = new Set()
97
- const source = whyByPkg ?? reasonsByPkg
157
+ const source = chains ? whyByPkg : reasonsByPkg
98
158
  for (const v of affected) {
99
- for (const p of source.get(`${pkg}@${v}`) ?? []) parts.add(p)
159
+ for (const p of source.get(keyOf(ecosystem, name, v)) ?? []) parts.add(p)
100
160
  }
101
161
  // --why: group `consumer: path` lines by consumer and order the groups
102
162
  // plugins -> run -> add (this also re-unites a consumer's lines when they were
103
163
  // split across affected versions); within a group collectWhy's compressed order
104
164
  // is preserved. Otherwise: the `, `-joined consumer set in the same order.
105
- if (whyByPkg) {
106
- const byConsumer = new Map()
107
- for (const line of parts) {
165
+ if (chains) {
166
+ const byConsumer = Map.groupBy(parts, (line) => {
108
167
  const i = line.indexOf(': ')
109
- const consumer = i === -1 ? '' : line.slice(0, i)
110
- if (!byConsumer.has(consumer)) byConsumer.set(consumer, [])
111
- byConsumer.get(consumer).push(line)
112
- }
168
+ return i === -1 ? '' : line.slice(0, i)
169
+ })
113
170
  return [...byConsumer.keys()].toSorted(byConsumerOrder).flatMap((c) => byConsumer.get(c)).join('\n')
114
171
  }
115
172
  const consumers = reasonFilter ? [...parts].filter((c) => c === reasonFilter) : [...parts]
116
173
  return consumers.toSorted(byConsumerOrder).join(', ')
117
174
  }
118
175
 
119
- export function flattenAdvisories(result, packages = [], reasonsByPkg = new Map(), whyByPkg = null, reasonFilter = null) {
120
- const installed = new Map()
121
- for (const { name, version } of packages) {
122
- if (!installed.has(name)) installed.set(name, [])
123
- installed.get(name).push(version)
124
- }
176
+ // `found` is what @preventive/upstream's advisories() answers: one row per advisory range, whose
177
+ // `versions` are the audited versions it covers (never empty -- a range covering none is dropped
178
+ // there, so the table and exit code reflect only real hits).
179
+ export function flattenAdvisories(found, reasonsByPkg = new Map(), whyByPkg = null, reasonFilter = null) {
125
180
  const rows = []
126
- for (const [pkg, list] of Object.entries(result)) {
127
- if (!Array.isArray(list)) continue
128
- const installedVersions = installed.get(pkg) ?? []
129
- for (const adv of list) {
130
- const range = adv.vulnerable_versions ?? ''
131
- const affected = range
132
- ? installedVersions.filter((v) => semver.satisfies(v, range))
133
- : installedVersions
134
- // npm sometimes returns advisories whose range matches none of the versions we submitted; drop
135
- // them so the table and exit code reflect only real hits.
136
- if (installedVersions.length > 0 && affected.length === 0) continue
137
- const reason = reasonCell(pkg, affected, reasonsByPkg, whyByPkg, reasonFilter)
138
- // --reason keeps only advisories tied to that consumer: once the cell is
139
- // narrowed to it, an empty cell means this package isn't related to it.
140
- if (reasonFilter && reason === '') continue
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
- }
181
+ for (const adv of found) {
182
+ const reason = reasonCell(adv, reasonsByPkg, whyByPkg, reasonFilter)
183
+ // --reason keeps only advisories tied to that consumer: once the cell is
184
+ // narrowed to it, an empty cell means this package isn't related to it.
185
+ if (reasonFilter && reason === '') continue
186
+ rows.push({
187
+ ecosystem: adv.ecosystem,
188
+ package: adv.name,
189
+ installed: adv.versions.join(', '),
190
+ vulnerable: adv.range ?? '',
191
+ severity: adv.severity ?? '',
192
+ title: adv.title ?? '',
193
+ id: adv.id,
194
+ reason,
195
+ })
151
196
  }
152
197
  rows.sort((a, b) => {
153
198
  const sa = SEVERITY_ORDER[a.severity] ?? 99
154
199
  const sb = SEVERITY_ORDER[b.severity] ?? 99
155
200
  if (sa !== sb) return sa - sb
156
201
  if (a.package !== b.package) return a.package < b.package ? -1 : 1
202
+ if (a.ecosystem !== b.ecosystem) return a.ecosystem < b.ecosystem ? -1 : 1
157
203
  return a.title < b.title ? -1 : 1
158
204
  })
159
205
  return rows
@@ -199,52 +245,75 @@ export function formatTable(rows, columns, { multiline = [] } = {}) {
199
245
  ].join('\n')
200
246
  }
201
247
 
202
- export async function audit(files, { why = false, whyDeep = false, whyFull = false, reason = null } = {}) {
248
+ // `repoAdvisories` and `github` (a @preventive/upstream/github.js client) go to advisories() as they
249
+ // are; a Soldeer package or a GitHub repo needs the client, their repository being their only source.
250
+ // A package that cannot be audited is `skipped`, with why (`because`), and asked for nothing.
251
+ export async function audit(files, { why = false, whyDeep = false, whyFull = false, reason = null, repoAdvisories = false, github } = {}) {
203
252
  // --why-deep and --why-full imply --why. Deep keeps every chain instead of the
204
253
  // default pruning of chains whose full suffix is already a chain (see
205
254
  // collectWhy/dropSuffixed); full spells chains out with no `...` collapse.
206
255
  why = why || whyDeep || whyFull
207
256
  const packages = collectPackages(files)
208
- if (packages.length === 0) {
209
- return { packages, advisories: {}, rows: [], why }
257
+ const skipped = packages.flatMap((pkg) => {
258
+ const because = unaudited(pkg)
259
+ return because === undefined ? [] : [{ ...pkg, because }]
260
+ })
261
+ const asked = packages.filter((pkg) => unaudited(pkg) === undefined)
262
+ if (asked.length === 0) {
263
+ return { packages, skipped, advisories: [], rows: [], why }
264
+ }
265
+ let result
266
+ try {
267
+ result = await advisories(asked.map(({ version, ...pkg }) => ({ ...pkg, versions: [version] })), { repoAdvisories, github })
268
+ } catch (cause) {
269
+ // Refused input or a malformed answer is an assertion that says so itself; a transport or
270
+ // HTTP failure gets the context of which request it was.
271
+ if (cause?.code === 'ERR_ASSERTION') throw cause
272
+ const sources = new Set([...asked.flatMap((pkg) => SOURCES[pkg.ecosystem]), ...(repoAdvisories ? ['GitHub'] : [])])
273
+ throw new Error(`${[...sources].join('/')} advisories request failed: ${cause.message}`, { cause })
210
274
  }
211
- const result = await advisories(packages)
212
275
  // `--why` REPLACES the consumer list with per-consumer import paths, so only
213
- // one of the two is computed. Restrict the (potentially expensive) path search
214
- // to the packages that actually carry an advisory. `reason` (--reason) narrows
215
- // both the paths (in collectWhy) and the consumer list (in flattenAdvisories)
216
- // to a single consumer, dropping advisories unrelated to it.
276
+ // one of the two is computed for npm's advisories, the only ones collectWhy has
277
+ // paths for: another ecosystem's keep their consumers. Restrict the (potentially
278
+ // expensive) path search to the packages that actually carry an advisory.
279
+ // `reason` (--reason) narrows both the paths (in collectWhy) and the consumer
280
+ // list (in flattenAdvisories) to a single consumer, dropping advisories
281
+ // unrelated to it.
217
282
  let rows
218
283
  if (why) {
219
- const vulnerable = new Set(
220
- Object.entries(result).filter(([, v]) => Array.isArray(v) && v.length > 0).map(([name]) => name)
221
- )
222
- const targetKeys = new Set(
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)
284
+ const npm = result.filter((adv) => adv.ecosystem === 'npm')
285
+ const targetKeys = new Set(npm.flatMap((adv) => adv.versions.map((v) => keyOf(adv.ecosystem, adv.name, v))))
286
+ const reasons = npm.length < result.length ? collectReasons(files) : undefined
287
+ rows = flattenAdvisories(result, reasons, collectWhy(files, targetKeys, reason, { deep: whyDeep, full: whyFull }), reason)
226
288
  } else {
227
- rows = flattenAdvisories(result, packages, collectReasons(files), null, reason)
289
+ rows = flattenAdvisories(result, collectReasons(files), null, reason)
228
290
  }
229
- return { packages, advisories: result, rows, why, whyDeep, whyFull, reason }
291
+ return { packages, skipped, advisories: result, rows, why, whyDeep, whyFull, reason }
292
+ }
293
+
294
+ // `10 alerts, 1 critical, 5 high, 3 moderate, 1 low`: the rows, then each severity present, in the
295
+ // rows' own order (most severe first); a row without one is `unrated`, so the parts add up.
296
+ function alertStats(rows) {
297
+ const bySeverity = Map.groupBy(rows, (r) => r.severity || 'unrated')
298
+ return [`${rows.length} alert${rows.length === 1 ? '' : 's'}`, ...[...bySeverity].map(([severity, list]) => `${list.length} ${severity}`)].join(', ')
230
299
  }
231
300
 
232
- export function printAuditReport({ packages, rows, why = false, reason = null }, { out = process.stdout, err = process.stderr } = {}) {
233
- err.write(`Scanned ${packages.length} package${packages.length === 1 ? '' : 's'}\n`)
301
+ export function printAuditReport({ packages, skipped = [], rows, why = false, reason = null }, { out = process.stdout, err = process.stderr } = {}) {
302
+ const scanned = `Scanned ${packages.length} package${packages.length === 1 ? '' : 's'}`
234
303
  if (packages.length === 0) {
235
- err.write('No node_modules entries found in the input files\n')
236
- return
237
- }
238
- if (rows.length === 0) {
239
- err.write('No advisories found\n')
304
+ err.write(`${scanned}\nNo dependencies found in the input files\n`)
240
305
  return
241
306
  }
242
- const columns = ['severity', 'package', 'installed', 'vulnerable', 'title', 'url']
307
+ err.write(`${scanned}: ${alertStats(rows)}\n`)
308
+ for (const pkg of skipped) err.write(`Not audited: ${pkg.ecosystem} ${pkg.name}@${pkg.version}, ${pkg.because}\n`)
309
+ if (rows.length === 0) return
310
+ // The ecosystem column only where one is not npm's, which the others were all once.
311
+ const columns = ['severity', ...(rows.some((r) => r.ecosystem !== 'npm') ? ['ecosystem'] : []), 'package', 'installed', 'vulnerable', 'title', 'id']
243
312
  // Surface the reason column only when some advisory has provenance -- bundle
244
313
  // consumers, or (with --why) import paths. Under --reason WITHOUT --why every
245
314
  // cell is just the filter value repeated, so drop the column then; --why still
246
315
  // earns it (the import chains differ per row).
247
- if (rows.some((r) => r.reason) && !(reason && !why)) columns.splice(3, 0, 'reason')
316
+ if (rows.some((r) => r.reason) && !(reason && !why)) columns.splice(columns.indexOf('installed') + 1, 0, 'reason')
248
317
  // Under --why the reason cell is a list of `consumer: path` lines; let it span
249
318
  // multiple physical rows instead of collapsing to one.
250
319
  out.write(formatTable(rows, columns, { multiline: why ? ['reason'] : [] }) + '\n')
@@ -4,7 +4,7 @@ import { resolve } from 'node:path'
4
4
 
5
5
  import { Bundle } from '@exodus/stasis-core/bundle'
6
6
  import { sha512integrity } from '@exodus/stasis-core/state-util'
7
- import { moduleFileKey, narrowExecutable } from '@exodus/stasis-core/util'
7
+ import { moduleFileKey, moduleInfo, narrowExecutable } from '@exodus/stasis-core/util'
8
8
 
9
9
  // Reconstruct an in-memory Bundle from a lockfile, reading every attested file from disk and
10
10
  // verifying each against the lockfile's SRI digest -- fail closed on mismatch, so this is as
@@ -14,7 +14,7 @@ export function bundleFromLockfile(lockfile, { root }) {
14
14
  if (!lockfile.imports) throw new Error('stasis: lockfile has no recorded import graph')
15
15
  const formats = lockfile.formats ?? new Map()
16
16
  const modules = new Map()
17
- for (const [dir, { name, version, ecosystem, files }] of lockfile.modules) {
17
+ for (const [dir, { files, ...info }] of lockfile.modules) {
18
18
  const out = Object.create(null)
19
19
  for (const [rel, integrity] of Object.entries(files)) {
20
20
  const file = moduleFileKey(dir, rel)
@@ -42,9 +42,7 @@ export function bundleFromLockfile(lockfile, { root }) {
42
42
  out[rel] = bytes.toString('utf8')
43
43
  }
44
44
  }
45
- modules.set(dir, ecosystem === undefined
46
- ? { name, version, files: out }
47
- : { name, version, ecosystem, files: out })
45
+ modules.set(dir, moduleInfo({ ...info, files: out }))
48
46
  }
49
47
  return new Bundle({
50
48
  config: lockfile.config,
@@ -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, mkdirSync, writeFileSync } from 'node:fs'
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 }