@dependably/npm-check 1.8.0 → 1.10.0
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 +47 -0
- package/bin/cli.js +93 -1
- package/package.json +24 -6
- package/src/audit-config.js +127 -75
- package/src/audit.js +235 -35
- package/src/exceptions.js +87 -70
- package/src/facts/collect.js +201 -0
- package/src/facts/document.js +140 -0
- package/src/facts/errors.js +19 -0
- package/src/facts/index.js +36 -0
- package/src/facts/lockfile-graph.js +467 -0
- package/src/facts/modulegraph.js +289 -0
- package/src/facts/resolve.js +486 -0
- package/src/facts/scan.js +755 -0
- package/src/facts/specifier.js +48 -0
- package/src/facts/ts.js +47 -0
- package/src/facts/types.d.ts +493 -0
- package/src/facts/version.js +11 -0
- package/src/facts/workspace.js +183 -0
- package/src/npmrc-validator.js +2 -1
- package/src/pnpm-workspace-validator.js +8 -2
- package/src/report.js +14 -6
- package/src/schema.js +54 -0
|
@@ -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
|
+
}
|