cra-audit 1.0.0 → 2.0.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/CHANGELOG.md +61 -0
- package/README.md +60 -10
- package/package.json +16 -2
- package/src/cli.js +10 -2
- package/src/commands/audit.js +6 -3
- package/src/commands/sbom.js +17 -6
- package/src/commands/visualize.js +8 -4
- package/src/core/auditor.js +63 -10
- package/src/core/installed-metadata.js +179 -0
- package/src/core/kev.js +44 -0
- package/src/core/license-checker.js +2 -27
- package/src/core/lockfile-parser.js +306 -67
- package/src/core/osv.js +211 -0
- package/src/core/policy.js +8 -0
- package/src/core/sbom-generator.js +330 -146
- package/src/core/sbom-validator.js +228 -124
- package/src/core/vuln-scanner.js +161 -11
- package/src/index.js +1 -1
- package/src/reporters/console.js +55 -13
- package/src/reporters/json.js +1 -1
- package/src/utils/http.js +11 -4
|
@@ -29,10 +29,17 @@ const LOCKFILES = [
|
|
|
29
29
|
* manager: 'npm'|'yarn'|'pnpm'|null,
|
|
30
30
|
* lockfileName: string|null,
|
|
31
31
|
* lockfileVersion: number|string|null,
|
|
32
|
-
* root: { name: string, version: string },
|
|
32
|
+
* root: { name: string, version: string, dependsOn: string[] },
|
|
33
33
|
* components: Array<object>,
|
|
34
|
+
* dependencyGraphComplete: boolean,
|
|
34
35
|
* error?: string
|
|
35
36
|
* }}
|
|
37
|
+
*
|
|
38
|
+
* Every component carries `dependsOn`: the `name@version` keys of the
|
|
39
|
+
* components it directly depends on (TR-03183 §5.2.2 "Dependencies on other
|
|
40
|
+
* components"). `root.dependsOn` lists the project's direct dependencies.
|
|
41
|
+
* `dependencyGraphComplete` is false when a required dependency could not be
|
|
42
|
+
* resolved in the lockfile, so the SBOM can declare the graph as incomplete.
|
|
36
43
|
*/
|
|
37
44
|
function parseLockfile(projectRoot) {
|
|
38
45
|
let lockPath = null;
|
|
@@ -54,6 +61,7 @@ function parseLockfile(projectRoot) {
|
|
|
54
61
|
const root = {
|
|
55
62
|
name: rootPkg.name || path.basename(projectRoot),
|
|
56
63
|
version: rootPkg.version || '0.0.0',
|
|
64
|
+
dependsOn: [],
|
|
57
65
|
};
|
|
58
66
|
|
|
59
67
|
if (!lockPath) {
|
|
@@ -64,6 +72,7 @@ function parseLockfile(projectRoot) {
|
|
|
64
72
|
lockfileVersion: null,
|
|
65
73
|
root,
|
|
66
74
|
components: [],
|
|
75
|
+
dependencyGraphComplete: false,
|
|
67
76
|
error:
|
|
68
77
|
'No lockfile found (package-lock.json, npm-shrinkwrap.json, ' +
|
|
69
78
|
'yarn.lock or pnpm-lock.yaml). The CRA requires deterministic dependency ' +
|
|
@@ -72,9 +81,21 @@ function parseLockfile(projectRoot) {
|
|
|
72
81
|
}
|
|
73
82
|
|
|
74
83
|
try {
|
|
75
|
-
|
|
76
|
-
if (manager === '
|
|
77
|
-
if (manager === '
|
|
84
|
+
let result = null;
|
|
85
|
+
if (manager === 'npm') result = parseNpmLock(lockPath, rootPkg);
|
|
86
|
+
else if (manager === 'yarn') result = parseYarnLock(lockPath, rootPkg);
|
|
87
|
+
else if (manager === 'pnpm') result = parsePnpmLock(lockPath);
|
|
88
|
+
|
|
89
|
+
root.dependsOn = uniqueSorted(result.rootDependsOn);
|
|
90
|
+
return {
|
|
91
|
+
ok: true,
|
|
92
|
+
manager,
|
|
93
|
+
lockfileName,
|
|
94
|
+
lockfileVersion: result.lockfileVersion,
|
|
95
|
+
root,
|
|
96
|
+
components: dedupe(result.components),
|
|
97
|
+
dependencyGraphComplete: result.complete,
|
|
98
|
+
};
|
|
78
99
|
} catch (err) {
|
|
79
100
|
return {
|
|
80
101
|
ok: false,
|
|
@@ -83,33 +104,72 @@ function parseLockfile(projectRoot) {
|
|
|
83
104
|
lockfileVersion: null,
|
|
84
105
|
root,
|
|
85
106
|
components: [],
|
|
107
|
+
dependencyGraphComplete: false,
|
|
86
108
|
error: `Could not parse ${lockfileName}: ${err.message}`,
|
|
87
109
|
};
|
|
88
110
|
}
|
|
89
|
-
|
|
90
|
-
return { ok: true, manager, lockfileName, lockfileVersion: null, root, components: [] };
|
|
91
111
|
}
|
|
92
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Marker returned by resolvers for dependencies that point to a local
|
|
115
|
+
* workspace/link: they are first-party code, not an unresolved dependency.
|
|
116
|
+
*/
|
|
117
|
+
const LINK = Symbol('link');
|
|
118
|
+
|
|
93
119
|
/** npm: package-lock.json (v1/v2/v3) or npm-shrinkwrap.json. */
|
|
94
|
-
function parseNpmLock(lockPath,
|
|
120
|
+
function parseNpmLock(lockPath, rootPkg) {
|
|
95
121
|
const lock = readJson(lockPath);
|
|
96
122
|
const lockfileVersion = lock.lockfileVersion || 1;
|
|
97
123
|
|
|
98
|
-
let components;
|
|
99
124
|
if (lock.packages) {
|
|
100
|
-
|
|
101
|
-
} else if (lock.dependencies) {
|
|
102
|
-
components = parseDependenciesField(lock.dependencies);
|
|
103
|
-
} else {
|
|
104
|
-
components = [];
|
|
125
|
+
return { lockfileVersion, ...parsePackagesField(lock.packages) };
|
|
105
126
|
}
|
|
106
|
-
|
|
107
|
-
|
|
127
|
+
if (lock.dependencies) {
|
|
128
|
+
return { lockfileVersion, ...parseDependenciesField(lock.dependencies, rootPkg) };
|
|
129
|
+
}
|
|
130
|
+
return { lockfileVersion, components: [], rootDependsOn: [], complete: true };
|
|
108
131
|
}
|
|
109
132
|
|
|
110
133
|
/** Lockfile v2/v3 store everything under the `packages` map keyed by path. */
|
|
111
134
|
function parsePackagesField(packages) {
|
|
112
135
|
const components = [];
|
|
136
|
+
let complete = true;
|
|
137
|
+
|
|
138
|
+
// Node's module resolution: look in `<from>/node_modules/<dep>`, then walk
|
|
139
|
+
// up through every enclosing node_modules folder until the project root.
|
|
140
|
+
const resolve = (fromPath, depName) => {
|
|
141
|
+
let base = fromPath;
|
|
142
|
+
for (;;) {
|
|
143
|
+
const info = packages[`${base ? `${base}/` : ''}node_modules/${depName}`];
|
|
144
|
+
if (info) {
|
|
145
|
+
if (info.link) return LINK;
|
|
146
|
+
return componentKey(info.name || depName, info.version);
|
|
147
|
+
}
|
|
148
|
+
if (!base) return null;
|
|
149
|
+
const idx = base.lastIndexOf('/node_modules/');
|
|
150
|
+
base = idx === -1 ? '' : base.slice(0, idx);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const edges = (fromPath, info, includeDev) => {
|
|
155
|
+
const optional = new Set([
|
|
156
|
+
...Object.keys(info.optionalDependencies || {}),
|
|
157
|
+
...Object.keys(info.peerDependencies || {}),
|
|
158
|
+
]);
|
|
159
|
+
const names = new Set([
|
|
160
|
+
...Object.keys(info.dependencies || {}),
|
|
161
|
+
...optional,
|
|
162
|
+
...(includeDev ? Object.keys(info.devDependencies || {}) : []),
|
|
163
|
+
]);
|
|
164
|
+
const dependsOn = [];
|
|
165
|
+
for (const depName of names) {
|
|
166
|
+
const ref = resolve(fromPath, depName);
|
|
167
|
+
if (ref === LINK) continue;
|
|
168
|
+
if (ref) dependsOn.push(ref);
|
|
169
|
+
else if (!optional.has(depName)) complete = false;
|
|
170
|
+
}
|
|
171
|
+
return dependsOn;
|
|
172
|
+
};
|
|
113
173
|
|
|
114
174
|
for (const [pkgPath, info] of Object.entries(packages)) {
|
|
115
175
|
// The root project is stored under the empty key.
|
|
@@ -128,30 +188,81 @@ function parsePackagesField(packages) {
|
|
|
128
188
|
dev: Boolean(info.dev),
|
|
129
189
|
optional: Boolean(info.optional),
|
|
130
190
|
path: pkgPath,
|
|
191
|
+
dependsOn: edges(pkgPath, info, false),
|
|
131
192
|
}));
|
|
132
193
|
}
|
|
133
194
|
|
|
134
|
-
|
|
195
|
+
const rootDependsOn = packages[''] ? edges('', packages[''], true) : [];
|
|
196
|
+
return { components, rootDependsOn, complete };
|
|
135
197
|
}
|
|
136
198
|
|
|
137
|
-
/**
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
path: `node_modules/${name}`,
|
|
149
|
-
}));
|
|
150
|
-
if (info.dependencies) {
|
|
151
|
-
parseDependenciesField(info.dependencies, acc);
|
|
199
|
+
/**
|
|
200
|
+
* Lockfile v1 nests dependencies recursively under `dependencies`; each entry
|
|
201
|
+
* lists what it needs under `requires`, resolved against the nearest scope.
|
|
202
|
+
*/
|
|
203
|
+
function parseDependenciesField(dependencies, rootPkg) {
|
|
204
|
+
const components = [];
|
|
205
|
+
let complete = true;
|
|
206
|
+
|
|
207
|
+
const lookup = (scopes, name) => {
|
|
208
|
+
for (const scope of scopes) {
|
|
209
|
+
if (scope[name]) return scope[name];
|
|
152
210
|
}
|
|
211
|
+
return null;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const walk = (deps, outerScopes, prefix) => {
|
|
215
|
+
const scopes = [deps, ...outerScopes];
|
|
216
|
+
for (const [name, info] of Object.entries(deps)) {
|
|
217
|
+
const ownScopes = info.dependencies ? [info.dependencies, ...scopes] : scopes;
|
|
218
|
+
const dependsOn = [];
|
|
219
|
+
for (const req of Object.keys(info.requires || {})) {
|
|
220
|
+
const found = lookup(ownScopes, req);
|
|
221
|
+
if (found) dependsOn.push(componentKey(req, found.version));
|
|
222
|
+
else complete = false;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const pkgPath = `${prefix}node_modules/${name}`;
|
|
226
|
+
components.push(normalizeComponent({
|
|
227
|
+
name,
|
|
228
|
+
version: info.version,
|
|
229
|
+
resolved: info.resolved,
|
|
230
|
+
integrity: info.integrity,
|
|
231
|
+
license: info.license,
|
|
232
|
+
dev: Boolean(info.dev),
|
|
233
|
+
optional: Boolean(info.optional),
|
|
234
|
+
path: pkgPath,
|
|
235
|
+
dependsOn,
|
|
236
|
+
}));
|
|
237
|
+
if (info.dependencies) walk(info.dependencies, scopes, `${pkgPath}/`);
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
walk(dependencies, [], '');
|
|
241
|
+
|
|
242
|
+
const rootDependsOn = [];
|
|
243
|
+
for (const { name, optional } of rootDependencies(rootPkg)) {
|
|
244
|
+
const found = dependencies[name];
|
|
245
|
+
if (found) rootDependsOn.push(componentKey(name, found.version));
|
|
246
|
+
else if (!optional) complete = false;
|
|
153
247
|
}
|
|
154
|
-
return
|
|
248
|
+
return { components, rootDependsOn, complete };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Direct dependencies declared in the project's package.json, including
|
|
253
|
+
* dev and optional ones, with the range each was requested with.
|
|
254
|
+
*/
|
|
255
|
+
function rootDependencies(rootPkg) {
|
|
256
|
+
const list = [];
|
|
257
|
+
const add = (map, optional) => {
|
|
258
|
+
for (const [name, range] of Object.entries(map || {})) {
|
|
259
|
+
list.push({ name, range: String(range), optional });
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
add(rootPkg.dependencies, false);
|
|
263
|
+
add(rootPkg.devDependencies, false);
|
|
264
|
+
add(rootPkg.optionalDependencies, true);
|
|
265
|
+
return list;
|
|
155
266
|
}
|
|
156
267
|
|
|
157
268
|
function nameFromPath(pkgPath) {
|
|
@@ -163,18 +274,52 @@ function nameFromPath(pkgPath) {
|
|
|
163
274
|
// --- Yarn ------------------------------------------------------------------
|
|
164
275
|
|
|
165
276
|
/** Yarn: dispatches between the classic v1 text format and Berry's YAML. */
|
|
166
|
-
function parseYarnLock(lockPath,
|
|
277
|
+
function parseYarnLock(lockPath, rootPkg) {
|
|
167
278
|
const content = fs.readFileSync(lockPath, 'utf8');
|
|
168
279
|
const isBerry = /^__metadata:/m.test(content) || /\n {2}resolution:/.test(content);
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
280
|
+
const entries = isBerry ? parseYarnBerry(content) : parseYarnClassic(content);
|
|
281
|
+
|
|
282
|
+
// Both formats key entries by the `name@range` descriptors that resolved
|
|
283
|
+
// to them, so edges are resolved by looking descriptors up.
|
|
284
|
+
const byDescriptor = new Map();
|
|
285
|
+
for (const entry of entries) {
|
|
286
|
+
for (const descriptor of entry.descriptors) {
|
|
287
|
+
byDescriptor.set(descriptor, entry.workspace ? LINK : componentKey(entry.name, entry.version));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
let complete = true;
|
|
292
|
+
const resolve = (name, range, optional) => {
|
|
293
|
+
const ref = byDescriptor.get(`${name}@${range}`) ||
|
|
294
|
+
(!range.includes(':') ? byDescriptor.get(`${name}@npm:${range}`) : undefined);
|
|
295
|
+
if (ref === LINK) return null;
|
|
296
|
+
if (ref) return ref;
|
|
297
|
+
// Local protocols never appear as third-party lockfile entries.
|
|
298
|
+
if (!optional && !/^(workspace|link|portal|file):/.test(range)) complete = false;
|
|
299
|
+
return null;
|
|
177
300
|
};
|
|
301
|
+
|
|
302
|
+
const components = [];
|
|
303
|
+
for (const entry of entries) {
|
|
304
|
+
if (entry.workspace) continue; // first-party workspace, not a third-party component
|
|
305
|
+
const dependsOn = entry.dependencies
|
|
306
|
+
.map((d) => resolve(d.name, d.range, d.optional))
|
|
307
|
+
.filter(Boolean);
|
|
308
|
+
components.push(normalizeComponent({
|
|
309
|
+
name: entry.name,
|
|
310
|
+
version: entry.version,
|
|
311
|
+
resolved: entry.resolved,
|
|
312
|
+
integrity: entry.integrity,
|
|
313
|
+
path: `node_modules/${entry.name}`,
|
|
314
|
+
dependsOn,
|
|
315
|
+
}));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const rootDependsOn = rootDependencies(rootPkg)
|
|
319
|
+
.map((d) => resolve(d.name, d.range, d.optional))
|
|
320
|
+
.filter(Boolean);
|
|
321
|
+
|
|
322
|
+
return { lockfileVersion: isBerry ? 'berry' : 1, components, rootDependsOn, complete };
|
|
178
323
|
}
|
|
179
324
|
|
|
180
325
|
/**
|
|
@@ -182,7 +327,7 @@ function parseYarnLock(lockPath, lockfileName, root) {
|
|
|
182
327
|
* resolved entry whose header lists one or more `name@range` descriptors.
|
|
183
328
|
*/
|
|
184
329
|
function parseYarnClassic(content) {
|
|
185
|
-
const
|
|
330
|
+
const entries = [];
|
|
186
331
|
const blocks = content.split(/\r?\n(?=\S)/);
|
|
187
332
|
|
|
188
333
|
for (const block of blocks) {
|
|
@@ -197,51 +342,106 @@ function parseYarnClassic(content) {
|
|
|
197
342
|
let version = null;
|
|
198
343
|
let resolved = null;
|
|
199
344
|
let integrity = null;
|
|
345
|
+
let section = null;
|
|
346
|
+
const dependencies = [];
|
|
200
347
|
for (const raw of lines.slice(1)) {
|
|
201
348
|
const line = raw.trim();
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
349
|
+
const indent = raw.length - raw.trimStart().length;
|
|
350
|
+
if (indent <= 2) {
|
|
351
|
+
section = /^(dependencies|optionalDependencies):$/.test(line) ? line.slice(0, -1) : null;
|
|
352
|
+
if (line.startsWith('version ')) version = stripQuotes(line.slice('version '.length).trim());
|
|
353
|
+
else if (line.startsWith('resolved ')) resolved = stripQuotes(line.slice('resolved '.length).trim());
|
|
354
|
+
else if (line.startsWith('integrity ')) integrity = stripQuotes(line.slice('integrity '.length).trim());
|
|
355
|
+
} else if (section) {
|
|
356
|
+
// `name "range"`, `"@scope/name" "range"` or `name range`.
|
|
357
|
+
const m = line.match(/^(?:"([^"]+)"|(\S+))\s+(?:"([^"]*)"|(\S+))$/);
|
|
358
|
+
if (m) {
|
|
359
|
+
dependencies.push({
|
|
360
|
+
name: m[1] || m[2],
|
|
361
|
+
range: m[3] !== undefined ? m[3] : m[4],
|
|
362
|
+
optional: section === 'optionalDependencies',
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
}
|
|
205
366
|
}
|
|
206
367
|
if (!version) continue;
|
|
207
368
|
|
|
208
|
-
|
|
369
|
+
entries.push({ descriptors, name, version, resolved, integrity, dependencies, workspace: false });
|
|
209
370
|
}
|
|
210
|
-
return
|
|
371
|
+
return entries;
|
|
211
372
|
}
|
|
212
373
|
|
|
213
374
|
/** Yarn Berry (v2+) lockfiles are YAML keyed by descriptor lists. */
|
|
214
375
|
function parseYarnBerry(content) {
|
|
215
376
|
const doc = parseYaml(content) || {};
|
|
216
|
-
const
|
|
377
|
+
const entries = [];
|
|
217
378
|
|
|
218
379
|
for (const [key, info] of Object.entries(doc)) {
|
|
219
380
|
if (key === '__metadata' || !info || typeof info !== 'object') continue;
|
|
220
381
|
if (!info.version) continue;
|
|
221
382
|
|
|
383
|
+
const descriptors = String(key).split(',').map((d) => stripQuotes(d.trim())).filter(Boolean);
|
|
222
384
|
const resolution = typeof info.resolution === 'string' ? info.resolution : '';
|
|
223
|
-
const name = packageNameFromDescriptor(resolution ||
|
|
385
|
+
const name = packageNameFromDescriptor(resolution || descriptors[0]);
|
|
224
386
|
if (!name) continue;
|
|
225
387
|
|
|
226
|
-
|
|
388
|
+
const optionalMeta = info.dependenciesMeta && typeof info.dependenciesMeta === 'object' ? info.dependenciesMeta : {};
|
|
389
|
+
const dependencies = Object.entries(info.dependencies || {}).map(([depName, range]) => ({
|
|
390
|
+
name: depName,
|
|
391
|
+
range: String(range),
|
|
392
|
+
optional: Boolean(optionalMeta[depName] && optionalMeta[depName].optional),
|
|
393
|
+
}));
|
|
394
|
+
for (const [depName, range] of Object.entries(info.optionalDependencies || {})) {
|
|
395
|
+
dependencies.push({ name: depName, range: String(range), optional: true });
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
entries.push({
|
|
399
|
+
descriptors,
|
|
227
400
|
name,
|
|
228
|
-
version: info.version,
|
|
401
|
+
version: String(info.version),
|
|
402
|
+
resolved: null,
|
|
229
403
|
// Berry's `checksum` is a cache key, not an npm SRI hash, so we omit it.
|
|
230
404
|
integrity: null,
|
|
231
|
-
|
|
232
|
-
|
|
405
|
+
dependencies,
|
|
406
|
+
workspace: /@workspace:/.test(resolution),
|
|
407
|
+
});
|
|
233
408
|
}
|
|
234
|
-
return
|
|
409
|
+
return entries;
|
|
235
410
|
}
|
|
236
411
|
|
|
237
412
|
// --- pnpm ------------------------------------------------------------------
|
|
238
413
|
|
|
239
414
|
/** pnpm: `pnpm-lock.yaml` (lockfileVersion 5.x / 6.x / 9.x). */
|
|
240
|
-
function parsePnpmLock(lockPath
|
|
415
|
+
function parsePnpmLock(lockPath) {
|
|
241
416
|
const doc = parseYaml(fs.readFileSync(lockPath, 'utf8')) || {};
|
|
242
417
|
const packages = doc.packages && typeof doc.packages === 'object' ? doc.packages : {};
|
|
243
|
-
|
|
418
|
+
// v9 moved the dependency edges from `packages` to `snapshots`.
|
|
419
|
+
const snapshots = doc.snapshots && typeof doc.snapshots === 'object' ? doc.snapshots : packages;
|
|
420
|
+
|
|
421
|
+
let complete = true;
|
|
422
|
+
const edges = (info) => {
|
|
423
|
+
const dependsOn = [];
|
|
424
|
+
if (!info || typeof info !== 'object') return dependsOn;
|
|
425
|
+
for (const [group, optional] of [['dependencies', false], ['devDependencies', false], ['optionalDependencies', true]]) {
|
|
426
|
+
for (const [depName, value] of Object.entries(info[group] || {})) {
|
|
427
|
+
const ref = pnpmDependencyRef(depName, value);
|
|
428
|
+
if (ref === LINK) continue;
|
|
429
|
+
if (ref) dependsOn.push(ref);
|
|
430
|
+
else if (!optional) complete = false;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return dependsOn;
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
const edgesByKey = new Map();
|
|
437
|
+
for (const [key, info] of Object.entries(snapshots)) {
|
|
438
|
+
const parsed = packageKeyToNameVersion(key);
|
|
439
|
+
if (!parsed) continue;
|
|
440
|
+
const ref = componentKey(parsed.name, parsed.version);
|
|
441
|
+
edgesByKey.set(ref, [...(edgesByKey.get(ref) || []), ...edges(info)]);
|
|
442
|
+
}
|
|
244
443
|
|
|
444
|
+
const components = [];
|
|
245
445
|
for (const [key, info] of Object.entries(packages)) {
|
|
246
446
|
const parsed = packageKeyToNameVersion(key);
|
|
247
447
|
if (!parsed) continue;
|
|
@@ -252,17 +452,37 @@ function parsePnpmLock(lockPath, lockfileName, root) {
|
|
|
252
452
|
version: parsed.version,
|
|
253
453
|
integrity,
|
|
254
454
|
path: `node_modules/${parsed.name}`,
|
|
455
|
+
dependsOn: edgesByKey.get(componentKey(parsed.name, parsed.version)) || [],
|
|
255
456
|
}));
|
|
256
457
|
}
|
|
257
458
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
459
|
+
// Root edges: `importers['.']` (workspaces, and every project since v9) or
|
|
460
|
+
// the top-level maps of older single-project lockfiles.
|
|
461
|
+
const rootImporter = doc.importers && doc.importers['.'] ? doc.importers['.'] : doc;
|
|
462
|
+
const rootDependsOn = edges(rootImporter);
|
|
463
|
+
|
|
464
|
+
return { lockfileVersion: doc.lockfileVersion || null, components, rootDependsOn, complete };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Resolves one pnpm dependency value to a component key. Values are a version
|
|
469
|
+
* (`1.3.0`), a version with a peer suffix (`1.3.0(react@18.0.0)` or v5's
|
|
470
|
+
* `1.3.0_react@18.0.0`), an alias (`npm:other@1.0.0`, `/other@1.0.0`), an
|
|
471
|
+
* importer object `{ specifier, version }`, or a local `link:`.
|
|
472
|
+
*/
|
|
473
|
+
function pnpmDependencyRef(depName, value) {
|
|
474
|
+
let v = value && typeof value === 'object' ? value.version : value;
|
|
475
|
+
if (v === undefined || v === null) return null;
|
|
476
|
+
v = String(v);
|
|
477
|
+
if (/^(link|file|workspace):/.test(v)) return LINK;
|
|
478
|
+
|
|
479
|
+
v = v.replace(/^npm:/, '').split('(')[0];
|
|
480
|
+
if (/^\d/.test(v)) {
|
|
481
|
+
// Plain version; drop v5's `_peer@x` suffix (semver never contains `_`).
|
|
482
|
+
return componentKey(depName, v.split('_')[0]);
|
|
483
|
+
}
|
|
484
|
+
const aliased = packageKeyToNameVersion(v);
|
|
485
|
+
return aliased ? componentKey(aliased.name, aliased.version) : null;
|
|
266
486
|
}
|
|
267
487
|
|
|
268
488
|
/**
|
|
@@ -292,6 +512,8 @@ function packageKeyToNameVersion(key) {
|
|
|
292
512
|
|
|
293
513
|
if (k.startsWith('/')) {
|
|
294
514
|
k = k.slice(1);
|
|
515
|
+
// v5 appends peers after an underscore: `/name/1.0.0_react@18.0.0`.
|
|
516
|
+
if (/^(@[^/]+\/)?[^/@]+\/\d/.test(k)) k = k.replace(/_[^/]*$/, '');
|
|
295
517
|
// v5 uses `/` as separator: name/version (scoped: @scope/name/version).
|
|
296
518
|
if (!k.includes('@', k.startsWith('@') ? 1 : 0) && k.includes('/')) {
|
|
297
519
|
const idx = k.lastIndexOf('/');
|
|
@@ -333,9 +555,19 @@ function normalizeComponent(raw) {
|
|
|
333
555
|
dev: Boolean(raw.dev),
|
|
334
556
|
optional: Boolean(raw.optional),
|
|
335
557
|
path: raw.path,
|
|
558
|
+
dependsOn: uniqueSorted(raw.dependsOn),
|
|
336
559
|
};
|
|
337
560
|
}
|
|
338
561
|
|
|
562
|
+
/** Stable identity of a component across the graph: `name@version`. */
|
|
563
|
+
function componentKey(name, version) {
|
|
564
|
+
return `${name}@${version}`;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function uniqueSorted(list) {
|
|
568
|
+
return [...new Set(list || [])].sort();
|
|
569
|
+
}
|
|
570
|
+
|
|
339
571
|
/**
|
|
340
572
|
* Parses an npm Subresource Integrity string ("sha512-<base64>")
|
|
341
573
|
* into a CycloneDX-friendly { algorithm, hash } pair.
|
|
@@ -357,6 +589,9 @@ function parseIntegrity(integrity) {
|
|
|
357
589
|
} catch {
|
|
358
590
|
hex = null;
|
|
359
591
|
}
|
|
592
|
+
// A digest of the wrong length is corrupt: better no hash than a bogus one.
|
|
593
|
+
const expectedHexLength = { 'SHA-512': 128, 'SHA-384': 96, 'SHA-256': 64, 'SHA-1': 40 }[algorithm];
|
|
594
|
+
if (!hex || hex.length !== expectedHexLength) return { algorithm: null, hash: null };
|
|
360
595
|
return { algorithm, hash: hex };
|
|
361
596
|
}
|
|
362
597
|
|
|
@@ -377,9 +612,13 @@ function buildPurl(name, version) {
|
|
|
377
612
|
function dedupe(components) {
|
|
378
613
|
const seen = new Map();
|
|
379
614
|
for (const comp of components) {
|
|
380
|
-
const key =
|
|
381
|
-
|
|
615
|
+
const key = componentKey(comp.name, comp.version);
|
|
616
|
+
const existing = seen.get(key);
|
|
617
|
+
if (!existing) {
|
|
382
618
|
seen.set(key, comp);
|
|
619
|
+
} else {
|
|
620
|
+
// The same name@version installed at several paths: merge its edges.
|
|
621
|
+
existing.dependsOn = uniqueSorted([...existing.dependsOn, ...comp.dependsOn]);
|
|
383
622
|
}
|
|
384
623
|
}
|
|
385
624
|
return Array.from(seen.values()).sort((a, b) =>
|
|
@@ -436,4 +675,4 @@ function toNpmLockfile(parsed) {
|
|
|
436
675
|
};
|
|
437
676
|
}
|
|
438
677
|
|
|
439
|
-
module.exports = { parseLockfile, buildPurl, parseIntegrity, toNpmLockfile };
|
|
678
|
+
module.exports = { parseLockfile, buildPurl, parseIntegrity, toNpmLockfile, componentKey };
|