cra-audit 0.1.0 → 1.1.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.
@@ -1,33 +1,57 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('node:fs');
3
4
  const path = require('node:path');
4
5
  const { readJson, exists } = require('../utils/fs');
6
+ const { parseYaml } = require('../utils/yaml');
5
7
 
6
8
  /**
7
- * Parses an npm lockfile (package-lock.json v2/v3 or npm-shrinkwrap.json)
8
- * into a normalized list of components. CRA / TR-03183 require traceability
9
- * of every direct and transitive dependency, including integrity hashes.
9
+ * Detection order: npm lockfiles take precedence (they carry the richest
10
+ * metadata and let `npm audit` run natively), followed by Yarn (classic v1
11
+ * and Berry v2+) and pnpm.
12
+ */
13
+ const LOCKFILES = [
14
+ { name: 'package-lock.json', manager: 'npm' },
15
+ { name: 'npm-shrinkwrap.json', manager: 'npm' },
16
+ { name: 'yarn.lock', manager: 'yarn' },
17
+ { name: 'pnpm-lock.yaml', manager: 'pnpm' },
18
+ ];
19
+
20
+ /**
21
+ * Parses the project lockfile (npm `package-lock.json`/`npm-shrinkwrap.json`,
22
+ * Yarn `yarn.lock` classic or Berry, or pnpm `pnpm-lock.yaml`) into a
23
+ * normalized list of components. CRA / TR-03183 require traceability of every
24
+ * direct and transitive dependency, including integrity hashes.
10
25
  *
11
26
  * @param {string} projectRoot
12
27
  * @returns {{
13
28
  * ok: boolean,
29
+ * manager: 'npm'|'yarn'|'pnpm'|null,
14
30
  * lockfileName: string|null,
15
- * lockfileVersion: number|null,
16
- * root: { name: string, version: string },
31
+ * lockfileVersion: number|string|null,
32
+ * root: { name: string, version: string, dependsOn: string[] },
17
33
  * components: Array<object>,
34
+ * dependencyGraphComplete: boolean,
18
35
  * error?: string
19
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.
20
43
  */
21
44
  function parseLockfile(projectRoot) {
22
- const candidates = ['package-lock.json', 'npm-shrinkwrap.json'];
23
45
  let lockPath = null;
24
46
  let lockfileName = null;
47
+ let manager = null;
25
48
 
26
- for (const candidate of candidates) {
27
- const full = path.join(projectRoot, candidate);
49
+ for (const candidate of LOCKFILES) {
50
+ const full = path.join(projectRoot, candidate.name);
28
51
  if (exists(full)) {
29
52
  lockPath = full;
30
- lockfileName = candidate;
53
+ lockfileName = candidate.name;
54
+ manager = candidate.manager;
31
55
  break;
32
56
  }
33
57
  }
@@ -37,45 +61,115 @@ function parseLockfile(projectRoot) {
37
61
  const root = {
38
62
  name: rootPkg.name || path.basename(projectRoot),
39
63
  version: rootPkg.version || '0.0.0',
64
+ dependsOn: [],
40
65
  };
41
66
 
42
67
  if (!lockPath) {
43
68
  return {
44
69
  ok: false,
70
+ manager: null,
45
71
  lockfileName: null,
46
72
  lockfileVersion: null,
47
73
  root,
48
74
  components: [],
75
+ dependencyGraphComplete: false,
49
76
  error:
50
- 'No se encontró package-lock.json ni npm-shrinkwrap.json. ' +
51
- 'El CRA exige trazabilidad determinista de dependencias: ejecuta `npm install` para generar el lockfile.',
77
+ 'No lockfile found (package-lock.json, npm-shrinkwrap.json, ' +
78
+ 'yarn.lock or pnpm-lock.yaml). The CRA requires deterministic dependency ' +
79
+ 'traceability: run `npm install` (or `yarn` / `pnpm install`) to generate the lockfile.',
52
80
  };
53
81
  }
54
82
 
83
+ try {
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
+ };
99
+ } catch (err) {
100
+ return {
101
+ ok: false,
102
+ manager,
103
+ lockfileName,
104
+ lockfileVersion: null,
105
+ root,
106
+ components: [],
107
+ dependencyGraphComplete: false,
108
+ error: `Could not parse ${lockfileName}: ${err.message}`,
109
+ };
110
+ }
111
+ }
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
+
119
+ /** npm: package-lock.json (v1/v2/v3) or npm-shrinkwrap.json. */
120
+ function parseNpmLock(lockPath, rootPkg) {
55
121
  const lock = readJson(lockPath);
56
122
  const lockfileVersion = lock.lockfileVersion || 1;
57
123
 
58
- let components;
59
124
  if (lock.packages) {
60
- components = parsePackagesField(lock.packages);
61
- } else if (lock.dependencies) {
62
- components = parseDependenciesField(lock.dependencies);
63
- } else {
64
- components = [];
125
+ return { lockfileVersion, ...parsePackagesField(lock.packages) };
65
126
  }
66
-
67
- return {
68
- ok: true,
69
- lockfileName,
70
- lockfileVersion,
71
- root,
72
- components,
73
- };
127
+ if (lock.dependencies) {
128
+ return { lockfileVersion, ...parseDependenciesField(lock.dependencies, rootPkg) };
129
+ }
130
+ return { lockfileVersion, components: [], rootDependsOn: [], complete: true };
74
131
  }
75
132
 
76
133
  /** Lockfile v2/v3 store everything under the `packages` map keyed by path. */
77
134
  function parsePackagesField(packages) {
78
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
+ };
79
173
 
80
174
  for (const [pkgPath, info] of Object.entries(packages)) {
81
175
  // The root project is stored under the empty key.
@@ -94,30 +188,81 @@ function parsePackagesField(packages) {
94
188
  dev: Boolean(info.dev),
95
189
  optional: Boolean(info.optional),
96
190
  path: pkgPath,
191
+ dependsOn: edges(pkgPath, info, false),
97
192
  }));
98
193
  }
99
194
 
100
- return dedupe(components);
195
+ const rootDependsOn = packages[''] ? edges('', packages[''], true) : [];
196
+ return { components, rootDependsOn, complete };
101
197
  }
102
198
 
103
- /** Lockfile v1 nests dependencies recursively under `dependencies`. */
104
- function parseDependenciesField(dependencies, acc = []) {
105
- for (const [name, info] of Object.entries(dependencies)) {
106
- acc.push(normalizeComponent({
107
- name,
108
- version: info.version,
109
- resolved: info.resolved,
110
- integrity: info.integrity,
111
- license: info.license,
112
- dev: Boolean(info.dev),
113
- optional: Boolean(info.optional),
114
- path: `node_modules/${name}`,
115
- }));
116
- if (info.dependencies) {
117
- 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];
118
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;
119
247
  }
120
- 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;
121
266
  }
122
267
 
123
268
  function nameFromPath(pkgPath) {
@@ -126,6 +271,275 @@ function nameFromPath(pkgPath) {
126
271
  return pkgPath.slice(idx + 'node_modules/'.length);
127
272
  }
128
273
 
274
+ // --- Yarn ------------------------------------------------------------------
275
+
276
+ /** Yarn: dispatches between the classic v1 text format and Berry's YAML. */
277
+ function parseYarnLock(lockPath, rootPkg) {
278
+ const content = fs.readFileSync(lockPath, 'utf8');
279
+ const isBerry = /^__metadata:/m.test(content) || /\n {2}resolution:/.test(content);
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;
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 };
323
+ }
324
+
325
+ /**
326
+ * Yarn classic (v1) lockfiles use a custom, non-YAML text format: a block per
327
+ * resolved entry whose header lists one or more `name@range` descriptors.
328
+ */
329
+ function parseYarnClassic(content) {
330
+ const entries = [];
331
+ const blocks = content.split(/\r?\n(?=\S)/);
332
+
333
+ for (const block of blocks) {
334
+ const lines = block.split(/\r?\n/);
335
+ const header = lines[0];
336
+ if (!header || header.startsWith('#') || !header.trim().endsWith(':')) continue;
337
+
338
+ const descriptors = header.replace(/:\s*$/, '').split(',').map((d) => stripQuotes(d.trim()));
339
+ const name = packageNameFromDescriptor(descriptors[0]);
340
+ if (!name) continue;
341
+
342
+ let version = null;
343
+ let resolved = null;
344
+ let integrity = null;
345
+ let section = null;
346
+ const dependencies = [];
347
+ for (const raw of lines.slice(1)) {
348
+ const line = raw.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
+ }
366
+ }
367
+ if (!version) continue;
368
+
369
+ entries.push({ descriptors, name, version, resolved, integrity, dependencies, workspace: false });
370
+ }
371
+ return entries;
372
+ }
373
+
374
+ /** Yarn Berry (v2+) lockfiles are YAML keyed by descriptor lists. */
375
+ function parseYarnBerry(content) {
376
+ const doc = parseYaml(content) || {};
377
+ const entries = [];
378
+
379
+ for (const [key, info] of Object.entries(doc)) {
380
+ if (key === '__metadata' || !info || typeof info !== 'object') continue;
381
+ if (!info.version) continue;
382
+
383
+ const descriptors = String(key).split(',').map((d) => stripQuotes(d.trim())).filter(Boolean);
384
+ const resolution = typeof info.resolution === 'string' ? info.resolution : '';
385
+ const name = packageNameFromDescriptor(resolution || descriptors[0]);
386
+ if (!name) continue;
387
+
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,
400
+ name,
401
+ version: String(info.version),
402
+ resolved: null,
403
+ // Berry's `checksum` is a cache key, not an npm SRI hash, so we omit it.
404
+ integrity: null,
405
+ dependencies,
406
+ workspace: /@workspace:/.test(resolution),
407
+ });
408
+ }
409
+ return entries;
410
+ }
411
+
412
+ // --- pnpm ------------------------------------------------------------------
413
+
414
+ /** pnpm: `pnpm-lock.yaml` (lockfileVersion 5.x / 6.x / 9.x). */
415
+ function parsePnpmLock(lockPath) {
416
+ const doc = parseYaml(fs.readFileSync(lockPath, 'utf8')) || {};
417
+ const packages = doc.packages && typeof doc.packages === 'object' ? doc.packages : {};
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
+ }
443
+
444
+ const components = [];
445
+ for (const [key, info] of Object.entries(packages)) {
446
+ const parsed = packageKeyToNameVersion(key);
447
+ if (!parsed) continue;
448
+ const resolution = info && typeof info === 'object' ? info.resolution : null;
449
+ const integrity = resolution && typeof resolution === 'object' ? resolution.integrity || null : null;
450
+ components.push(normalizeComponent({
451
+ name: parsed.name,
452
+ version: parsed.version,
453
+ integrity,
454
+ path: `node_modules/${parsed.name}`,
455
+ dependsOn: edgesByKey.get(componentKey(parsed.name, parsed.version)) || [],
456
+ }));
457
+ }
458
+
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;
486
+ }
487
+
488
+ /**
489
+ * Extracts a package name from a Yarn/pnpm descriptor or resolution string,
490
+ * e.g. `lodash@^4.17.21`, `@babel/core@npm:7.0.0`, `left-pad@1.3.0`.
491
+ */
492
+ function packageNameFromDescriptor(descriptor) {
493
+ if (!descriptor) return null;
494
+ let d = stripQuotes(String(descriptor).trim());
495
+ // Drop a protocol/range part: take everything before the version separator.
496
+ const scoped = d.startsWith('@');
497
+ const at = d.indexOf('@', scoped ? 1 : 0);
498
+ return at === -1 ? d : d.slice(0, at);
499
+ }
500
+
501
+ /**
502
+ * Splits a pnpm package map key into { name, version }. Handles the historical
503
+ * variants: `/lodash/4.17.21`, `/lodash@4.17.21`, `lodash@4.17.21` and peer
504
+ * suffixes like `@babel/core@7.0.0(react@18.0.0)`.
505
+ */
506
+ function packageKeyToNameVersion(key) {
507
+ let k = stripQuotes(String(key).trim());
508
+ if (!k) return null;
509
+ // Strip peer-dependency suffix: name@version(peer@x)(...).
510
+ const peerIdx = k.indexOf('(');
511
+ if (peerIdx !== -1) k = k.slice(0, peerIdx);
512
+
513
+ if (k.startsWith('/')) {
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(/_[^/]*$/, '');
517
+ // v5 uses `/` as separator: name/version (scoped: @scope/name/version).
518
+ if (!k.includes('@', k.startsWith('@') ? 1 : 0) && k.includes('/')) {
519
+ const idx = k.lastIndexOf('/');
520
+ return { name: k.slice(0, idx), version: k.slice(idx + 1) };
521
+ }
522
+ }
523
+
524
+ const scoped = k.startsWith('@');
525
+ const at = k.lastIndexOf('@');
526
+ if (at <= 0) return null;
527
+ if (scoped && at === 0) return null;
528
+ const name = k.slice(0, at);
529
+ const version = k.slice(at + 1);
530
+ if (!name || !version) return null;
531
+ return { name, version };
532
+ }
533
+
534
+ function stripQuotes(value) {
535
+ if (typeof value !== 'string') return value;
536
+ const t = value.trim();
537
+ if (t.length >= 2 && ((t[0] === '"' && t[t.length - 1] === '"') || (t[0] === "'" && t[t.length - 1] === "'"))) {
538
+ return t.slice(1, -1);
539
+ }
540
+ return t;
541
+ }
542
+
129
543
  function normalizeComponent(raw) {
130
544
  const { algorithm, hash } = parseIntegrity(raw.integrity);
131
545
  return {
@@ -138,12 +552,22 @@ function normalizeComponent(raw) {
138
552
  hashValue: hash,
139
553
  license: raw.license || null,
140
554
  scope: raw.dev ? 'optional' : 'required',
141
- dev: raw.dev,
142
- optional: raw.optional,
555
+ dev: Boolean(raw.dev),
556
+ optional: Boolean(raw.optional),
143
557
  path: raw.path,
558
+ dependsOn: uniqueSorted(raw.dependsOn),
144
559
  };
145
560
  }
146
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
+
147
571
  /**
148
572
  * Parses an npm Subresource Integrity string ("sha512-<base64>")
149
573
  * into a CycloneDX-friendly { algorithm, hash } pair.
@@ -165,6 +589,9 @@ function parseIntegrity(integrity) {
165
589
  } catch {
166
590
  hex = null;
167
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 };
168
595
  return { algorithm, hash: hex };
169
596
  }
170
597
 
@@ -185,9 +612,13 @@ function buildPurl(name, version) {
185
612
  function dedupe(components) {
186
613
  const seen = new Map();
187
614
  for (const comp of components) {
188
- const key = `${comp.name}@${comp.version}`;
189
- if (!seen.has(key)) {
615
+ const key = componentKey(comp.name, comp.version);
616
+ const existing = seen.get(key);
617
+ if (!existing) {
190
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]);
191
622
  }
192
623
  }
193
624
  return Array.from(seen.values()).sort((a, b) =>
@@ -195,4 +626,53 @@ function dedupe(components) {
195
626
  );
196
627
  }
197
628
 
198
- module.exports = { parseLockfile, buildPurl, parseIntegrity };
629
+ /**
630
+ * Builds a synthetic npm `package-lock.json` (v3) and matching `package.json`
631
+ * from a parsed lockfile, so that `npm audit --package-lock-only` can scan a
632
+ * Yarn/pnpm project without an npm lockfile. The exact versions resolved by
633
+ * Yarn/pnpm are preserved; duplicate versions of a package are nested so each
634
+ * is present in the tree the auditor inspects.
635
+ *
636
+ * @param {{ root: {name:string, version:string}, components: Array<object> }} parsed
637
+ * @returns {{ packageJson: object, packageLock: object }}
638
+ */
639
+ function toNpmLockfile(parsed) {
640
+ const packages = { '': { name: parsed.root.name, version: parsed.root.version } };
641
+ const rootDeps = {};
642
+ const placed = new Map();
643
+
644
+ for (const c of parsed.components) {
645
+ if (!c.name || !c.version) continue;
646
+ const count = placed.get(c.name) || 0;
647
+ placed.set(c.name, count + 1);
648
+
649
+ let key;
650
+ if (count === 0) {
651
+ key = `node_modules/${c.name}`;
652
+ rootDeps[c.name] = c.version;
653
+ } else {
654
+ key = `node_modules/${c.name}` + `/node_modules/${c.name}`.repeat(count);
655
+ }
656
+
657
+ const entry = { version: c.version };
658
+ if (c.resolved) entry.resolved = c.resolved;
659
+ if (c.integrity) entry.integrity = c.integrity;
660
+ if (c.license) entry.license = c.license;
661
+ packages[key] = entry;
662
+ }
663
+
664
+ packages[''].dependencies = rootDeps;
665
+
666
+ return {
667
+ packageJson: { name: parsed.root.name, version: parsed.root.version, dependencies: rootDeps },
668
+ packageLock: {
669
+ name: parsed.root.name,
670
+ version: parsed.root.version,
671
+ lockfileVersion: 3,
672
+ requires: true,
673
+ packages,
674
+ },
675
+ };
676
+ }
677
+
678
+ module.exports = { parseLockfile, buildPurl, parseIntegrity, toNpmLockfile, componentKey };
@@ -15,6 +15,9 @@ const DEFAULT_POLICY = {
15
15
  // Require an SBOM that satisfies the minimum TR-03183 elements.
16
16
  requireSbom: true,
17
17
  sbomFormat: 'cyclonedx', // cyclonedx | spdx
18
+ // Email or URL of the SBOM creator (TR-03183-2 §5.2.1). Defaults to the
19
+ // project's package.json author, homepage or repository.
20
+ sbomCreator: null,
18
21
  // Audit only production dependencies (recommended for shipped products).
19
22
  productionOnly: false,
20
23
  vulnerabilities: {
@@ -44,7 +47,7 @@ function loadPolicy(projectRoot, explicitPath) {
44
47
  ? explicitPath
45
48
  : path.join(projectRoot, explicitPath);
46
49
  if (!exists(configPath)) {
47
- throw new Error(`No se encontró el archivo de configuración: ${explicitPath}`);
50
+ throw new Error(`Configuration file not found: ${explicitPath}`);
48
51
  }
49
52
  } else {
50
53
  for (const name of CONFIG_FILENAMES) {