@exodus/stasis 1.0.0-beta.1 → 1.0.0-beta.10

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 (48) hide show
  1. package/README.md +32 -8
  2. package/bin/stasis.js +386 -213
  3. package/package.json +52 -12
  4. package/src/audit-corrections.js +96 -0
  5. package/src/audit.js +289 -64
  6. package/src/babel-worker.cjs +65 -0
  7. package/src/babel.js +131 -0
  8. package/src/bundle-from-lockfile.js +14 -22
  9. package/src/bundle-reader.js +77 -0
  10. package/src/cmd/build.js +96 -128
  11. package/src/cmd/bundle.js +1121 -654
  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 +7 -19
  16. package/src/diff.js +80 -129
  17. package/src/installed-commits.js +182 -0
  18. package/src/json-stream.js +283 -0
  19. package/src/loader-mock.js +8 -15
  20. package/src/loaders/bash.js +49 -116
  21. package/src/loaders/cargo.js +1716 -0
  22. package/src/loaders/foundry.js +923 -0
  23. package/src/loaders/paths.js +33 -0
  24. package/src/loaders/php.js +375 -402
  25. package/src/loaders/rust.js +3254 -242
  26. package/src/loaders/solidity-ownership.js +531 -0
  27. package/src/loaders/solidity.js +412 -162
  28. package/src/loaders/toml.js +68 -0
  29. package/src/metro-resolver.js +150 -0
  30. package/src/metro-transformer.js +3 -2
  31. package/src/mock.js +57 -165
  32. package/src/parse.js +4 -16
  33. package/src/resolve-fields.js +370 -0
  34. package/src/resolve-node.js +419 -0
  35. package/src/resolve-typescript.js +541 -0
  36. package/src/rollup.js +5 -0
  37. package/src/sbom.js +120 -97
  38. package/src/scan.js +348 -180
  39. package/src/vfs-bundle/entries.js +144 -0
  40. package/src/vfs-bundle/github.js +202 -0
  41. package/src/vfs-bundle/prisma.js +439 -0
  42. package/src/vfs-bundle/soldeer.js +125 -0
  43. package/src/vfs-bundle/tree.js +647 -0
  44. package/src/vfs-bundle.js +80 -0
  45. package/src/why.js +434 -0
  46. package/src/apis/npm/index.js +0 -36
  47. package/src/apis/npm/semver.cjs +0 -44
  48. package/src/cmd/extract.js +0 -193
@@ -0,0 +1,80 @@
1
+ import { posix } from 'node:path'
2
+
3
+ import { Vfs } from '@preventive/vfs'
4
+ import { KINDS, checkAhead } from './vfs-bundle/entries.js'
5
+ import { suggestedRepoEntries } from './vfs-bundle/github.js'
6
+ import { NODE_MODULES_MANAGERS, checkCache, checkTarget, checkVfs, detectPackageManager, loadTree, packageManagerFor, vfsHost } from './vfs-bundle/tree.js'
7
+
8
+ // @exodus/stasis/vfs-bundle: static bundles from a project's lockfile alone, through the
9
+ // dependencies its package manager would install (`packageManager`: 'pnpm', pnpm 9, 10, 11 or 12;
10
+ // 'yarn1', yarn 1.22; 'npm', npm 10.9 or 11; or 'soldeer', Soldeer 0.12; without one, the one whose
11
+ // lockfile installs the project, where only one's does), over the project held in a Vfs, which is
12
+ // only read. The tree is laid out by @preventive/deptree into a Vfs of its own, and nothing is read
13
+ // from disk or written there but the tarballs and zips: fetched from registry.npmjs.org and
14
+ // Soldeer's registry, or read from npm's cache or ~/.audit's where one holds them, every copy held
15
+ // to the lockfile's integrity before it is used; cached only where setCacheDir says. A Soldeer git
16
+ // dependency is GitHub's tarball of its commit, fetched through the `client` buildVfsBundle is given.
17
+ // buildGitHubBundle builds one from a GitHub repo at a commit, or the one a tag names (the default
18
+ // branch's head without either), its tree fetched from GitHub and held to its git tree id, cached
19
+ // there the same way, and its Soldeer git dependencies through the same client.
20
+
21
+ export { buildVfsBundle } from './cmd/bundle.js'
22
+ export { buildGitHubBundle } from './vfs-bundle/github.js'
23
+ export { setCacheDir } from '@preventive/deptree/pnpm.js'
24
+ export { Vfs }
25
+
26
+ // A `host` (@exodus/stasis-core/host) over `vfs` alone, its `/` the filesystem's, reading it as it is
27
+ // at each call.
28
+ export function createVfsHost(vfs) {
29
+ checkVfs('createVfsHost', vfs)
30
+ return vfsHost(vfs, { cache: false })
31
+ }
32
+
33
+ // -> { root, vfs, projects, host, stats, installed, packageManager, packageManagerVersion }, as
34
+ // 'pnpm', 'yarn1' or 'npm' installs node_modules: the directory in the project's Vfs it installs cwd
35
+ // from, which holds the lockfile; the Vfs the tree is laid out into, rooted there; the projects'
36
+ // directories from there; the host reading the project through the tree; deptree's counts, and the
37
+ // packages it installs (each by its `path` from there, with the `commit` its version document
38
+ // names, where it names a full one); and the
39
+ // package manager reproduced, at `packageManagerVersion` if given, else the one the root
40
+ // package.json's packageManager pins, else pnpm 10.33.4, yarn 1.22.22 or npm 11.21.0 (which nothing
41
+ // pins: npm reads no packageManager). The host caches what it reads, of the project's Vfs too, so
42
+ // neither is to change while it is used.
43
+ // `os`, `cpu` and `libc` ('glibc', 'musl' or 'unknown', pnpm's and npm's, which takes 'unknown' for
44
+ // none) are the machine packages are matched against: this one's but for what is given (for another
45
+ // os, libc defaults to 'unknown'). Without a `packageManager`, it is the one of the three whose
46
+ // lockfile installs cwd, where only one's does. `cache` is where the packages' tarballs and version
47
+ // documents are kept, as @preventive/upstream's CacheOptions take it: left out, in setCacheDir's
48
+ // cache; a store with `read` and `write`, in place of it; or false, nowhere.
49
+ export async function loadNodeModules({ vfs, packageManager, cwd = '/', packageManagerVersion, os, cpu, libc, cache } = {}) {
50
+ checkVfs('loadNodeModules', vfs)
51
+ checkTarget('loadNodeModules', { os, cpu, libc })
52
+ checkCache('loadNodeModules', cache)
53
+ const project = vfsHost(vfs)
54
+ // A real path, as detection and the layout take it.
55
+ cwd = project.realpath(posix.resolve('/', cwd))
56
+ packageManager = packageManagerFor('loadNodeModules', project, cwd, { packageManager, packageManagerVersion, os }, NODE_MODULES_MANAGERS)
57
+ return loadTree({ project, packageManager, cwd, packageManagerVersion, os, cpu, libc, cache })
58
+ }
59
+
60
+ // -> the entries buildGitHubBundle takes where none are given, as paths from the project's
61
+ // directory, of the kind its package manager builds: `packageManager` if given, else the one
62
+ // detected as the build detects it. For pnpm and yarn 1, the entry points its package.json names,
63
+ // resolved as the build resolves them with the same `conditions`, `mainFields`, `metro`,
64
+ // `platforms` and `typescript` (packageEntries); for Soldeer, its .sol entry points by name
65
+ // and layout (solidityEntries). The options are checked as the build checks them, and `os`, the
66
+ // one the build is for, detects the package manager as there. Of the project held in `vfs`, from
67
+ // `cwd`; or of a GitHub repo, `{ github, sha, tag, directory, client }` as buildGitHubBundle takes
68
+ // them, downloaded as it downloads them.
69
+ export async function suggestedEntries({ vfs, cwd = '/', packageManager, conditions, mainFields, metro, platforms, jsx, typescript, os, ...repo } = {}) {
70
+ checkTarget('suggestedEntries', { os })
71
+ const resolution = { conditions, mainFields, metro, platforms, jsx, typescript }
72
+ if (vfs === undefined && repo.github === undefined) throw new Error('suggestedEntries: a vfs or a github repo is required')
73
+ if (vfs !== undefined && repo.github !== undefined) throw new Error('suggestedEntries: takes a vfs or a github repo, not both')
74
+ if (vfs === undefined) return suggestedRepoEntries({ ...repo, packageManager }, { ...resolution, os })
75
+ checkVfs('suggestedEntries', vfs)
76
+ const host = vfsHost(vfs)
77
+ const at = posix.resolve('/', cwd)
78
+ const pm = checkAhead('suggestedEntries', packageManager ?? detectPackageManager('suggestedEntries', host, at, { os }), resolution)
79
+ return KINDS[pm.kind].entries(host, at, resolution)
80
+ }
package/src/why.js ADDED
@@ -0,0 +1,434 @@
1
+ import { hasNodeModulesSegment, moduleFileKey } from '@exodus/stasis-core/util'
2
+ import { isEvidenceFile } from './audit-corrections.js'
3
+ import { parseFile } from './parse.js'
4
+
5
+ // `stasis audit --why` support: from an artifact's resolution graph (`imports`)
6
+ // and its per-consumer provenance (`reason`), explain WHY a vulnerable module is
7
+ // present by listing the cross-module import chains that reach it.
8
+ //
9
+ // Corrections apply at the EDGE level (see audit-corrections.js): an edge whose
10
+ // target file is not evidence of the module's real code -- a package.json
11
+ // manifest read, or a corrected file like ws's noop browser.js stub -- never
12
+ // enters the graph, so no displayed chain ends with (or passes through) one, and
13
+ // a consumer that recorded only such files of a package is not one of its reasons.
14
+ //
15
+ // A "path" is a chain of node_modules packages ending at the target module, e.g.
16
+ // `a -> b -> c`. The first-party (`src`) node is never shown: a chain is walked
17
+ // back through cross-module edges and stops at a HEAD -- a module that a src file
18
+ // imports directly, OR one nothing else in node_modules imports (the top of its
19
+ // dependency subtree). So `a -> b -> c` surfaces even when no src file imports
20
+ // `a` directly (a is the top of the chain), and `b -> c` surfaces additionally
21
+ // when `b` is itself imported by src.
22
+ //
23
+ // The reasons shown for a flagged package are EXACTLY the ones that recorded the
24
+ // package itself -- the same set the non-`--why` column reports (see collectReasons).
25
+ // `--why` only adds, per reason, HOW that reason reaches it. So a consumer that
26
+ // imports a package but never recorded it (externalized it) is not one of its
27
+ // reasons and is never shown; and every reason that did record it appears, at least
28
+ // as a bare line.
29
+ //
30
+ // The per-reason PATH is file-level. A consumer "has" the edge `b -> c` only if it
31
+ // recorded the specific file in `b` that imports `c`, so a chain belongs to a
32
+ // consumer only when it recorded the file forming EVERY edge -- `metro: a -> b -> c`
33
+ // can't appear if the file importing `c` from `b` isn't metro's. Each reason's
34
+ // chains are enumerated on its own subgraph (only its edges), so it shows its
35
+ // MAXIMAL file-level path: if metro's edges only reach up to `b`, its line is the
36
+ // shorter `metro: b -> c`; a reason that recorded the package but never imports it
37
+ // through the graph shows a bare `metro: c`. A bundle with a single consumer omits
38
+ // the `reason` map entirely; there every chain renders bare, with no prefix.
39
+
40
+ // Cap on paths enumerated per target to bound pathological graphs (many diamond
41
+ // dependencies compound into a combinatorial number of simple paths). Hitting it
42
+ // is surfaced as a marker line rather than silently dropped.
43
+ const PATH_CAP = 2000
44
+
45
+ // Packages a chain never walks PAST: everything imports these build/runtime hubs,
46
+ // so continuing up from them just adds noise. When a chain reaches one it stops
47
+ // there, and the hub becomes the chain's head (`@babel/core -> a -> b -> c`),
48
+ // still prefixed with its own reason.
49
+ const TERMINAL_PACKAGES = new Set(['@babel/core', 'react-native', 'metro'])
50
+
51
+ // Index one parsed artifact: map every project-relative file to its owning module
52
+ // dir, record each dir's package identity + whether it's a node_modules dep, and
53
+ // keep each dir's EVIDENCE file list (for the target's-own-reason lookup).
54
+ // `fileToDir` is complete; `dirFiles` only holds files that count as real code of
55
+ // the dir (see audit-corrections.js) -- a consumer that recorded nothing of a
56
+ // package but a corrected file / its package.json manifest is not one of its
57
+ // reasons, exactly as in collectReasons.
58
+ function moduleIndex(artifact) {
59
+ const fileToDir = new Map()
60
+ const dirInfo = new Map()
61
+ const dirFiles = new Map()
62
+ for (const [dir, { name, version, files }] of artifact.modules) {
63
+ dirInfo.set(dir, { name: name ?? null, version: version ?? null, dep: hasNodeModulesSegment(dir) })
64
+ const list = []
65
+ for (const rel of Object.keys(files)) {
66
+ const f = moduleFileKey(dir, rel)
67
+ fileToDir.set(f, dir)
68
+ if (isEvidenceFile(name ?? null, version ?? null, rel)) list.push(f)
69
+ }
70
+ dirFiles.set(dir, list)
71
+ }
72
+ return { fileToDir, dirInfo, dirFiles }
73
+ }
74
+
75
+ // Flatten the imports graph (conditions -> parent -> specifier -> target) into
76
+ // deduped file-level edges [parentFile, targetFile]. A `--metro` target may be a
77
+ // { platform: file } map; each platform's file becomes its own edge.
78
+ function fileEdges(imports) {
79
+ const edges = []
80
+ const seen = new Set()
81
+ for (const [, byParent] of imports) {
82
+ for (const [parent, specifiers] of byParent) {
83
+ for (const [, target] of specifiers) {
84
+ const targets = target instanceof Map ? [...target.values()] : [target]
85
+ for (const t of targets) {
86
+ const key = `${parent}\0${t}`
87
+ if (seen.has(key)) continue
88
+ seen.add(key)
89
+ edges.push([parent, t])
90
+ }
91
+ }
92
+ }
93
+ }
94
+ return edges
95
+ }
96
+
97
+ // Per-reason cross-module subgraphs. Every import edge is a FILE edge (a specific
98
+ // file in the parent importing the target), and a consumer only "has" that edge if
99
+ // it recorded the PARENT file. So a chain belongs to a reason only when that reason
100
+ // bundled the file forming EVERY one of its edges. We build one subgraph per
101
+ // consumer (bucket) -- holding only the edges that consumer's files created -- and
102
+ // later enumerate that consumer's chains on it. With no reason map there is a single
103
+ // bucket '' holding the full graph (chains render bare). Each subgraph:
104
+ // adjRev: depDir -> Set(dep predecessor dir) -- dep->dep edges, reversed
105
+ // srcImporters: depDir -> Set(src file) -- src->dep edges
106
+ // Intra-module edges and edges into a src module are dropped.
107
+ function bucketGraphs(edges, fileToDir, dirInfo, fileReasons) {
108
+ const graphs = new Map()
109
+ const graphFor = (bucket) => {
110
+ let g = graphs.get(bucket)
111
+ if (g === undefined) graphs.set(bucket, (g = { adjRev: new Map(), srcImporters: new Map() }))
112
+ return g
113
+ }
114
+ for (const [pf, tf] of edges) {
115
+ const pd = fileToDir.get(pf)
116
+ const td = fileToDir.get(tf)
117
+ if (pd === undefined || td === undefined || pd === td) continue
118
+ const tdi = dirInfo.get(td)
119
+ if (!tdi.dep) continue
120
+ // An edge counts only when it targets the module's real code (see
121
+ // audit-corrections.js): a `package.json` manifest read (react-native scans
122
+ // sibling packages' manifests for Haste/asset resolution) or a corrected file
123
+ // (ws's noop browser.js stub) is not a dependency on the module and must not
124
+ // pull it into the --why graph -- no chain may end (or pass) through one.
125
+ if (!isEvidenceFile(tdi.name, tdi.version, tf.slice(td.length + 1))) continue
126
+ // The consumers this edge belongs to: those that recorded its parent file.
127
+ const buckets = fileReasons === null ? [''] : (fileReasons.get(pf) ?? [])
128
+ const dep = dirInfo.get(pd).dep
129
+ for (const bucket of buckets) {
130
+ const map = dep ? graphFor(bucket).adjRev : graphFor(bucket).srcImporters
131
+ let set = map.get(td)
132
+ if (set === undefined) map.set(td, (set = new Set()))
133
+ set.add(dep ? pd : pf)
134
+ }
135
+ }
136
+ return graphs
137
+ }
138
+
139
+ // Invert the reason map to file -> Set(consumer). null when the artifact carries
140
+ // no reason map (single-consumer bundle / lockfile) -> chains render bare.
141
+ export function invertReason(reason) {
142
+ if (!reason) return null
143
+ const byFile = new Map()
144
+ for (const [consumer, list] of Object.entries(reason)) {
145
+ if (!Array.isArray(list)) continue
146
+ for (const f of list) {
147
+ let set = byFile.get(f)
148
+ if (set === undefined) byFile.set(f, (set = new Set()))
149
+ set.add(consumer)
150
+ }
151
+ }
152
+ return byFile
153
+ }
154
+
155
+ // dir -> Set(consumer): the consumers that recorded any of the dir's own EVIDENCE
156
+ // files (dirFiles is pre-filtered -- a consumer that recorded only a corrected
157
+ // file / manifest of the dir doesn't count; see moduleIndex). null when the
158
+ // artifact has no reason map. Used to decide whether a bare target belongs to a
159
+ // reason that never imports it (a flagged package a bundler shipped).
160
+ function dirReasonsIndex(dirFiles, fileReasons) {
161
+ if (fileReasons === null) return null
162
+ const byDir = new Map()
163
+ for (const [dir, files] of dirFiles) {
164
+ let set
165
+ for (const f of files) for (const c of fileReasons.get(f) ?? []) (set ??= new Set()).add(c)
166
+ if (set !== undefined) byDir.set(dir, set)
167
+ }
168
+ return byDir
169
+ }
170
+
171
+ // Every simple dep-only path (within one reason's subgraph) that ends at
172
+ // `targetDir`, walking predecessors backward and emitting a path each time its
173
+ // head is a HEAD node (`isHead`) or a terminal package (`isTerminal`). A terminal
174
+ // node ALSO stops the walk -- the chain never goes past @babel/core / react-native
175
+ // / metro, and this applies to `targetDir` too: a terminal target is emitted bare
176
+ // (just `[targetDir]`), never climbed above. Its provenance instead comes from the
177
+ // reason whose subgraph this is. Returns dirs head-first; `truncated` flags the cap.
178
+ function pathsTo(adjRev, isHead, isTerminal, targetDir) {
179
+ const results = []
180
+ let truncated = false
181
+ const walk = (node, suffix, inPath) => {
182
+ if (results.length >= PATH_CAP) {
183
+ truncated = true
184
+ return
185
+ }
186
+ const terminal = isTerminal(node)
187
+ if (isHead(node) || terminal) results.push(suffix)
188
+ if (terminal) return // don't walk past @babel/core / react-native / metro
189
+ for (const pred of adjRev.get(node) ?? []) {
190
+ if (inPath.has(pred)) continue // no repeated module -> break cycles
191
+ inPath.add(pred)
192
+ walk(pred, [pred, ...suffix], inPath)
193
+ inPath.delete(pred)
194
+ }
195
+ }
196
+ walk(targetDir, [targetDir], new Set([targetDir]))
197
+ return { results, truncated }
198
+ }
199
+
200
+ // A chain (node names, head first) from index `i` on, as a lookup key; and as a display line.
201
+ const chainKey = (c, i = 0) => c.slice(i).join('\0')
202
+ const renderChain = (c) => c.join(' -> ')
203
+
204
+ // Display order for one bucket's (deduped) chains:
205
+ // 1. a chain that is a full proper suffix of another always renders before it
206
+ // (`B -> C` before `A -> B -> C`) -- a hard constraint;
207
+ // 2. chains sharing a head stay contiguous, and recursively chains sharing a
208
+ // longer prefix stay contiguous within their group -- never interleaved
209
+ // with other groups;
210
+ // 3. among sibling groups (at every level), the group whose LONGEST chain is
211
+ // shortest renders first; ties alphabetically.
212
+ // All chains end at the same target, so a full suffix always has a DIFFERENT
213
+ // head: rule 1 only ever orders head groups against each other, never members of
214
+ // one group, and is enforced with a topological pass over the head groups. In
215
+ // the rare cyclic case (mutual suffix constraints through an import cycle) the
216
+ // preferred group order wins and one constraint is dropped.
217
+ function orderChains(chains) {
218
+ if (chains.length <= 1) return chains
219
+
220
+ // Recursive contiguous grouping: `list` shares its first `depth` nodes; group
221
+ // by the node AT `depth` and order sibling groups shortest-max first.
222
+ const arrange = (list, depth) => {
223
+ if (list.length <= 1) return list
224
+ return [...Map.groupBy(list, (c) => c[depth])]
225
+ .map(([node, g]) => ({ node, g, max: Math.max(...g.map((c) => c.length)) }))
226
+ .toSorted((a, b) => a.max - b.max || a.node.localeCompare(b.node))
227
+ .flatMap(({ g }) => arrange(g, depth + 1))
228
+ }
229
+
230
+ const byHead = Map.groupBy(chains, (c) => c[0])
231
+
232
+ // Rule 1: whenever a chain is a full suffix of another, its head group must
233
+ // render before the extension's head group.
234
+ const keys = new Set(chains.map((c) => chainKey(c)))
235
+ const succ = new Map([...byHead.keys()].map((h) => [h, new Set()]))
236
+ const indeg = new Map([...byHead.keys()].map((h) => [h, 0]))
237
+ for (const c of chains) {
238
+ for (let i = 1; i < c.length; i++) {
239
+ if (!keys.has(chainKey(c, i))) continue
240
+ const suffixHead = c[i]
241
+ if (suffixHead === c[0] || succ.get(suffixHead).has(c[0])) continue
242
+ succ.get(suffixHead).add(c[0])
243
+ indeg.set(c[0], indeg.get(c[0]) + 1)
244
+ }
245
+ }
246
+
247
+ const maxLen = new Map([...byHead].map(([h, g]) => [h, Math.max(...g.map((c) => c.length))]))
248
+ const prefer = (a, b) => maxLen.get(a) - maxLen.get(b) || a.localeCompare(b)
249
+ const remaining = new Set(byHead.keys())
250
+ const out = []
251
+ while (remaining.size > 0) {
252
+ const free = [...remaining].filter((h) => indeg.get(h) === 0)
253
+ // No free head means a constraint cycle -- break it by preference.
254
+ const head = (free.length > 0 ? free : [...remaining]).toSorted(prefer)[0]
255
+ remaining.delete(head)
256
+ for (const s of succ.get(head)) indeg.set(s, indeg.get(s) - 1)
257
+ out.push(...arrange(byHead.get(head), 1))
258
+ }
259
+ return out
260
+ }
261
+
262
+ // Compress one reason bucket's chains (node-name arrays, all ending at the same
263
+ // target). Chains are emitted in display order (orderChains -- prefix groups
264
+ // kept contiguous, a full-suffix chain always before its extensions); each collapses
265
+ // at the earliest node whose remaining sub-path to the target is already
266
+ // recoverable from the lines above it, rendering `prefix -> node -> ... -> target`.
267
+ // A sub-path is recoverable once ANY emitted line shows its head node in the
268
+ // line's visible part -- a full line shows every node, a collapsed line its prefix
269
+ // down to the collapse point -- because from there the earlier lines spell out the
270
+ // rest. So a repeated tail is written out once and referenced with `...`
271
+ // everywhere else, transitively:
272
+ // D -> C -> B -> A (full)
273
+ // P -> E -> D -> ... -> A (D's tail is in the full line)
274
+ // Q -> E -> ... -> A (E's tail is now visible in P's line)
275
+ // G -> F -> E -> ... -> A
276
+ // H -> F -> ... -> A (F's tail is visible in G's line)
277
+ // A collapse hides only nodes between the visible ones and the target, and lines
278
+ // that collapse to identical text are emitted once (a hub reached by several shown
279
+ // sub-paths yields a single `importer -> hub -> ... -> target` per importer).
280
+ function compressChains(chains) {
281
+ const uniq = [...new Map(chains.map((c) => [renderChain(c), c])).values()]
282
+ if (uniq.length <= 1) return uniq.map(renderChain)
283
+ const sorted = orderChains(uniq)
284
+ const target = sorted[0][sorted[0].length - 1]
285
+
286
+ const revealed = new Set() // sub-paths recoverable from lines already emitted
287
+ const out = []
288
+ const seen = new Set()
289
+ const push = (line) => {
290
+ if (!seen.has(line)) {
291
+ seen.add(line)
292
+ out.push(line)
293
+ }
294
+ }
295
+ for (const c of sorted) {
296
+ // Collapse at the longest proper tail (earliest position, >= 3 nodes so >= 1 is
297
+ // hidden) that an earlier line already made recoverable.
298
+ let at = -1
299
+ for (let i = 1; c.length - i >= 3; i++) {
300
+ if (revealed.has(chainKey(c, i))) {
301
+ at = i
302
+ break
303
+ }
304
+ }
305
+ // Index of the last node this line shows: all of them when full, the collapse
306
+ // point otherwise.
307
+ const visible = at === -1 ? c.length - 1 : at
308
+ if (at === -1) push(renderChain(c))
309
+ else push(`${renderChain(c.slice(0, at + 1))} -> ... -> ${target}`)
310
+ // Every sub-path headed by a node this line shows is now recoverable.
311
+ for (let i = 0; i <= visible; i++) revealed.add(chainKey(c, i))
312
+ }
313
+ return out
314
+ }
315
+
316
+ // Default (non `--why-deep`) pruning: drop a chain when another chain in the SAME
317
+ // bucket is a full proper suffix of it. Those longer chains only tell how the
318
+ // suffix's head is reached through further subdeps -- when that head is itself a
319
+ // direct dependency of src (or the flagged target itself), its own line already
320
+ // explains the pull-in, so `A -> B -> C -> D` is noise next to `B -> C -> D` (and
321
+ // everything is noise next to a bare `D`). Only a FULL suffix counts: chains that
322
+ // merely share a tail (`E -> C -> B -> A` vs `D -> C -> B -> A`) both survive and
323
+ // are left to compressChains. The shortest chain always survives.
324
+ function dropSuffixed(chains) {
325
+ const keys = new Set(chains.map((c) => chainKey(c)))
326
+ return chains.filter((c) => {
327
+ for (let i = 1; i < c.length; i++) {
328
+ if (keys.has(chainKey(c, i))) return false
329
+ }
330
+ return true
331
+ })
332
+ }
333
+
334
+ // Map each targeted package (`name@version`) to its ordered `--why` lines.
335
+ // `targetKeys` (optional) restricts work to the packages that matter (the ones
336
+ // carrying advisories); omit it to compute for every node_modules package.
337
+ // `reasonFilter` (optional, from --reason) keeps only chains attributed to that
338
+ // consumer, dropping every other chain (and bare, unattributed chains).
339
+ // By default a chain is dropped when a same-bucket chain is a full suffix of it
340
+ // (see dropSuffixed); `deep` (--why-deep) keeps every chain. Chains are then
341
+ // deduplicated and compressed per reason bucket (see compressChains), unless
342
+ // `full` (--why-full) -- then every surviving chain is spelled out with no `...`
343
+ // collapse, in the same shortest-first order. The flags compose: deep + full is
344
+ // the complete raw listing. Artifacts without a resolution graph (`imports`)
345
+ // contribute nothing.
346
+ export function collectWhy(files, targetKeys, reasonFilter = null, { deep = false, full = false } = {}) {
347
+ // key -> reason bucket ('' = unattributed) -> Map(chainKey -> node-name array)
348
+ const acc = new Map()
349
+ const truncatedKeys = new Set()
350
+ const bucketChains = (key, bucket) => {
351
+ let buckets = acc.get(key)
352
+ if (buckets === undefined) acc.set(key, (buckets = new Map()))
353
+ let chains = buckets.get(bucket)
354
+ if (chains === undefined) buckets.set(bucket, (chains = new Map()))
355
+ return chains
356
+ }
357
+ const addChain = (key, bucket, nodes) => bucketChains(key, bucket).set(chainKey(nodes), nodes)
358
+
359
+ const EMPTY_GRAPH = { adjRev: new Map(), srcImporters: new Map() }
360
+
361
+ for (const file of files) {
362
+ const artifact = parseFile(file)
363
+ const imports = artifact.imports
364
+ if (!imports || imports.size === 0) continue
365
+
366
+ const { fileToDir, dirInfo, dirFiles } = moduleIndex(artifact)
367
+ const edges = fileEdges(imports)
368
+ const fileReasons = invertReason(artifact.reason)
369
+ const graphs = bucketGraphs(edges, fileToDir, dirInfo, fileReasons)
370
+ const dirReasons = dirReasonsIndex(dirFiles, fileReasons)
371
+ // Terminal packages end a chain regardless of what imports them.
372
+ const isTerminal = (node) => TERMINAL_PACKAGES.has(dirInfo.get(node)?.name)
373
+
374
+ // Group target dirs by `name@version` (a package can appear at several dirs,
375
+ // e.g. nested node_modules); paths from every matching dir are unioned.
376
+ const dirsByKey = new Map()
377
+ for (const [dir, info] of dirInfo) {
378
+ if (!info.dep || !info.name || !info.version) continue
379
+ const key = `${info.name}@${info.version}`
380
+ if (targetKeys && !targetKeys.has(key)) continue
381
+ let arr = dirsByKey.get(key)
382
+ if (arr === undefined) dirsByKey.set(key, (arr = []))
383
+ arr.push(dir)
384
+ }
385
+ if (dirsByKey.size === 0) continue
386
+
387
+ for (const [key, dirs] of dirsByKey) {
388
+ for (const targetDir of dirs) {
389
+ // The reasons to report are exactly the ones the FLAGGED package itself was
390
+ // recorded under -- identical to the non-`--why` column (see collectReasons).
391
+ // For each, enumerate that reason's MAXIMAL file-level chains on its own
392
+ // subgraph; a reason that recorded the package but doesn't import it through
393
+ // the graph still yields a bare line. A reason that IMPORTS the package but
394
+ // never recorded it (externalized it) is not one of the package's reasons and
395
+ // is not shown -- so the --why reason set matches non-`--why` exactly.
396
+ const own = fileReasons === null ? [''] : (dirReasons.get(targetDir) ?? [])
397
+ for (const bucket of own) {
398
+ if (reasonFilter !== null && bucket !== reasonFilter) continue
399
+ const { adjRev, srcImporters } = graphs.get(bucket) ?? EMPTY_GRAPH
400
+ // A head tops a chain: a src file imports it directly, or nothing else
401
+ // in this bucket's graph imports it.
402
+ const isHead = (node) => srcImporters.has(node) || !adjRev.has(node)
403
+ const { results, truncated } = pathsTo(adjRev, isHead, isTerminal, targetDir)
404
+ if (truncated) truncatedKeys.add(key)
405
+ for (const p of results) {
406
+ const nodes = p.map((d) => dirInfo.get(d)?.name ?? d)
407
+ addChain(key, reasonFilter !== null ? '' : bucket, nodes)
408
+ }
409
+ }
410
+ }
411
+ }
412
+ }
413
+
414
+ // --why-full: no `...` collapse -- every chain spelled out, in the same display
415
+ // order as the collapsed rendering (see orderChains).
416
+ const renderFull = (chains) => orderChains(chains).map(renderChain)
417
+
418
+ // Prune (unless deep), compress (unless full), and render each bucket (buckets
419
+ // sorted so a consumer's chains group together; bare '' sorts first).
420
+ const byPkg = new Map()
421
+ for (const [key, buckets] of acc) {
422
+ const lines = []
423
+ for (const bucket of [...buckets.keys()].toSorted((a, b) => a.localeCompare(b))) {
424
+ let chains = [...buckets.get(bucket).values()]
425
+ if (!deep) chains = dropSuffixed(chains)
426
+ for (const rendered of (full ? renderFull(chains) : compressChains(chains))) {
427
+ lines.push(bucket ? `${bucket}: ${rendered}` : rendered)
428
+ }
429
+ }
430
+ if (truncatedKeys.has(key)) lines.push(`… (${PATH_CAP}+ paths, truncated)`)
431
+ byPkg.set(key, lines)
432
+ }
433
+ return byPkg
434
+ }
@@ -1,36 +0,0 @@
1
- import assert from 'node:assert/strict'
2
-
3
- import semver from './semver.cjs'
4
-
5
- const packageNameRegex = /^(@[\da-z-]+\/)?[\w-]+(\.[\w-]+)*$/u
6
-
7
- export async function advisories(list, { signal = AbortSignal.timeout(30_000) } = {}) {
8
- const groups = new Map()
9
- for (const { name, version } of list) {
10
- assert(typeof name === 'string' && typeof version === 'string')
11
- assert(packageNameRegex.test(name), `Unexpected package name: ${name}`)
12
- assert(semver.valid(version), `Invalid version: ${version}`)
13
- if (!groups.has(name)) groups.set(name, new Set())
14
- groups.get(name).add(version)
15
- }
16
-
17
- const entries = [...groups].map(([k, v]) => [k, [...v].toSorted((a, b) => semver.compare(a, b))])
18
- const body = Object.fromEntries(entries.toSorted((a, b) => a[0] < b[0] ? -1 : 1))
19
-
20
- let res
21
- try {
22
- res = await fetch('https://registry.npmjs.org/-/npm/v1/security/advisories/bulk', {
23
- method: 'POST',
24
- headers: { 'Content-Type': 'application/json' },
25
- body: JSON.stringify(body),
26
- signal,
27
- })
28
- } catch (cause) {
29
- throw new Error(`npm advisories request failed: ${cause.message}`, { cause })
30
- }
31
- if (!res.ok) {
32
- const text = await res.text().catch(() => '')
33
- throw new Error(`npm advisories request failed: ${res.status} ${res.statusText}${text ? ` — ${text.slice(0, 200)}` : ''}`)
34
- }
35
- return res.json()
36
- }
@@ -1,44 +0,0 @@
1
- 'use strict'
2
-
3
- const assert = require('node:assert/strict')
4
- const { basename, dirname, resolve } = require('node:path')
5
-
6
- let semver
7
-
8
- const argv0 = process.argv[0] // not process.argv0 which lacks path
9
-
10
- function getSemver() {
11
- if (semver) return semver
12
-
13
- // Coherence check that we got Node.js path there
14
- assert(['node', 'node.exe'].includes(basename(argv0)))
15
- assert.equal(basename(argv0), basename(process.argv0))
16
-
17
- // Load semver from npm bundled with Node.js
18
- const semverDir = resolve(dirname(argv0), '../lib/node_modules/npm/node_modules/semver')
19
- semver = require(semverDir)
20
- return semver
21
- }
22
-
23
- exports.compare = (...a) => getSemver().compare(...a)
24
- exports.gtr = (...a) => getSemver().gtr(...a)
25
- exports.lt = (...a) => getSemver().lt(...a)
26
- exports.lte = (...a) => getSemver().lte(...a)
27
- exports.ltr = (...a) => getSemver().ltr(...a)
28
- exports.satisfies = (...a) => getSemver().satisfies(...a)
29
- exports.validRange = (...a) => getSemver().validRange(...a)
30
-
31
- exports.valid = (version, options) => {
32
- // Fast path for certainly valid versions
33
- if (
34
- options === undefined &&
35
- typeof version === 'string' &&
36
- version.length <= 256 &&
37
- // 1-15 chars because it must fit in Number.MAX_SAFE_INTEGER
38
- /^(0|[1-9]\d{0,14})\.(0|[1-9]\d{0,14})\.(0|[1-9]\d{0,14})$/iu.test(version)
39
- ) {
40
- return version
41
- }
42
-
43
- return getSemver().valid(version, options)
44
- }