@dependably/npm-check 1.9.0 → 1.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,289 @@
1
+ // src/facts/modulegraph.js
2
+ // The statically resolved module graph THROUGH node_modules. Every first-party
3
+ // file's imports are resolved to the installed file they load, that file is
4
+ // parsed with the same parse-only scanner, its imports are resolved in turn,
5
+ // and so on until the graph is exhausted or a budget runs out. The result is,
6
+ // per installed package copy, the import sites that load it and the chain of
7
+ // packages between first-party code and it.
8
+ //
9
+ // It is a MODULE graph, not a call graph: an edge means "evaluating this
10
+ // module evaluates that one" (top-level `import` / `require`), which is what
11
+ // makes a package's code present and executable in the process. Whether a
12
+ // specific function is then CALLED is a question for the consumer's symbol
13
+ // layer, fed by the importers this walker collects — including the ones
14
+ // inside node_modules.
15
+ //
16
+ // Honesty rules, all reported on the result so the consumer can weaken any
17
+ // negative it draws (a fact this walk could not establish is reported as a
18
+ // gap, never silently dropped):
19
+ // - a non-literal `require()`/`import()` inside a package marks that
20
+ // package `dynamic`: it can load things this walk cannot see;
21
+ // - a file skipped for size (bundled 5 MB `dist/` files are common) or a
22
+ // file that resolved but could not be read marks its package `incomplete`
23
+ // — some of its own edges are unknown — AND is listed in `unanalyzable`;
24
+ // a RELATIVE import inside a package that does not resolve marks the
25
+ // package `incomplete` too, but there is no file to list for it (nothing
26
+ // was found to skip), so it appears nowhere else;
27
+ // - an unresolvable BARE specifier is recorded under the package name it
28
+ // asked for (`unresolvedByName`), so the gap is attributable precisely;
29
+ // the walk stopping on the file budget sets `truncated` and counts the
30
+ // files past the frontier (`filesPastBudget`).
31
+ //
32
+ // Ported from sbom-reach's `packages/analyzer-npm/src/modulegraph.ts`;
33
+ // `unanalyzable` and `filesPastBudget` are additive.
34
+ import { readFileSync, statSync } from 'node:fs';
35
+ import { relative, sep } from 'node:path';
36
+
37
+ /** @typedef {import('./types.d.ts').ImportKind} ImportKind */
38
+ /** @typedef {import('./types.d.ts').ImportSite} ImportSite */
39
+ /** @typedef {import('./types.d.ts').PackageInfo} PackageInfo */
40
+ /** @typedef {import('./types.d.ts').ResolveMode} ResolveMode */
41
+ /** @typedef {import('./types.d.ts').GraphImporter} GraphImporter */
42
+ /** @typedef {import('./types.d.ts').ReachedPackage} ReachedPackage */
43
+ /** @typedef {import('./types.d.ts').ModuleGraph} ModuleGraph */
44
+ /** @typedef {import('./types.d.ts').WalkOptions} WalkOptions */
45
+
46
+ export const DEFAULT_MAX_FILES = 25_000;
47
+ export const DEFAULT_MAX_FILE_BYTES = 1_500_000;
48
+
49
+ /**
50
+ * `${name}@${version}\0${root}` — one key per INSTALLED COPY, since two copies
51
+ * of one version can differ only by location and one lockfile can hold
52
+ * several versions. The `\0` separator cannot occur in a name, a version or a
53
+ * path, so the key splits back unambiguously.
54
+ * @param {PackageInfo} pkg
55
+ * @returns {string}
56
+ */
57
+ export function packageKey(pkg) {
58
+ return `${pkg.name.toLowerCase()}@${pkg.version}\0${pkg.root}`;
59
+ }
60
+
61
+ /**
62
+ * @param {ImportKind} kind
63
+ * @returns {ResolveMode}
64
+ */
65
+ function modeFor(kind) {
66
+ return kind === 'require' ? 'require' : 'import';
67
+ }
68
+
69
+ /**
70
+ * @param {WalkOptions} opts
71
+ * @returns {ModuleGraph}
72
+ */
73
+ export function walkModuleGraph(opts) {
74
+ const maxFiles = opts.maxFiles ?? DEFAULT_MAX_FILES;
75
+ const maxFileBytes = opts.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
76
+ /** @type {Map<string, ReachedPackage>} */
77
+ const reached = new Map();
78
+ /** @type {Set<string>} */
79
+ const visited = new Set();
80
+ let filesParsed = 0;
81
+ let filesSkippedForSize = 0;
82
+ let filesPastBudget = 0;
83
+ let unresolved = 0;
84
+ let truncated = false;
85
+ /** @type {Map<string, { file: string; reason: string }[]>} */
86
+ const unresolvedByName = new Map();
87
+ /** @type {{ file: string; reason: string }[]} */
88
+ const unanalyzable = [];
89
+ /** Package key of `item` → its entry, to flag `incomplete` from inside the loop.
90
+ * @param {string | undefined} key
91
+ * @returns {ReachedPackage | undefined} */
92
+ const entryOf = (key) => (key === undefined ? undefined : reached.get(key));
93
+
94
+ /**
95
+ * @typedef {object} QueueItem
96
+ * @property {string} file
97
+ * @property {ImportSite[]} sites
98
+ * @property {string | undefined} fromPackage
99
+ * @property {string[]} chain
100
+ */
101
+ /** @type {QueueItem[]} */
102
+ const queue = [];
103
+
104
+ for (const root of opts.roots) {
105
+ queue.push({ file: root.file, sites: root.scan.sites, fromPackage: undefined, chain: [] });
106
+ }
107
+
108
+ /**
109
+ * @param {PackageInfo} pkg
110
+ * @param {GraphImporter} importer
111
+ * @param {string[]} chain
112
+ * @returns {ReachedPackage}
113
+ */
114
+ const reach = (pkg, importer, chain) => {
115
+ const key = packageKey(pkg);
116
+ let entry = reached.get(key);
117
+ if (entry === undefined) {
118
+ entry = {
119
+ key,
120
+ name: pkg.name,
121
+ dirName: pkg.dirName,
122
+ version: pkg.version,
123
+ root: pkg.root,
124
+ importers: [],
125
+ chain: [...chain, key],
126
+ dynamic: false,
127
+ incomplete: false
128
+ };
129
+ reached.set(key, entry);
130
+ }
131
+ // A package's own internal wiring (`require('./processor')`) is how the
132
+ // walk gets THROUGH it, not evidence that anything loads it: recording
133
+ // it inflated evidence ~70× on a real tree, pushed the one external
134
+ // importer past a consumer's evidence cap, and made a package's own
135
+ // `import { danger } from './x.js'` count as the vulnerable symbol being
136
+ // used by a consumer.
137
+ if (importer.fromPackage !== key) entry.importers.push(importer);
138
+ return entry;
139
+ };
140
+
141
+ while (queue.length > 0) {
142
+ const item = /** @type {QueueItem} */ (queue.shift());
143
+ for (const site of item.sites) {
144
+ // A type-only import never loads code at runtime; it is first-party
145
+ // evidence (kept by the caller) but not an edge of this graph.
146
+ if (site.kind === 'type-only-import') continue;
147
+ const resolution = opts.resolver.resolve(item.file, site.specifier, modeFor(site.kind));
148
+ if (resolution.kind === 'unresolved') {
149
+ unresolved++;
150
+ const bare = bareNameOf(site.specifier);
151
+ if (bare !== undefined) {
152
+ const list = unresolvedByName.get(bare) ?? [];
153
+ if (list.length < 5) list.push({ file: item.file, reason: resolution.reason });
154
+ unresolvedByName.set(bare, list);
155
+ } else {
156
+ // A relative/`#` import inside a package that goes nowhere: that
157
+ // package's own edges are not all known.
158
+ const owner = entryOf(item.fromPackage);
159
+ if (owner !== undefined) owner.incomplete = true;
160
+ }
161
+ continue;
162
+ }
163
+ if (resolution.kind !== 'file' && resolution.kind !== 'asset') continue;
164
+ const pkg = resolution.pkg;
165
+ // Resolved into first-party code (a relative import, a hoisted
166
+ // workspace package): first-party files are roots already, and a
167
+ // first-party file the caller chose not to scan (gitignored build
168
+ // output) is not something to start parsing here.
169
+ if (pkg === undefined) continue;
170
+ /** @type {GraphImporter} */
171
+ const importer = {
172
+ file: item.file,
173
+ line: site.line,
174
+ snippet: site.snippet,
175
+ kind: site.kind,
176
+ ...(site.bindings ? { bindings: site.bindings } : {}),
177
+ ...(site.referenced ? { referenced: site.referenced } : {}),
178
+ ...(site.opaque ? { opaque: true } : {}),
179
+ ...(item.fromPackage !== undefined ? { fromPackage: item.fromPackage } : {})
180
+ };
181
+ const entry = reach(pkg, importer, item.chain);
182
+ if (resolution.kind === 'asset') continue;
183
+ if (visited.has(resolution.path)) continue;
184
+ visited.add(resolution.path);
185
+ if (filesParsed >= maxFiles) {
186
+ truncated = true;
187
+ filesPastBudget++;
188
+ continue;
189
+ }
190
+ /** @type {number} */
191
+ let size;
192
+ try {
193
+ size = statSync(resolution.path).size;
194
+ } catch (err) {
195
+ entry.incomplete = true;
196
+ unanalyzable.push({ file: resolution.path, reason: `unreadable: ${errorCode(err)}` });
197
+ continue;
198
+ }
199
+ if (size > maxFileBytes) {
200
+ filesSkippedForSize++;
201
+ entry.incomplete = true;
202
+ unanalyzable.push({ file: resolution.path, reason: `too large to parse: ${size} bytes exceeds the ${maxFileBytes}-byte limit` });
203
+ continue;
204
+ }
205
+ /** @type {string} */
206
+ let content;
207
+ try {
208
+ content = readFileSync(resolution.path, 'utf8');
209
+ } catch (err) {
210
+ entry.incomplete = true;
211
+ unanalyzable.push({ file: resolution.path, reason: `unreadable: ${errorCode(err)}` });
212
+ continue;
213
+ }
214
+ filesParsed++;
215
+ const rel = relative(opts.srcDir, resolution.path).split(sep).join('/');
216
+ const scan = opts.scan(rel, content);
217
+ if (scan.dynamicUnknown > 0) entry.dynamic = true;
218
+ queue.push({ file: resolution.path, sites: scan.sites, fromPackage: entry.key, chain: entry.chain });
219
+ }
220
+ }
221
+
222
+ /** @type {Map<string, ReachedPackage[]>} */
223
+ const byNameVersion = new Map();
224
+ /** @type {Map<string, ReachedPackage[]>} */
225
+ const byName = new Map();
226
+ /** @type {Set<string>} */
227
+ const weak = new Set();
228
+ for (const entry of reached.values()) {
229
+ // Indexed under both spellings when they differ (aliased installs), so a
230
+ // lockfile-named component and a package.json-named one both hit.
231
+ const names = new Set([entry.name.toLowerCase(), entry.dirName.toLowerCase()]);
232
+ for (const name of names) {
233
+ push(byNameVersion, `${name}@${entry.version}`, entry);
234
+ push(byName, name, entry);
235
+ }
236
+ if (entry.dynamic || entry.incomplete) weak.add(`${entry.name}@${entry.version}`);
237
+ }
238
+ return {
239
+ reached,
240
+ byNameVersion,
241
+ byName,
242
+ filesParsed,
243
+ filesSkippedForSize,
244
+ filesPastBudget,
245
+ unresolved,
246
+ unresolvedByName,
247
+ truncated,
248
+ weakPackages: [...weak].sort(),
249
+ unanalyzable
250
+ };
251
+ }
252
+
253
+ /**
254
+ * The package name a bare specifier asks for (lower-cased), or undefined for
255
+ * a relative/absolute/`#` one.
256
+ * @param {string} specifier
257
+ * @returns {string | undefined}
258
+ */
259
+ function bareNameOf(specifier) {
260
+ if (specifier.length === 0 || specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('#')) return undefined;
261
+ if (/^[a-z][a-z0-9+.-]*:/i.test(specifier)) return undefined;
262
+ const parts = specifier.split('/');
263
+ if (specifier.startsWith('@')) return parts.length >= 2 ? `${parts[0]}/${parts[1]}`.toLowerCase() : undefined;
264
+ return parts[0].toLowerCase();
265
+ }
266
+
267
+ /**
268
+ * @template K, V
269
+ * @param {Map<K, V[]>} map
270
+ * @param {K} key
271
+ * @param {V} value
272
+ */
273
+ function push(map, key, value) {
274
+ const list = map.get(key);
275
+ if (list === undefined) map.set(key, [value]);
276
+ else list.push(value);
277
+ }
278
+
279
+ /**
280
+ * A path-free spelling of an I/O failure (Node's message embeds the absolute
281
+ * path, and `unanalyzable[].reason` must compare across machines): the
282
+ * `code` when there is one, else the constructor name.
283
+ * @param {unknown} err
284
+ * @returns {string}
285
+ */
286
+ function errorCode(err) {
287
+ if (err && typeof err === 'object' && 'code' in err && typeof err.code === 'string') return err.code;
288
+ return err instanceof Error ? err.name : 'error';
289
+ }