cra-audit 0.1.0 → 1.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.
@@ -1,33 +1,50 @@
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,
31
+ * lockfileVersion: number|string|null,
16
32
  * root: { name: string, version: string },
17
33
  * components: Array<object>,
18
34
  * error?: string
19
35
  * }}
20
36
  */
21
37
  function parseLockfile(projectRoot) {
22
- const candidates = ['package-lock.json', 'npm-shrinkwrap.json'];
23
38
  let lockPath = null;
24
39
  let lockfileName = null;
40
+ let manager = null;
25
41
 
26
- for (const candidate of candidates) {
27
- const full = path.join(projectRoot, candidate);
42
+ for (const candidate of LOCKFILES) {
43
+ const full = path.join(projectRoot, candidate.name);
28
44
  if (exists(full)) {
29
45
  lockPath = full;
30
- lockfileName = candidate;
46
+ lockfileName = candidate.name;
47
+ manager = candidate.manager;
31
48
  break;
32
49
  }
33
50
  }
@@ -42,16 +59,39 @@ function parseLockfile(projectRoot) {
42
59
  if (!lockPath) {
43
60
  return {
44
61
  ok: false,
62
+ manager: null,
45
63
  lockfileName: null,
46
64
  lockfileVersion: null,
47
65
  root,
48
66
  components: [],
49
67
  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.',
68
+ 'No lockfile found (package-lock.json, npm-shrinkwrap.json, ' +
69
+ 'yarn.lock or pnpm-lock.yaml). The CRA requires deterministic dependency ' +
70
+ 'traceability: run `npm install` (or `yarn` / `pnpm install`) to generate the lockfile.',
71
+ };
72
+ }
73
+
74
+ 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);
78
+ } catch (err) {
79
+ return {
80
+ ok: false,
81
+ manager,
82
+ lockfileName,
83
+ lockfileVersion: null,
84
+ root,
85
+ components: [],
86
+ error: `Could not parse ${lockfileName}: ${err.message}`,
52
87
  };
53
88
  }
54
89
 
90
+ return { ok: true, manager, lockfileName, lockfileVersion: null, root, components: [] };
91
+ }
92
+
93
+ /** npm: package-lock.json (v1/v2/v3) or npm-shrinkwrap.json. */
94
+ function parseNpmLock(lockPath, lockfileName, root) {
55
95
  const lock = readJson(lockPath);
56
96
  const lockfileVersion = lock.lockfileVersion || 1;
57
97
 
@@ -64,13 +104,7 @@ function parseLockfile(projectRoot) {
64
104
  components = [];
65
105
  }
66
106
 
67
- return {
68
- ok: true,
69
- lockfileName,
70
- lockfileVersion,
71
- root,
72
- components,
73
- };
107
+ return { ok: true, manager: 'npm', lockfileName, lockfileVersion, root, components };
74
108
  }
75
109
 
76
110
  /** Lockfile v2/v3 store everything under the `packages` map keyed by path. */
@@ -126,6 +160,164 @@ function nameFromPath(pkgPath) {
126
160
  return pkgPath.slice(idx + 'node_modules/'.length);
127
161
  }
128
162
 
163
+ // --- Yarn ------------------------------------------------------------------
164
+
165
+ /** Yarn: dispatches between the classic v1 text format and Berry's YAML. */
166
+ function parseYarnLock(lockPath, lockfileName, root) {
167
+ const content = fs.readFileSync(lockPath, 'utf8');
168
+ 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),
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Yarn classic (v1) lockfiles use a custom, non-YAML text format: a block per
182
+ * resolved entry whose header lists one or more `name@range` descriptors.
183
+ */
184
+ function parseYarnClassic(content) {
185
+ const components = [];
186
+ const blocks = content.split(/\r?\n(?=\S)/);
187
+
188
+ for (const block of blocks) {
189
+ const lines = block.split(/\r?\n/);
190
+ const header = lines[0];
191
+ if (!header || header.startsWith('#') || !header.trim().endsWith(':')) continue;
192
+
193
+ const descriptors = header.replace(/:\s*$/, '').split(',').map((d) => stripQuotes(d.trim()));
194
+ const name = packageNameFromDescriptor(descriptors[0]);
195
+ if (!name) continue;
196
+
197
+ let version = null;
198
+ let resolved = null;
199
+ let integrity = null;
200
+ for (const raw of lines.slice(1)) {
201
+ 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());
205
+ }
206
+ if (!version) continue;
207
+
208
+ components.push(normalizeComponent({ name, version, resolved, integrity, path: `node_modules/${name}` }));
209
+ }
210
+ return components;
211
+ }
212
+
213
+ /** Yarn Berry (v2+) lockfiles are YAML keyed by descriptor lists. */
214
+ function parseYarnBerry(content) {
215
+ const doc = parseYaml(content) || {};
216
+ const components = [];
217
+
218
+ for (const [key, info] of Object.entries(doc)) {
219
+ if (key === '__metadata' || !info || typeof info !== 'object') continue;
220
+ if (!info.version) continue;
221
+
222
+ const resolution = typeof info.resolution === 'string' ? info.resolution : '';
223
+ const name = packageNameFromDescriptor(resolution || String(key).split(',')[0].trim());
224
+ if (!name) continue;
225
+
226
+ components.push(normalizeComponent({
227
+ name,
228
+ version: info.version,
229
+ // Berry's `checksum` is a cache key, not an npm SRI hash, so we omit it.
230
+ integrity: null,
231
+ path: `node_modules/${name}`,
232
+ }));
233
+ }
234
+ return components;
235
+ }
236
+
237
+ // --- pnpm ------------------------------------------------------------------
238
+
239
+ /** pnpm: `pnpm-lock.yaml` (lockfileVersion 5.x / 6.x / 9.x). */
240
+ function parsePnpmLock(lockPath, lockfileName, root) {
241
+ const doc = parseYaml(fs.readFileSync(lockPath, 'utf8')) || {};
242
+ const packages = doc.packages && typeof doc.packages === 'object' ? doc.packages : {};
243
+ const components = [];
244
+
245
+ for (const [key, info] of Object.entries(packages)) {
246
+ const parsed = packageKeyToNameVersion(key);
247
+ if (!parsed) continue;
248
+ const resolution = info && typeof info === 'object' ? info.resolution : null;
249
+ const integrity = resolution && typeof resolution === 'object' ? resolution.integrity || null : null;
250
+ components.push(normalizeComponent({
251
+ name: parsed.name,
252
+ version: parsed.version,
253
+ integrity,
254
+ path: `node_modules/${parsed.name}`,
255
+ }));
256
+ }
257
+
258
+ return {
259
+ ok: true,
260
+ manager: 'pnpm',
261
+ lockfileName,
262
+ lockfileVersion: doc.lockfileVersion || null,
263
+ root,
264
+ components: dedupe(components),
265
+ };
266
+ }
267
+
268
+ /**
269
+ * Extracts a package name from a Yarn/pnpm descriptor or resolution string,
270
+ * e.g. `lodash@^4.17.21`, `@babel/core@npm:7.0.0`, `left-pad@1.3.0`.
271
+ */
272
+ function packageNameFromDescriptor(descriptor) {
273
+ if (!descriptor) return null;
274
+ let d = stripQuotes(String(descriptor).trim());
275
+ // Drop a protocol/range part: take everything before the version separator.
276
+ const scoped = d.startsWith('@');
277
+ const at = d.indexOf('@', scoped ? 1 : 0);
278
+ return at === -1 ? d : d.slice(0, at);
279
+ }
280
+
281
+ /**
282
+ * Splits a pnpm package map key into { name, version }. Handles the historical
283
+ * variants: `/lodash/4.17.21`, `/lodash@4.17.21`, `lodash@4.17.21` and peer
284
+ * suffixes like `@babel/core@7.0.0(react@18.0.0)`.
285
+ */
286
+ function packageKeyToNameVersion(key) {
287
+ let k = stripQuotes(String(key).trim());
288
+ if (!k) return null;
289
+ // Strip peer-dependency suffix: name@version(peer@x)(...).
290
+ const peerIdx = k.indexOf('(');
291
+ if (peerIdx !== -1) k = k.slice(0, peerIdx);
292
+
293
+ if (k.startsWith('/')) {
294
+ k = k.slice(1);
295
+ // v5 uses `/` as separator: name/version (scoped: @scope/name/version).
296
+ if (!k.includes('@', k.startsWith('@') ? 1 : 0) && k.includes('/')) {
297
+ const idx = k.lastIndexOf('/');
298
+ return { name: k.slice(0, idx), version: k.slice(idx + 1) };
299
+ }
300
+ }
301
+
302
+ const scoped = k.startsWith('@');
303
+ const at = k.lastIndexOf('@');
304
+ if (at <= 0) return null;
305
+ if (scoped && at === 0) return null;
306
+ const name = k.slice(0, at);
307
+ const version = k.slice(at + 1);
308
+ if (!name || !version) return null;
309
+ return { name, version };
310
+ }
311
+
312
+ function stripQuotes(value) {
313
+ if (typeof value !== 'string') return value;
314
+ const t = value.trim();
315
+ if (t.length >= 2 && ((t[0] === '"' && t[t.length - 1] === '"') || (t[0] === "'" && t[t.length - 1] === "'"))) {
316
+ return t.slice(1, -1);
317
+ }
318
+ return t;
319
+ }
320
+
129
321
  function normalizeComponent(raw) {
130
322
  const { algorithm, hash } = parseIntegrity(raw.integrity);
131
323
  return {
@@ -138,8 +330,8 @@ function normalizeComponent(raw) {
138
330
  hashValue: hash,
139
331
  license: raw.license || null,
140
332
  scope: raw.dev ? 'optional' : 'required',
141
- dev: raw.dev,
142
- optional: raw.optional,
333
+ dev: Boolean(raw.dev),
334
+ optional: Boolean(raw.optional),
143
335
  path: raw.path,
144
336
  };
145
337
  }
@@ -195,4 +387,53 @@ function dedupe(components) {
195
387
  );
196
388
  }
197
389
 
198
- module.exports = { parseLockfile, buildPurl, parseIntegrity };
390
+ /**
391
+ * Builds a synthetic npm `package-lock.json` (v3) and matching `package.json`
392
+ * from a parsed lockfile, so that `npm audit --package-lock-only` can scan a
393
+ * Yarn/pnpm project without an npm lockfile. The exact versions resolved by
394
+ * Yarn/pnpm are preserved; duplicate versions of a package are nested so each
395
+ * is present in the tree the auditor inspects.
396
+ *
397
+ * @param {{ root: {name:string, version:string}, components: Array<object> }} parsed
398
+ * @returns {{ packageJson: object, packageLock: object }}
399
+ */
400
+ function toNpmLockfile(parsed) {
401
+ const packages = { '': { name: parsed.root.name, version: parsed.root.version } };
402
+ const rootDeps = {};
403
+ const placed = new Map();
404
+
405
+ for (const c of parsed.components) {
406
+ if (!c.name || !c.version) continue;
407
+ const count = placed.get(c.name) || 0;
408
+ placed.set(c.name, count + 1);
409
+
410
+ let key;
411
+ if (count === 0) {
412
+ key = `node_modules/${c.name}`;
413
+ rootDeps[c.name] = c.version;
414
+ } else {
415
+ key = `node_modules/${c.name}` + `/node_modules/${c.name}`.repeat(count);
416
+ }
417
+
418
+ const entry = { version: c.version };
419
+ if (c.resolved) entry.resolved = c.resolved;
420
+ if (c.integrity) entry.integrity = c.integrity;
421
+ if (c.license) entry.license = c.license;
422
+ packages[key] = entry;
423
+ }
424
+
425
+ packages[''].dependencies = rootDeps;
426
+
427
+ return {
428
+ packageJson: { name: parsed.root.name, version: parsed.root.version, dependencies: rootDeps },
429
+ packageLock: {
430
+ name: parsed.root.name,
431
+ version: parsed.root.version,
432
+ lockfileVersion: 3,
433
+ requires: true,
434
+ packages,
435
+ },
436
+ };
437
+ }
438
+
439
+ module.exports = { parseLockfile, buildPurl, parseIntegrity, toNpmLockfile };
@@ -44,7 +44,7 @@ function loadPolicy(projectRoot, explicitPath) {
44
44
  ? explicitPath
45
45
  : path.join(projectRoot, explicitPath);
46
46
  if (!exists(configPath)) {
47
- throw new Error(`No se encontró el archivo de configuración: ${explicitPath}`);
47
+ throw new Error(`Configuration file not found: ${explicitPath}`);
48
48
  }
49
49
  } else {
50
50
  for (const name of CONFIG_FILENAMES) {
@@ -24,7 +24,7 @@ function generateSbom(projectRoot, { format = 'cyclonedx' } = {}) {
24
24
  } else if (normalizedFormat === 'cyclonedx' || normalizedFormat === 'cdx') {
25
25
  document = buildCycloneDx(parsed);
26
26
  } else {
27
- return { ok: false, error: `Formato de SBOM no soportado: "${format}". Usa "cyclonedx" o "spdx".` };
27
+ return { ok: false, error: `Unsupported SBOM format: "${format}". Use "cyclonedx" or "spdx".` };
28
28
  }
29
29
 
30
30
  return {
@@ -14,7 +14,7 @@
14
14
  */
15
15
  function validateSbom(document) {
16
16
  if (!document || typeof document !== 'object') {
17
- return invalid('El SBOM no es un objeto JSON válido.');
17
+ return invalid('The SBOM is not a valid JSON object.');
18
18
  }
19
19
 
20
20
  if (document.bomFormat === 'CycloneDX' || document.specVersion) {
@@ -24,38 +24,38 @@ function validateSbom(document) {
24
24
  return validateSpdx(document);
25
25
  }
26
26
 
27
- return invalid('Formato de SBOM no reconocido (no es CycloneDX ni SPDX).');
27
+ return invalid('Unrecognized SBOM format (neither CycloneDX nor SPDX).');
28
28
  }
29
29
 
30
30
  function validateCycloneDx(doc) {
31
31
  const issues = [];
32
32
  const checks = [];
33
33
 
34
- pushCheck(checks, 'format', 'Formato declarado (CycloneDX)', doc.bomFormat === 'CycloneDX');
35
- pushCheck(checks, 'specVersion', 'Versión de especificación presente', Boolean(doc.specVersion));
36
- pushCheck(checks, 'serialNumber', 'Identificador único del documento (serialNumber)', Boolean(doc.serialNumber));
37
- pushCheck(checks, 'timestamp', 'Marca temporal de creación', Boolean(doc.metadata && doc.metadata.timestamp));
38
- pushCheck(checks, 'author', 'Autor / herramienta generadora declarada',
34
+ pushCheck(checks, 'format', 'Format declared (CycloneDX)', doc.bomFormat === 'CycloneDX');
35
+ pushCheck(checks, 'specVersion', 'Specification version present', Boolean(doc.specVersion));
36
+ pushCheck(checks, 'serialNumber', 'Unique document identifier (serialNumber)', Boolean(doc.serialNumber));
37
+ pushCheck(checks, 'timestamp', 'Creation timestamp', Boolean(doc.metadata && doc.metadata.timestamp));
38
+ pushCheck(checks, 'author', 'Author / generating tool declared',
39
39
  Boolean(doc.metadata && (doc.metadata.tools || doc.metadata.authors)));
40
- pushCheck(checks, 'rootComponent', 'Componente raíz (producto) identificado',
40
+ pushCheck(checks, 'rootComponent', 'Root component (product) identified',
41
41
  Boolean(doc.metadata && doc.metadata.component && doc.metadata.component.name));
42
42
 
43
43
  const components = Array.isArray(doc.components) ? doc.components : [];
44
- pushCheck(checks, 'components', 'Contiene al menos un componente', components.length > 0);
44
+ pushCheck(checks, 'components', 'Contains at least one component', components.length > 0);
45
45
 
46
46
  const stats = { total: components.length, missingVersion: 0, missingHash: 0, missingLicense: 0, missingPurl: 0 };
47
47
  for (const comp of components) {
48
- const label = comp.name || comp['bom-ref'] || 'desconocido';
49
- if (!comp.version) { stats.missingVersion++; issues.push(`Componente sin versión: ${label}`); }
50
- if (!hasCdxHash(comp)) { stats.missingHash++; issues.push(`Componente sin hash criptográfico: ${label}`); }
51
- if (!hasCdxLicense(comp)) { stats.missingLicense++; issues.push(`Componente sin licencia: ${label}`); }
52
- if (!comp.purl && !comp['bom-ref']) { stats.missingPurl++; issues.push(`Componente sin identificador único (purl): ${label}`); }
48
+ const label = comp.name || comp['bom-ref'] || 'unknown';
49
+ if (!comp.version) { stats.missingVersion++; issues.push(`Component without a version: ${label}`); }
50
+ if (!hasCdxHash(comp)) { stats.missingHash++; issues.push(`Component without a cryptographic hash: ${label}`); }
51
+ if (!hasCdxLicense(comp)) { stats.missingLicense++; issues.push(`Component without a license: ${label}`); }
52
+ if (!comp.purl && !comp['bom-ref']) { stats.missingPurl++; issues.push(`Component without a unique identifier (purl): ${label}`); }
53
53
  }
54
54
 
55
- pushCheck(checks, 'componentVersions', 'Todos los componentes tienen versión', stats.missingVersion === 0);
56
- pushCheck(checks, 'componentHashes', 'Todos los componentes tienen hash criptográfico', stats.missingHash === 0);
57
- pushCheck(checks, 'componentLicenses', 'Todos los componentes tienen licencia', stats.missingLicense === 0);
58
- pushCheck(checks, 'componentIdentifiers', 'Todos los componentes tienen identificador único', stats.missingPurl === 0);
55
+ pushCheck(checks, 'componentVersions', 'All components have a version', stats.missingVersion === 0);
56
+ pushCheck(checks, 'componentHashes', 'All components have a cryptographic hash', stats.missingHash === 0);
57
+ pushCheck(checks, 'componentLicenses', 'All components have a license', stats.missingLicense === 0);
58
+ pushCheck(checks, 'componentIdentifiers', 'All components have a unique identifier', stats.missingPurl === 0);
59
59
 
60
60
  return summarize('cyclonedx', checks, issues, stats);
61
61
  }
@@ -64,30 +64,30 @@ function validateSpdx(doc) {
64
64
  const issues = [];
65
65
  const checks = [];
66
66
 
67
- pushCheck(checks, 'format', 'Formato declarado (SPDX)', Boolean(doc.spdxVersion));
68
- pushCheck(checks, 'dataLicense', 'Licencia de los datos declarada', Boolean(doc.dataLicense));
69
- pushCheck(checks, 'serialNumber', 'Namespace único del documento', Boolean(doc.documentNamespace));
70
- pushCheck(checks, 'timestamp', 'Marca temporal de creación', Boolean(doc.creationInfo && doc.creationInfo.created));
71
- pushCheck(checks, 'author', 'Autor / creador declarado',
67
+ pushCheck(checks, 'format', 'Format declared (SPDX)', Boolean(doc.spdxVersion));
68
+ pushCheck(checks, 'dataLicense', 'Data license declared', Boolean(doc.dataLicense));
69
+ pushCheck(checks, 'serialNumber', 'Unique document namespace', Boolean(doc.documentNamespace));
70
+ pushCheck(checks, 'timestamp', 'Creation timestamp', Boolean(doc.creationInfo && doc.creationInfo.created));
71
+ pushCheck(checks, 'author', 'Author / creator declared',
72
72
  Boolean(doc.creationInfo && Array.isArray(doc.creationInfo.creators) && doc.creationInfo.creators.length));
73
73
 
74
74
  const packages = Array.isArray(doc.packages) ? doc.packages : [];
75
- pushCheck(checks, 'components', 'Contiene al menos un paquete', packages.length > 0);
75
+ pushCheck(checks, 'components', 'Contains at least one package', packages.length > 0);
76
76
 
77
77
  const stats = { total: packages.length, missingVersion: 0, missingHash: 0, missingLicense: 0, missingPurl: 0 };
78
78
  for (const pkg of packages) {
79
- const label = pkg.name || pkg.SPDXID || 'desconocido';
79
+ const label = pkg.name || pkg.SPDXID || 'unknown';
80
80
  if (pkg.SPDXID === 'SPDXRef-Package-root') continue;
81
- if (!pkg.versionInfo || pkg.versionInfo === 'NOASSERTION') { stats.missingVersion++; issues.push(`Paquete sin versión: ${label}`); }
82
- if (!Array.isArray(pkg.checksums) || pkg.checksums.length === 0) { stats.missingHash++; issues.push(`Paquete sin checksum: ${label}`); }
83
- if (!pkg.licenseConcluded || pkg.licenseConcluded === 'NOASSERTION') { stats.missingLicense++; issues.push(`Paquete sin licencia: ${label}`); }
84
- if (!Array.isArray(pkg.externalRefs) || !pkg.externalRefs.some((r) => r.referenceType === 'purl')) { stats.missingPurl++; issues.push(`Paquete sin purl: ${label}`); }
81
+ if (!pkg.versionInfo || pkg.versionInfo === 'NOASSERTION') { stats.missingVersion++; issues.push(`Package without a version: ${label}`); }
82
+ if (!Array.isArray(pkg.checksums) || pkg.checksums.length === 0) { stats.missingHash++; issues.push(`Package without a checksum: ${label}`); }
83
+ if (!pkg.licenseConcluded || pkg.licenseConcluded === 'NOASSERTION') { stats.missingLicense++; issues.push(`Package without a license: ${label}`); }
84
+ if (!Array.isArray(pkg.externalRefs) || !pkg.externalRefs.some((r) => r.referenceType === 'purl')) { stats.missingPurl++; issues.push(`Package without a purl: ${label}`); }
85
85
  }
86
86
 
87
- pushCheck(checks, 'componentVersions', 'Todos los paquetes tienen versión', stats.missingVersion === 0);
88
- pushCheck(checks, 'componentHashes', 'Todos los paquetes tienen checksum', stats.missingHash === 0);
89
- pushCheck(checks, 'componentLicenses', 'Todos los paquetes tienen licencia', stats.missingLicense === 0);
90
- pushCheck(checks, 'componentIdentifiers', 'Todos los paquetes tienen purl', stats.missingPurl === 0);
87
+ pushCheck(checks, 'componentVersions', 'All packages have a version', stats.missingVersion === 0);
88
+ pushCheck(checks, 'componentHashes', 'All packages have a checksum', stats.missingHash === 0);
89
+ pushCheck(checks, 'componentLicenses', 'All packages have a license', stats.missingLicense === 0);
90
+ pushCheck(checks, 'componentIdentifiers', 'All packages have a purl', stats.missingPurl === 0);
91
91
 
92
92
  return summarize('spdx', checks, issues, stats);
93
93
  }
@@ -1,6 +1,11 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
3
6
  const { runJson } = require('../utils/exec');
7
+ const { exists } = require('../utils/fs');
8
+ const { parseLockfile, toNpmLockfile } = require('./lockfile-parser');
4
9
 
5
10
  const SEVERITY_ORDER = ['info', 'low', 'moderate', 'high', 'critical'];
6
11
 
@@ -9,22 +14,47 @@ const SEVERITY_ORDER = ['info', 'low', 'moderate', 'high', 'critical'];
9
14
  * `npm audit --json`. The CRA requires products to ship "without known
10
15
  * exploitable vulnerabilities", so this is the core gate of the audit.
11
16
  *
17
+ * npm projects are audited in place. Yarn/pnpm projects (which have no npm
18
+ * lockfile) are audited against a synthetic `package-lock.json` generated from
19
+ * their lockfile, preserving the exact installed versions.
20
+ *
12
21
  * @param {string} projectRoot
13
22
  * @param {{ production?: boolean }} [options]
14
23
  */
15
24
  function scanVulnerabilities(projectRoot, { production = false } = {}) {
25
+ if (hasNpmLockfile(projectRoot)) {
26
+ return runNpmAudit(projectRoot, { production });
27
+ }
28
+
29
+ const parsed = parseLockfile(projectRoot);
30
+ if (parsed.ok && parsed.components.length > 0) {
31
+ return auditSynthesized(parsed, { production });
32
+ }
33
+
34
+ // No usable lockfile: run npm audit in place so the user gets npm's own error.
35
+ return runNpmAudit(projectRoot, { production });
36
+ }
37
+
38
+ function hasNpmLockfile(projectRoot) {
39
+ return exists(path.join(projectRoot, 'package-lock.json')) ||
40
+ exists(path.join(projectRoot, 'npm-shrinkwrap.json'));
41
+ }
42
+
43
+ /** Runs `npm audit --json` in the given directory. */
44
+ function runNpmAudit(cwd, { production = false, packageLockOnly = false } = {}) {
16
45
  const args = ['audit', '--json'];
17
46
  if (production) args.push('--omit=dev');
47
+ if (packageLockOnly) args.push('--package-lock-only');
18
48
 
19
- const result = runJson('npm', args, { cwd: projectRoot });
49
+ const result = runJson('npm', args, { cwd });
20
50
 
21
51
  if (!result.data) {
22
52
  return {
23
53
  ok: false,
24
54
  error:
25
- 'No se pudo ejecutar `npm audit`. Asegúrate de tener npm instalado y un ' +
26
- 'package-lock.json presente. ' +
27
- (result.stderr ? `Detalle: ${result.stderr.trim()}` : ''),
55
+ 'Could not run `npm audit`. Make sure npm is installed and a ' +
56
+ 'lockfile is present. ' +
57
+ (result.stderr ? `Detail: ${result.stderr.trim()}` : ''),
28
58
  counts: emptyCounts(),
29
59
  vulnerabilities: [],
30
60
  };
@@ -46,6 +76,32 @@ function scanVulnerabilities(projectRoot, { production = false } = {}) {
46
76
  };
47
77
  }
48
78
 
79
+ /**
80
+ * Audits a Yarn/pnpm project by materializing a synthetic npm lockfile in a
81
+ * temporary directory and running `npm audit --package-lock-only` there.
82
+ */
83
+ function auditSynthesized(parsed, { production = false } = {}) {
84
+ const { packageJson, packageLock } = toNpmLockfile(parsed);
85
+ let tmpDir;
86
+ try {
87
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cra-audit-'));
88
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(packageJson, null, 2));
89
+ fs.writeFileSync(path.join(tmpDir, 'package-lock.json'), JSON.stringify(packageLock, null, 2));
90
+ return runNpmAudit(tmpDir, { production, packageLockOnly: true });
91
+ } catch (err) {
92
+ return {
93
+ ok: false,
94
+ error: `Could not analyze vulnerabilities from lockfile ${parsed.lockfileName || ''}: ${err.message}`.trim(),
95
+ counts: emptyCounts(),
96
+ vulnerabilities: [],
97
+ };
98
+ } finally {
99
+ if (tmpDir) {
100
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
101
+ }
102
+ }
103
+ }
104
+
49
105
  function parseModernReport(data) {
50
106
  const counts = emptyCounts();
51
107
  const vulnerabilities = [];
@@ -63,14 +119,14 @@ function parseModernReport(data) {
63
119
  const advisories = via.filter((v) => typeof v === 'object');
64
120
  const sources = advisories.length
65
121
  ? advisories.map((adv) => ({
66
- title: adv.title || 'Vulnerabilidad conocida',
122
+ title: adv.title || 'Known vulnerability',
67
123
  url: adv.url || null,
68
124
  cwe: adv.cwe || [],
69
125
  cvss: adv.cvss ? adv.cvss.score : null,
70
126
  source: adv.source || null,
71
127
  range: adv.range || null,
72
128
  }))
73
- : [{ title: `Dependencia vulnerable a través de ${via.join(', ')}`, url: null, cwe: [], cvss: null }];
129
+ : [{ title: `Dependency vulnerable through ${via.join(', ')}`, url: null, cwe: [], cvss: null }];
74
130
 
75
131
  vulnerabilities.push({
76
132
  name,