@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/package.json CHANGED
@@ -1,54 +1,77 @@
1
1
  {
2
2
  "name": "@exodus/stasis",
3
- "version": "1.0.0-beta.2",
3
+ "version": "1.0.0-beta.5",
4
4
  "description": "File-level source bundles and lockfiles",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  "./esbuild": "./src/esbuild.js",
8
8
  "./webpack": "./src/webpack.js",
9
+ "./rollup": "./src/rollup.js",
9
10
  "./metro": "./src/metro.js",
10
11
  "./metro-transformer": "./src/metro-transformer.js",
11
12
  "./loader": "./src/loader.js",
12
13
  "./mock": "./src/mock.js",
13
14
  "./bundle": "./src/bundle.js",
15
+ "./bundle-reader": "./src/bundle-reader.js",
14
16
  "./cmd/bundle": "./src/cmd/bundle.js",
15
17
  "./cmd/build": "./src/cmd/build.js",
16
18
  "./diff": "./src/diff.js",
17
19
  "./lockfile": "./src/lockfile.js",
18
20
  "./prune": "./src/prune.js",
19
21
  "./sbom": "./src/sbom.js",
22
+ "./vfs-bundle": "./src/vfs-bundle.js",
20
23
  "./logo.svg": "./logo.svg"
21
24
  },
22
25
  "files": [
23
26
  "bin/stasis.js",
24
27
  "logo.svg",
25
- "src/apis/npm/index.js",
26
- "src/apis/npm/semver.cjs",
28
+ "src/audit-corrections.js",
27
29
  "src/audit.js",
30
+ "src/babel.js",
31
+ "src/babel-worker.cjs",
28
32
  "src/bundle.js",
29
33
  "src/bundle-from-lockfile.js",
34
+ "src/bundle-reader.js",
30
35
  "src/cmd/bundle.js",
31
36
  "src/cmd/build.js",
32
37
  "src/cmd/diff.js",
33
- "src/cmd/extract.js",
38
+ "src/cmd/github-bundle.js",
39
+ "src/cmd/output.js",
34
40
  "src/cmd/sbom.js",
35
41
  "src/diff.js",
36
42
  "src/esbuild.js",
43
+ "src/json-stream.js",
37
44
  "src/loader.js",
38
45
  "src/loader-mock.js",
39
46
  "src/loaders/bash.js",
47
+ "src/loaders/cargo.js",
48
+ "src/loaders/foundry.js",
40
49
  "src/loaders/rust.js",
41
50
  "src/loaders/solidity.js",
51
+ "src/loaders/solidity-ownership.js",
52
+ "src/loaders/toml.js",
42
53
  "src/loaders/php.js",
54
+ "src/loaders/paths.js",
43
55
  "src/lockfile.js",
44
56
  "src/metro.js",
57
+ "src/metro-resolver.js",
45
58
  "src/metro-transformer.js",
46
59
  "src/mock.js",
47
60
  "src/parse.js",
48
61
  "src/prune.js",
62
+ "src/resolve-fields.js",
63
+ "src/resolve-node.js",
64
+ "src/resolve-typescript.js",
65
+ "src/rollup.js",
49
66
  "src/sbom.js",
50
67
  "src/scan.js",
51
- "src/webpack.js"
68
+ "src/vfs-bundle.js",
69
+ "src/vfs-bundle/entries.js",
70
+ "src/vfs-bundle/github.js",
71
+ "src/vfs-bundle/soldeer.js",
72
+ "src/vfs-bundle/tree.js",
73
+ "src/webpack.js",
74
+ "src/why.js"
52
75
  ],
53
76
  "engines": {
54
77
  "node": ">=24.14.0"
@@ -58,26 +81,36 @@
58
81
  },
59
82
  "repository": {
60
83
  "type": "git",
61
- "url": "git+https://github.com/ExodusOSS/stasis.git",
84
+ "url": "git+https://github.com/PreventiveMeasures/stasis.git",
62
85
  "directory": "stasis"
63
86
  },
64
87
  "author": "Exodus Movement, Inc.",
65
88
  "license": "MIT",
66
89
  "bugs": {
67
- "url": "https://github.com/ExodusOSS/stasis/issues"
90
+ "url": "https://github.com/PreventiveMeasures/stasis/issues"
68
91
  },
69
- "homepage": "https://github.com/ExodusOSS/stasis#readme",
92
+ "homepage": "https://github.com/PreventiveMeasures/stasis#readme",
70
93
  "dependencies": {
71
- "@exodus/stasis-core": "1.0.0-beta.2",
72
- "@exodus/stasis-plugins": "1.0.0-beta.2",
94
+ "@exodus/bytes": "^1.16.0",
95
+ "@exodus/stasis-core": "1.0.0-beta.5",
96
+ "@exodus/stasis-plugins": "1.0.0-beta.5",
97
+ "@preventive/archive": "1.0.0-beta.3",
98
+ "@preventive/deptree": "1.0.0-alpha.6",
99
+ "@preventive/lockfile": "1.0.0-alpha.5",
100
+ "@preventive/upstream": "1.0.0-alpha.4",
101
+ "@preventive/vfs": "1.0.0-beta.3",
73
102
  "oxc-parser": "^0.94.0"
74
103
  },
75
104
  "peerDependencies": {
76
- "esbuild": "*"
105
+ "esbuild": "*",
106
+ "flow-remove-types": "*"
77
107
  },
78
108
  "peerDependenciesMeta": {
79
109
  "esbuild": {
80
110
  "optional": true
111
+ },
112
+ "flow-remove-types": {
113
+ "optional": true
81
114
  }
82
115
  }
83
116
  }
@@ -0,0 +1,68 @@
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, its composer.json, a Solidity dependency's foundry.toml and remappings.
52
+ const MANIFESTS = {
53
+ npm: new Set(['package.json']),
54
+ cargo: new Set(['Cargo.toml', 'Cargo.lock', '.cargo-checksum.json']),
55
+ composer: new Set(['composer.json', 'composer.lock']),
56
+ soldeer: new Set(['package.json', 'foundry.toml', 'remappings.txt', 'soldeer.toml']),
57
+ github: new Set(['package.json', 'foundry.toml', 'remappings.txt', 'soldeer.toml']),
58
+ }
59
+ const isManifest = (rel, ecosystem) => MANIFESTS[ecosystem]?.has(rel.slice(rel.lastIndexOf('/') + 1)) ?? false
60
+
61
+ // Is `rel` evidence that `name@version`'s REAL code is present, of a dependency of
62
+ // `ecosystem` (npm by default)? This is the one rule every audit surface shares --
63
+ // package presence, the reason column, and the --why chain graph all count a file
64
+ // (or an edge targeting it) only when it passes. Manifests never do; corrected
65
+ // files (npm's) don't within their verified range.
66
+ export function isEvidenceFile(name, version, rel, ecosystem = 'npm') {
67
+ return !isManifest(rel, ecosystem) && (ecosystem !== 'npm' || !isCorrectedFile(name, version, rel))
68
+ }
package/src/audit.js CHANGED
@@ -1,112 +1,296 @@
1
- import { advisories } from './apis/npm/index.js'
2
- import semver from './apis/npm/semver.cjs'
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'
4
+ import { isEvidenceFile } from './audit-corrections.js'
3
5
  import { parseFile } from './parse.js'
6
+ import { collectWhy, invertReason } from './why.js'
4
7
 
5
- // Only audit installed dependencies; workspace/first-party packages live under
6
- // non-`node_modules` keys in .modules (Lockfile/Bundle merge sources in) and
7
- // shouldn't be sent to the public registry — they leak names and produce noise.
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.
41
+ //
42
+ // A package counts as present only when its REAL code is, and recorded = present:
43
+ // an artifact records exactly the files it ships or attested -- imported, entry,
44
+ // or `add`-ed (the last two have no in-edge in the resolution graph, so presence
45
+ // must NOT be derived from edges). The package is audited only if some recorded
46
+ // file is code evidence (see audit-corrections.js) -- not a corrected file (ws's
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.
8
52
  export function collectPackagesFromFile(file) {
9
53
  const out = []
10
- for (const [dir, { name, version }] of parseFile(file).modules) {
11
- if (!dir.includes('node_modules')) continue
12
- if (name && version) out.push({ name, version })
54
+ for (const [dir, { name, version, ecosystem: tagged, files }] of parseFile(file).modules) {
55
+ const ecosystem = ecosystemOf(dir, tagged)
56
+ if (ecosystem === undefined) continue
57
+ if (!name || !version) continue
58
+ if (!Object.keys(files).some((rel) => isEvidenceFile(name, version, rel, ecosystem))) continue
59
+ out.push({ ecosystem, name, version: versionOf(ecosystem, version) })
13
60
  }
14
61
  return out
15
62
  }
16
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
+
17
68
  export function collectPackages(files) {
18
69
  const seen = new Set()
19
70
  const out = []
20
71
  for (const file of files) {
21
- for (const { name, version } of collectPackagesFromFile(file)) {
22
- const key = `${name}@${version}`
72
+ for (const pkg of collectPackagesFromFile(file)) {
73
+ const key = keyOf(pkg.ecosystem, pkg.name, pkg.version)
23
74
  if (seen.has(key)) continue
24
75
  seen.add(key)
25
- out.push({ name, version })
76
+ out.push(pkg)
26
77
  }
27
78
  }
28
- return out.toSorted((a, b) => a.name.localeCompare(b.name) || semver.compare(a.version, b.version))
79
+ return out.toSorted((a, b) => a.ecosystem.localeCompare(b.ecosystem) || a.name.localeCompare(b.name) || byVersion(a.version, b.version))
80
+ }
81
+
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.
88
+ export function collectReasons(files) {
89
+ const byPkg = new Map()
90
+ for (const file of files) {
91
+ const artifact = parseFile(file)
92
+ const fileReasons = invertReason(artifact.reason)
93
+ if (!fileReasons) continue
94
+ const fileToPkg = new Map()
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
98
+ for (const rel of Object.keys(modFiles)) {
99
+ if (!isEvidenceFile(name, version, rel, ecosystem)) continue
100
+ fileToPkg.set(moduleFileKey(dir, rel), keyOf(ecosystem, name, versionOf(ecosystem, version)))
101
+ }
102
+ }
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)
109
+ }
110
+ }
111
+ return byPkg
29
112
  }
30
113
 
31
114
  const SEVERITY_ORDER = { critical: 0, high: 1, moderate: 2, low: 3, info: 4, none: 5 }
32
115
 
33
- export function flattenAdvisories(result, packages = []) {
34
- const installed = new Map()
35
- for (const { name, version } of packages) {
36
- if (!installed.has(name)) installed.set(name, [])
37
- installed.get(name).push(version)
116
+ // Consumer display order: bundler plugins (metro, webpack, esbuild, ...) first,
117
+ // then `run`, then `add`. Ties broken alphabetically.
118
+ const CONSUMER_RANK = { run: 1, add: 2 }
119
+ const consumerRank = (c) => CONSUMER_RANK[c] ?? 0
120
+ const byConsumerOrder = (a, b) => consumerRank(a) - consumerRank(b) || a.localeCompare(b)
121
+
122
+ // Build a row's `reason` cell by unioning over exactly the affected versions it
123
+ // covers. Without `--why` that's the `, `-joined set of bundle consumers; with
124
+ // `--why` it's the newline-joined `consumer: a -> b -> c` import-path lines
125
+ // (which REPLACE the consumer list). Consumers are ordered plugins -> run -> add.
126
+ // `reasonFilter`, when set, narrows the cell to a single consumer: the `--why`
127
+ // chains are already filtered upstream (see collectWhy), so only the consumer
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'
132
+ const parts = new Set()
133
+ const source = chains ? whyByPkg : reasonsByPkg
134
+ for (const v of affected) {
135
+ for (const p of source.get(keyOf(ecosystem, name, v)) ?? []) parts.add(p)
38
136
  }
137
+ // --why: group `consumer: path` lines by consumer and order the groups
138
+ // plugins -> run -> add (this also re-unites a consumer's lines when they were
139
+ // split across affected versions); within a group collectWhy's compressed order
140
+ // is preserved. Otherwise: the `, `-joined consumer set in the same order.
141
+ if (chains) {
142
+ const byConsumer = Map.groupBy(parts, (line) => {
143
+ const i = line.indexOf(': ')
144
+ return i === -1 ? '' : line.slice(0, i)
145
+ })
146
+ return [...byConsumer.keys()].toSorted(byConsumerOrder).flatMap((c) => byConsumer.get(c)).join('\n')
147
+ }
148
+ const consumers = reasonFilter ? [...parts].filter((c) => c === reasonFilter) : [...parts]
149
+ return consumers.toSorted(byConsumerOrder).join(', ')
150
+ }
151
+
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) {
39
156
  const rows = []
40
- for (const [pkg, list] of Object.entries(result)) {
41
- if (!Array.isArray(list)) continue
42
- const installedVersions = installed.get(pkg) ?? []
43
- for (const adv of list) {
44
- const range = adv.vulnerable_versions ?? ''
45
- const affected = range
46
- ? installedVersions.filter((v) => semver.satisfies(v, range))
47
- : installedVersions
48
- // npm occasionally returns advisories whose vulnerable range matches none
49
- // of the versions we submitted; drop them so the table (and the CLI exit
50
- // code) reflect only real hits.
51
- if (installedVersions.length > 0 && affected.length === 0) continue
52
- rows.push({
53
- package: pkg,
54
- installed: affected.join(', '),
55
- vulnerable: range,
56
- severity: adv.severity ?? '',
57
- title: adv.title ?? '',
58
- url: adv.url ?? '',
59
- })
60
- }
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
+ })
61
172
  }
62
173
  rows.sort((a, b) => {
63
174
  const sa = SEVERITY_ORDER[a.severity] ?? 99
64
175
  const sb = SEVERITY_ORDER[b.severity] ?? 99
65
176
  if (sa !== sb) return sa - sb
66
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
67
179
  return a.title < b.title ? -1 : 1
68
180
  })
69
181
  return rows
70
182
  }
71
183
 
72
- // Line breaks in npm advisory titles would otherwise break the box layout.
73
- // Covers LF, bare CR (and CRLF), and U+2028 / U+2029 line/paragraph separators.
184
+ // Strip line breaks (LF/CR/CRLF, U+2028/U+2029) from advisory titles so they can't break the box layout.
74
185
  const cell = (v) => String(v ?? '').replace(/[\r\n\u2028\u2029]+/gu, ' ')
75
186
 
76
- export function formatTable(rows, columns) {
187
+ // Split a value into the physical lines a cell occupies. Non-multiline columns
188
+ // collapse every line break to a space via `cell` (one line). A column named in
189
+ // `multiline` (the `--why` reason column) keeps intentional `\n` breaks -- one
190
+ // physical line per entry -- while still flattening stray CR/separators in each.
191
+ const cellLines = (v, multiline) =>
192
+ multiline ? String(v ?? '').split('\n').map((s) => cell(s)) : [cell(v)]
193
+
194
+ export function formatTable(rows, columns, { multiline = [] } = {}) {
77
195
  if (rows.length === 0) return ''
78
- const widths = columns.map((c) => Math.max(c.length, ...rows.map((r) => cell(r[c]).length)))
196
+ const ml = new Set(multiline)
197
+ const linesOf = (obj) => columns.map((c) => cellLines(obj[c], ml.has(c)))
198
+ const header = columns.map((c) => [c])
199
+ const body = rows.map((r) => linesOf(r))
200
+ const widths = columns.map((c, i) =>
201
+ Math.max(c.length, ...body.map((cells) => Math.max(0, ...cells[i].map((l) => l.length))))
202
+ )
79
203
  const pad = (s, w) => s.padEnd(w)
80
204
  const line = (l, m, r, fill) => l + widths.map((w) => fill.repeat(w + 2)).join(m) + r
81
- const row = (vals) => '│ ' + vals.map((v, i) => pad(cell(v), widths[i])).join(' │ ') + ' │'
205
+ // A row spans as many physical lines as its tallest cell; shorter cells pad
206
+ // out with blanks so the borders stay aligned.
207
+ const render = (cells) => {
208
+ const height = Math.max(...cells.map((c) => c.length))
209
+ const out = []
210
+ for (let i = 0; i < height; i++) {
211
+ out.push('│ ' + cells.map((c, j) => pad(c[i] ?? '', widths[j])).join(' │ ') + ' │')
212
+ }
213
+ return out.join('\n')
214
+ }
82
215
  return [
83
216
  line('┌', '┬', '┐', '─'),
84
- row(columns),
217
+ render(header),
85
218
  line('├', '┼', '┤', '─'),
86
- ...rows.map((r) => row(columns.map((c) => r[c]))),
219
+ ...body.map(render),
87
220
  line('└', '┴', '┘', '─'),
88
221
  ].join('\n')
89
222
  }
90
223
 
91
- export async function audit(files) {
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 } = {}) {
228
+ // --why-deep and --why-full imply --why. Deep keeps every chain instead of the
229
+ // default pruning of chains whose full suffix is already a chain (see
230
+ // collectWhy/dropSuffixed); full spells chains out with no `...` collapse.
231
+ why = why || whyDeep || whyFull
92
232
  const packages = collectPackages(files)
93
- if (packages.length === 0) {
94
- return { packages, advisories: {}, rows: [] }
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 })
95
250
  }
96
- const result = await advisories(packages)
97
- const rows = flattenAdvisories(result, packages)
98
- return { packages, advisories: result, rows }
251
+ // `--why` REPLACES the consumer list with per-consumer import paths, so only
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.
258
+ let rows
259
+ if (why) {
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)
264
+ } else {
265
+ rows = flattenAdvisories(result, collectReasons(files), null, reason)
266
+ }
267
+ return { packages, skipped, advisories: result, rows, why, whyDeep, whyFull, reason }
99
268
  }
100
269
 
101
- export function printAuditReport({ packages, rows }, { out = process.stdout, err = process.stderr } = {}) {
102
- err.write(`Scanned ${packages.length} package${packages.length === 1 ? '' : 's'}\n`)
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'}`
103
279
  if (packages.length === 0) {
104
- err.write('No node_modules entries found in the input files\n')
105
- return
106
- }
107
- if (rows.length === 0) {
108
- err.write('No advisories found\n')
280
+ err.write(`${scanned}\nNo dependencies found in the input files\n`)
109
281
  return
110
282
  }
111
- out.write(formatTable(rows, ['severity', 'package', 'installed', 'vulnerable', 'title', 'url']) + '\n')
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']
288
+ // Surface the reason column only when some advisory has provenance -- bundle
289
+ // consumers, or (with --why) import paths. Under --reason WITHOUT --why every
290
+ // cell is just the filter value repeated, so drop the column then; --why still
291
+ // earns it (the import chains differ per row).
292
+ if (rows.some((r) => r.reason) && !(reason && !why)) columns.splice(columns.indexOf('installed') + 1, 0, 'reason')
293
+ // Under --why the reason cell is a list of `consumer: path` lines; let it span
294
+ // multiple physical rows instead of collapsing to one.
295
+ out.write(formatTable(rows, columns, { multiline: why ? ['reason'] : [] }) + '\n')
112
296
  }
@@ -0,0 +1,65 @@
1
+ 'use strict'
2
+
3
+ const { Worker, MessageChannel, isMainThread, parentPort, workerData } = require('node:worker_threads')
4
+ const { once } = require('node:events')
5
+ const { availableParallelism } = require('node:os')
6
+
7
+ // Babel transforms are CPU-bound and `stasis build` fires many concurrently (esbuild loads
8
+ // files in parallel), so they run on a small worker-thread pool off the main thread. Workers
9
+ // are unref()ed: a pending transform keeps the process alive through its open MessagePort,
10
+ // but an idle pool never holds the process open after the build finishes.
11
+ //
12
+ // Babel is the project's dependency, not stasis's, so each pool is keyed by the resolved
13
+ // @babel/core entry path and the worker loads that exact copy (passed via workerData).
14
+ if (isMainThread) {
15
+ const maxWorkers = availableParallelism() >= 4 ? 2 : 1
16
+ const pools = new Map() // resolved @babel/core path -> [{ worker, busy }]
17
+
18
+ const getWorker = (babelPath) => {
19
+ if (!pools.has(babelPath)) pools.set(babelPath, [])
20
+ const workers = pools.get(babelPath)
21
+
22
+ const idle = workers.find((info) => info.busy === 0)
23
+ if (idle) return idle
24
+
25
+ if (workers.length < maxWorkers) {
26
+ const worker = new Worker(__filename, { workerData: { babelPath } })
27
+ worker.unref()
28
+ // unhandled top-level errors crash the process automatically, which is the desired
29
+ // behavior -- no need to listen to 'error'
30
+ workers.unshift({ worker, busy: 0 })
31
+ } else if (workers.length > 1) {
32
+ workers.sort((a, b) => a.busy - b.busy)
33
+ }
34
+
35
+ return workers[0]
36
+ }
37
+
38
+ const transformAsync = async (babelPath, code, options) => {
39
+ const info = getWorker(babelPath)
40
+ info.busy++
41
+ const channel = new MessageChannel()
42
+ info.worker.postMessage({ port: channel.port1, code, options }, [channel.port1])
43
+ const [{ result, error }] = await once(channel.port2, 'message')
44
+ info.busy--
45
+ if (error) throw error
46
+ return result
47
+ }
48
+
49
+ module.exports = { transformAsync }
50
+ } else {
51
+ const babel = require(workerData.babelPath)
52
+
53
+ parentPort.on('message', ({ port, code: input, options }) => {
54
+ try {
55
+ // transformSync: the worker is already off the main thread, async only adds overhead
56
+ const { code, map } = babel.transformSync(input, options)
57
+ // only plain data crosses the port -- a full Babel result carries non-cloneable state
58
+ port.postMessage({ result: { code, map } })
59
+ } catch (error) {
60
+ port.postMessage({ error })
61
+ }
62
+
63
+ port.close()
64
+ })
65
+ }