@exodus/stasis 1.0.0-beta.3 → 1.0.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/bin/stasis.js +236 -17
- package/package.json +36 -9
- package/src/audit-corrections.js +68 -0
- package/src/audit.js +153 -108
- package/src/bundle-reader.js +77 -0
- package/src/cmd/build.js +3 -5
- package/src/cmd/bundle.js +571 -376
- package/src/cmd/github-bundle.js +39 -0
- package/src/cmd/output.js +36 -0
- package/src/cmd/sbom.js +3 -11
- package/src/diff.js +6 -6
- package/src/json-stream.js +283 -0
- package/src/loaders/bash.js +17 -36
- package/src/loaders/cargo.js +1699 -0
- package/src/loaders/foundry.js +923 -0
- package/src/loaders/paths.js +33 -0
- package/src/loaders/php.js +208 -124
- package/src/loaders/rust.js +3234 -166
- package/src/loaders/solidity-ownership.js +531 -0
- package/src/loaders/solidity.js +412 -137
- package/src/loaders/toml.js +68 -0
- package/src/metro-resolver.js +8 -23
- package/src/mock.js +3 -5
- package/src/resolve-fields.js +331 -0
- package/src/resolve-node.js +416 -0
- package/src/resolve-typescript.js +427 -0
- package/src/rollup.js +5 -0
- package/src/sbom.js +32 -18
- package/src/scan.js +79 -43
- package/src/vfs-bundle/entries.js +140 -0
- package/src/vfs-bundle/github.js +193 -0
- package/src/vfs-bundle/soldeer.js +125 -0
- package/src/vfs-bundle/tree.js +606 -0
- package/src/vfs-bundle.js +74 -0
- package/src/why.js +434 -0
- package/src/apis/npm/index.js +0 -36
- package/src/apis/npm/semver.cjs +0 -42
|
@@ -0,0 +1,74 @@
|
|
|
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, 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.
|
|
16
|
+
// buildGitHubBundle builds one from a GitHub repo at a commit, or the one a tag names (the default
|
|
17
|
+
// branch's head without either), its tree fetched from GitHub and held to its git tree id, cached
|
|
18
|
+
// there the same way.
|
|
19
|
+
|
|
20
|
+
export { buildVfsBundle } from './cmd/bundle.js'
|
|
21
|
+
export { buildGitHubBundle } from './vfs-bundle/github.js'
|
|
22
|
+
export { setCacheDir } from '@preventive/deptree/pnpm.js'
|
|
23
|
+
export { Vfs }
|
|
24
|
+
|
|
25
|
+
// A `host` (@exodus/stasis-core/host) over `vfs` alone, its `/` the filesystem's, reading it as it is
|
|
26
|
+
// at each call.
|
|
27
|
+
export function createVfsHost(vfs) {
|
|
28
|
+
checkVfs('createVfsHost', vfs)
|
|
29
|
+
return vfsHost(vfs, { cache: false })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// -> { root, vfs, projects, host, stats, packageManager, packageManagerVersion }, as 'pnpm',
|
|
33
|
+
// 'yarn1' or 'npm' installs node_modules: the directory in the project's Vfs it installs cwd from,
|
|
34
|
+
// which holds the lockfile; the Vfs the tree is laid out into, rooted there; the projects'
|
|
35
|
+
// directories from there; the host reading the project through the tree; deptree's counts; and the
|
|
36
|
+
// package manager reproduced, at `packageManagerVersion` if given, else the one the root
|
|
37
|
+
// package.json's packageManager pins, else pnpm 10.33.4, yarn 1.22.22 or npm 11.21.0 (which nothing
|
|
38
|
+
// pins: npm reads no packageManager). The host caches what it reads, of the project's Vfs too, so
|
|
39
|
+
// neither is to change while it is used.
|
|
40
|
+
// `os`, `cpu` and `libc` ('glibc', 'musl' or 'unknown', pnpm's and npm's, which takes 'unknown' for
|
|
41
|
+
// none) are the machine packages are matched against: this one's but for what is given (for another
|
|
42
|
+
// os, libc defaults to 'unknown'). Without a `packageManager`, it is the one of the three whose
|
|
43
|
+
// lockfile installs cwd, where only one's does.
|
|
44
|
+
export async function loadNodeModules({ vfs, packageManager, cwd = '/', packageManagerVersion, os, cpu, libc } = {}) {
|
|
45
|
+
checkVfs('loadNodeModules', vfs)
|
|
46
|
+
checkTarget('loadNodeModules', { os, cpu, libc })
|
|
47
|
+
const project = vfsHost(vfs)
|
|
48
|
+
// A real path, as detection and the layout take it.
|
|
49
|
+
cwd = project.realpath(posix.resolve('/', cwd))
|
|
50
|
+
packageManager = packageManagerFor('loadNodeModules', project, cwd, { packageManager, packageManagerVersion, os }, NODE_MODULES_MANAGERS)
|
|
51
|
+
return loadTree({ project, packageManager, cwd, packageManagerVersion, os, cpu, libc })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// -> the entries buildGitHubBundle takes where none are given, as paths from the project's
|
|
55
|
+
// directory, of the kind its package manager builds: `packageManager` if given, else the one
|
|
56
|
+
// detected as the build detects it. For pnpm and yarn 1, the entry points its package.json names,
|
|
57
|
+
// resolved as the build resolves them with the same `conditions`, `mainFields`, `metro`,
|
|
58
|
+
// `platforms`, `jsx` and `typescript` (packageEntries); for Soldeer, its .sol entry points by name
|
|
59
|
+
// and layout (solidityEntries). The options are checked as the build checks them, and `os`, the
|
|
60
|
+
// one the build is for, detects the package manager as there. Of the project held in `vfs`, from
|
|
61
|
+
// `cwd`; or of a GitHub repo, `{ github, sha, tag, directory, client }` as buildGitHubBundle takes
|
|
62
|
+
// them, downloaded as it downloads them.
|
|
63
|
+
export async function suggestedEntries({ vfs, cwd = '/', packageManager, conditions, mainFields, metro, platforms, jsx, typescript, os, ...repo } = {}) {
|
|
64
|
+
checkTarget('suggestedEntries', { os })
|
|
65
|
+
const resolution = { conditions, mainFields, metro, platforms, jsx, typescript }
|
|
66
|
+
if (vfs === undefined && repo.github === undefined) throw new Error('suggestedEntries: a vfs or a github repo is required')
|
|
67
|
+
if (vfs !== undefined && repo.github !== undefined) throw new Error('suggestedEntries: takes a vfs or a github repo, not both')
|
|
68
|
+
if (vfs === undefined) return suggestedRepoEntries({ ...repo, packageManager }, { ...resolution, os })
|
|
69
|
+
checkVfs('suggestedEntries', vfs)
|
|
70
|
+
const host = vfsHost(vfs)
|
|
71
|
+
const at = posix.resolve('/', cwd)
|
|
72
|
+
const pm = checkAhead('suggestedEntries', packageManager ?? detectPackageManager('suggestedEntries', host, at, { os }), resolution)
|
|
73
|
+
return KINDS[pm.kind].entries(host, at, resolution)
|
|
74
|
+
}
|
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
|
+
}
|
package/src/apis/npm/index.js
DELETED
|
@@ -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
|
-
}
|
package/src/apis/npm/semver.cjs
DELETED
|
@@ -1,42 +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
|
-
assert(['node', 'node.exe'].includes(basename(argv0)))
|
|
14
|
-
assert.equal(basename(argv0), basename(process.argv0))
|
|
15
|
-
|
|
16
|
-
const semverDir = resolve(dirname(argv0), '../lib/node_modules/npm/node_modules/semver')
|
|
17
|
-
semver = require(semverDir)
|
|
18
|
-
return semver
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
exports.compare = (...a) => getSemver().compare(...a)
|
|
22
|
-
exports.gtr = (...a) => getSemver().gtr(...a)
|
|
23
|
-
exports.lt = (...a) => getSemver().lt(...a)
|
|
24
|
-
exports.lte = (...a) => getSemver().lte(...a)
|
|
25
|
-
exports.ltr = (...a) => getSemver().ltr(...a)
|
|
26
|
-
exports.satisfies = (...a) => getSemver().satisfies(...a)
|
|
27
|
-
exports.validRange = (...a) => getSemver().validRange(...a)
|
|
28
|
-
|
|
29
|
-
exports.valid = (version, options) => {
|
|
30
|
-
// Fast path for certainly valid versions
|
|
31
|
-
if (
|
|
32
|
-
options === undefined &&
|
|
33
|
-
typeof version === 'string' &&
|
|
34
|
-
version.length <= 256 &&
|
|
35
|
-
// 1-15 chars because it must fit in Number.MAX_SAFE_INTEGER
|
|
36
|
-
/^(0|[1-9]\d{0,14})\.(0|[1-9]\d{0,14})\.(0|[1-9]\d{0,14})$/iu.test(version)
|
|
37
|
-
) {
|
|
38
|
-
return version
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
return getSemver().valid(version, options)
|
|
42
|
-
}
|