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.
@@ -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
- if (manager === 'npm') return parseNpmLock(lockPath, lockfileName, root);
76
- if (manager === 'yarn') return parseYarnLock(lockPath, lockfileName, root);
77
- if (manager === 'pnpm') return parsePnpmLock(lockPath, lockfileName, root);
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, lockfileName, root) {
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
- components = parsePackagesField(lock.packages);
101
- } else if (lock.dependencies) {
102
- components = parseDependenciesField(lock.dependencies);
103
- } else {
104
- components = [];
125
+ return { lockfileVersion, ...parsePackagesField(lock.packages) };
105
126
  }
106
-
107
- return { ok: true, manager: 'npm', lockfileName, lockfileVersion, root, components };
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
- return dedupe(components);
195
+ const rootDependsOn = packages[''] ? edges('', packages[''], true) : [];
196
+ return { components, rootDependsOn, complete };
135
197
  }
136
198
 
137
- /** Lockfile v1 nests dependencies recursively under `dependencies`. */
138
- function parseDependenciesField(dependencies, acc = []) {
139
- for (const [name, info] of Object.entries(dependencies)) {
140
- acc.push(normalizeComponent({
141
- name,
142
- version: info.version,
143
- resolved: info.resolved,
144
- integrity: info.integrity,
145
- license: info.license,
146
- dev: Boolean(info.dev),
147
- optional: Boolean(info.optional),
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 dedupe(acc);
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, lockfileName, root) {
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 components = isBerry ? parseYarnBerry(content) : parseYarnClassic(content);
170
- return {
171
- ok: true,
172
- manager: 'yarn',
173
- lockfileName,
174
- lockfileVersion: isBerry ? 'berry' : 1,
175
- root,
176
- components: dedupe(components),
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 components = [];
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
- if (line.startsWith('version ')) version = stripQuotes(line.slice('version '.length).trim());
203
- else if (line.startsWith('resolved ')) resolved = stripQuotes(line.slice('resolved '.length).trim());
204
- else if (line.startsWith('integrity ')) integrity = stripQuotes(line.slice('integrity '.length).trim());
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
- components.push(normalizeComponent({ name, version, resolved, integrity, path: `node_modules/${name}` }));
369
+ entries.push({ descriptors, name, version, resolved, integrity, dependencies, workspace: false });
209
370
  }
210
- return components;
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 components = [];
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 || String(key).split(',')[0].trim());
385
+ const name = packageNameFromDescriptor(resolution || descriptors[0]);
224
386
  if (!name) continue;
225
387
 
226
- components.push(normalizeComponent({
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
- path: `node_modules/${name}`,
232
- }));
405
+ dependencies,
406
+ workspace: /@workspace:/.test(resolution),
407
+ });
233
408
  }
234
- return components;
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, lockfileName, root) {
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
- const components = [];
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
- return {
259
- ok: true,
260
- manager: 'pnpm',
261
- lockfileName,
262
- lockfileVersion: doc.lockfileVersion || null,
263
- root,
264
- components: dedupe(components),
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 = `${comp.name}@${comp.version}`;
381
- if (!seen.has(key)) {
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 };