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,124 +1,228 @@
1
- 'use strict';
2
-
3
- /**
4
- * Validates an SBOM document against the minimum elements required by
5
- * CRA Annex I (2)(1) and BSI TR-03183 Part 2. It auto-detects whether the
6
- * document is CycloneDX or SPDX.
7
- *
8
- * Minimum required elements (TR-03183 §6 "minimum elements"):
9
- * - SBOM format declared (CycloneDX or SPDX)
10
- * - Author / creator of the SBOM
11
- * - Timestamp of creation
12
- * - For each component: name, version, supplier/author, unique identifier,
13
- * cryptographic hash, license and dependency relationship.
14
- */
15
- function validateSbom(document) {
16
- if (!document || typeof document !== 'object') {
17
- return invalid('El SBOM no es un objeto JSON válido.');
18
- }
19
-
20
- if (document.bomFormat === 'CycloneDX' || document.specVersion) {
21
- return validateCycloneDx(document);
22
- }
23
- if (document.spdxVersion || document.SPDXID === 'SPDXRef-DOCUMENT') {
24
- return validateSpdx(document);
25
- }
26
-
27
- return invalid('Formato de SBOM no reconocido (no es CycloneDX ni SPDX).');
28
- }
29
-
30
- function validateCycloneDx(doc) {
31
- const issues = [];
32
- const checks = [];
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',
39
- Boolean(doc.metadata && (doc.metadata.tools || doc.metadata.authors)));
40
- pushCheck(checks, 'rootComponent', 'Componente raíz (producto) identificado',
41
- Boolean(doc.metadata && doc.metadata.component && doc.metadata.component.name));
42
-
43
- const components = Array.isArray(doc.components) ? doc.components : [];
44
- pushCheck(checks, 'components', 'Contiene al menos un componente', components.length > 0);
45
-
46
- const stats = { total: components.length, missingVersion: 0, missingHash: 0, missingLicense: 0, missingPurl: 0 };
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}`); }
53
- }
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);
59
-
60
- return summarize('cyclonedx', checks, issues, stats);
61
- }
62
-
63
- function validateSpdx(doc) {
64
- const issues = [];
65
- const checks = [];
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',
72
- Boolean(doc.creationInfo && Array.isArray(doc.creationInfo.creators) && doc.creationInfo.creators.length));
73
-
74
- const packages = Array.isArray(doc.packages) ? doc.packages : [];
75
- pushCheck(checks, 'components', 'Contiene al menos un paquete', packages.length > 0);
76
-
77
- const stats = { total: packages.length, missingVersion: 0, missingHash: 0, missingLicense: 0, missingPurl: 0 };
78
- for (const pkg of packages) {
79
- const label = pkg.name || pkg.SPDXID || 'desconocido';
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}`); }
85
- }
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);
91
-
92
- return summarize('spdx', checks, issues, stats);
93
- }
94
-
95
- function hasCdxHash(comp) {
96
- return Array.isArray(comp.hashes) && comp.hashes.length > 0;
97
- }
98
-
99
- function hasCdxLicense(comp) {
100
- return Array.isArray(comp.licenses) && comp.licenses.length > 0;
101
- }
102
-
103
- function pushCheck(checks, id, label, passed) {
104
- checks.push({ id, label, passed: Boolean(passed) });
105
- }
106
-
107
- function summarize(format, checks, issues, stats) {
108
- const failed = checks.filter((c) => !c.passed);
109
- return {
110
- ok: true,
111
- format,
112
- valid: failed.length === 0,
113
- checks,
114
- failedChecks: failed,
115
- issues,
116
- stats,
117
- };
118
- }
119
-
120
- function invalid(message) {
121
- return { ok: false, valid: false, error: message, checks: [], issues: [message] };
122
- }
123
-
124
- module.exports = { validateSbom };
1
+ 'use strict';
2
+
3
+ /**
4
+ * Validates an SBOM document against the requirements of CRA Annex I and
5
+ * BSI TR-03183-2 v2.1.0. It auto-detects whether the document is CycloneDX
6
+ * or SPDX.
7
+ *
8
+ * TR-03183-2 v2.1.0 requirements checked:
9
+ * - §4: format CycloneDX >= 1.6 or SPDX >= 3.0.1
10
+ * - §5.2.1: creator of the SBOM (email or URL) and timestamp
11
+ * - §5.2.2: for each component: creator (email or URL), name, version,
12
+ * filename, dependencies (and completeness of that enumeration),
13
+ * licence, SHA-512 hash of the deployable component and the
14
+ * executable / archive / structured properties
15
+ * - §5.2.4: unique identifier (purl, CPE, SWID)
16
+ */
17
+ function validateSbom(document) {
18
+ if (!document || typeof document !== 'object') {
19
+ return invalid('The SBOM is not a valid JSON object.');
20
+ }
21
+
22
+ if (document.bomFormat === 'CycloneDX' || document.specVersion) {
23
+ return validateCycloneDx(document);
24
+ }
25
+ if (document.spdxVersion || document.SPDXID === 'SPDXRef-DOCUMENT') {
26
+ return validateSpdx(document);
27
+ }
28
+
29
+ return invalid('Unrecognized SBOM format (neither CycloneDX nor SPDX).');
30
+ }
31
+
32
+ const BSI_PROPERTIES = ['bsi:component:executable', 'bsi:component:archive', 'bsi:component:structured'];
33
+
34
+ function validateCycloneDx(doc) {
35
+ const issues = [];
36
+ const checks = [];
37
+ const metadata = doc.metadata || {};
38
+
39
+ pushCheck(checks, 'format', 'Format declared (CycloneDX)', doc.bomFormat === 'CycloneDX');
40
+ pushCheck(checks, 'specVersion', 'Specification version CycloneDX >= 1.6 (TR-03183-2 §4)',
41
+ compareVersions(doc.specVersion, '1.6') >= 0);
42
+ pushCheck(checks, 'serialNumber', 'Unique document identifier (serialNumber)', Boolean(doc.serialNumber));
43
+ pushCheck(checks, 'timestamp', 'Creation timestamp', Boolean(metadata.timestamp));
44
+ pushCheck(checks, 'author', 'Creator of the SBOM (email or URL)',
45
+ hasCdxContact(metadata.manufacturer) || toArray(metadata.authors).some((a) => a && a.email));
46
+ pushCheck(checks, 'rootComponent', 'Root component (product) identified',
47
+ Boolean(metadata.component && metadata.component.name));
48
+
49
+ const components = toArray(doc.components);
50
+ pushCheck(checks, 'components', 'Contains at least one component', components.length > 0);
51
+
52
+ const graphRefs = new Set(toArray(doc.dependencies).map((d) => d && d.ref));
53
+ const stats = {
54
+ total: components.length,
55
+ missingVersion: 0,
56
+ missingCreator: 0,
57
+ missingFilename: 0,
58
+ missingHash: 0,
59
+ missingLicense: 0,
60
+ missingPurl: 0,
61
+ missingProperties: 0,
62
+ missingDependencies: 0,
63
+ };
64
+
65
+ for (const comp of components) {
66
+ const label = comp.name || comp['bom-ref'] || 'unknown';
67
+ const props = new Map(toArray(comp.properties).map((p) => [p && p.name, p && p.value]));
68
+
69
+ if (!comp.version) { stats.missingVersion++; issues.push(`Component without a version: ${label}`); }
70
+ if (!hasCdxContact(comp.manufacturer) && !hasCdxContact(comp.supplier) && !hasEmail(comp.authors)) {
71
+ stats.missingCreator++; issues.push(`Component without a creator (email or URL): ${label}`);
72
+ }
73
+ if (!props.get('bsi:component:filename')) { stats.missingFilename++; issues.push(`Component without a filename: ${label}`); }
74
+ if (!hasCdxSha512(comp)) { stats.missingHash++; issues.push(`Component without a SHA-512 hash: ${label}`); }
75
+ if (!hasCdxLicense(comp)) { stats.missingLicense++; issues.push(`Component without a license: ${label}`); }
76
+ if (!comp.purl && !comp.cpe && !comp.swid && !comp['bom-ref']) {
77
+ stats.missingPurl++; issues.push(`Component without a unique identifier (purl): ${label}`);
78
+ }
79
+ if (BSI_PROPERTIES.some((name) => !props.get(name))) {
80
+ stats.missingProperties++; issues.push(`Component without the executable/archive/structured properties: ${label}`);
81
+ }
82
+ if (!graphRefs.has(comp['bom-ref'])) {
83
+ stats.missingDependencies++; issues.push(`Component missing from the dependency graph: ${label}`);
84
+ }
85
+ }
86
+
87
+ const completeness = toArray(doc.compositions).some((c) => c && c.aggregate);
88
+
89
+ pushCheck(checks, 'componentVersions', 'All components have a version', stats.missingVersion === 0);
90
+ pushCheck(checks, 'componentCreators', 'All components have a creator (email or URL)', stats.missingCreator === 0);
91
+ pushCheck(checks, 'componentFilenames', 'All components have a filename', stats.missingFilename === 0);
92
+ pushCheck(checks, 'componentHashes', 'All components have a SHA-512 hash', stats.missingHash === 0);
93
+ pushCheck(checks, 'componentLicenses', 'All components have a license', stats.missingLicense === 0);
94
+ pushCheck(checks, 'componentIdentifiers', 'All components have a unique identifier', stats.missingPurl === 0);
95
+ pushCheck(checks, 'componentProperties', 'All components declare executable/archive/structured', stats.missingProperties === 0);
96
+ pushCheck(checks, 'dependencyGraph', 'Dependency graph covers every component', stats.missingDependencies === 0);
97
+ pushCheck(checks, 'dependencyCompleteness', 'Completeness of the dependency graph declared (compositions)', completeness);
98
+
99
+ return summarize('cyclonedx', checks, issues, stats);
100
+ }
101
+
102
+ function validateSpdx(doc) {
103
+ const issues = [];
104
+ const checks = [];
105
+ const creators = toArray(doc.creationInfo && doc.creationInfo.creators);
106
+
107
+ pushCheck(checks, 'format', 'Format declared (SPDX)', Boolean(doc.spdxVersion));
108
+ const spdxVersion = String(doc.spdxVersion || '').replace(/^SPDX-/, '');
109
+ pushCheck(checks, 'specVersion', 'Specification version SPDX >= 3.0.1 (TR-03183-2 §4; use CycloneDX)',
110
+ compareVersions(spdxVersion, '3.0.1') >= 0);
111
+ pushCheck(checks, 'dataLicense', 'Data license declared', Boolean(doc.dataLicense));
112
+ pushCheck(checks, 'serialNumber', 'Unique document namespace', Boolean(doc.documentNamespace));
113
+ pushCheck(checks, 'timestamp', 'Creation timestamp', Boolean(doc.creationInfo && doc.creationInfo.created));
114
+ pushCheck(checks, 'author', 'Creator of the SBOM (organization or person)',
115
+ creators.some((c) => /^(Organization|Person):/.test(String(c))));
116
+
117
+ const packages = toArray(doc.packages);
118
+ pushCheck(checks, 'components', 'Contains at least one package', packages.length > 0);
119
+
120
+ const relationships = toArray(doc.relationships);
121
+ const inGraph = new Set();
122
+ for (const r of relationships) {
123
+ if (r && r.relationshipType === 'DEPENDS_ON') {
124
+ inGraph.add(r.spdxElementId);
125
+ inGraph.add(r.relatedSpdxElement);
126
+ }
127
+ }
128
+
129
+ const stats = {
130
+ total: packages.length,
131
+ missingVersion: 0,
132
+ missingCreator: 0,
133
+ missingFilename: 0,
134
+ missingHash: 0,
135
+ missingLicense: 0,
136
+ missingPurl: 0,
137
+ missingDependencies: 0,
138
+ };
139
+ for (const pkg of packages) {
140
+ const label = pkg.name || pkg.SPDXID || 'unknown';
141
+ if (pkg.SPDXID === 'SPDXRef-Package-root') continue;
142
+ if (!pkg.versionInfo || pkg.versionInfo === 'NOASSERTION') { stats.missingVersion++; issues.push(`Package without a version: ${label}`); }
143
+ if (!isAssertion(pkg.originator) && !isAssertion(pkg.supplier)) { stats.missingCreator++; issues.push(`Package without an originator: ${label}`); }
144
+ if (!pkg.packageFileName) { stats.missingFilename++; issues.push(`Package without a filename: ${label}`); }
145
+ if (!toArray(pkg.checksums).some((c) => /^SHA-?512$/i.test(String(c && c.algorithm)))) {
146
+ stats.missingHash++; issues.push(`Package without a SHA-512 checksum: ${label}`);
147
+ }
148
+ if (!isAssertion(pkg.licenseConcluded)) { stats.missingLicense++; issues.push(`Package without a license: ${label}`); }
149
+ if (!toArray(pkg.externalRefs).some((r) => r.referenceType === 'purl')) { stats.missingPurl++; issues.push(`Package without a purl: ${label}`); }
150
+ if (packages.length > 2 && !inGraph.has(pkg.SPDXID)) {
151
+ stats.missingDependencies++; issues.push(`Package missing from the dependency graph: ${label}`);
152
+ }
153
+ }
154
+
155
+ pushCheck(checks, 'componentVersions', 'All packages have a version', stats.missingVersion === 0);
156
+ pushCheck(checks, 'componentCreators', 'All packages have an originator', stats.missingCreator === 0);
157
+ pushCheck(checks, 'componentFilenames', 'All packages have a filename', stats.missingFilename === 0);
158
+ pushCheck(checks, 'componentHashes', 'All packages have a SHA-512 checksum', stats.missingHash === 0);
159
+ pushCheck(checks, 'componentLicenses', 'All packages have a license', stats.missingLicense === 0);
160
+ pushCheck(checks, 'componentIdentifiers', 'All packages have a purl', stats.missingPurl === 0);
161
+ pushCheck(checks, 'dependencyGraph', 'Dependency relationships (DEPENDS_ON) declared', stats.missingDependencies === 0);
162
+
163
+ return summarize('spdx', checks, issues, stats);
164
+ }
165
+
166
+ /** CycloneDX organizational entity with an email contact or a URL. */
167
+ function hasCdxContact(entity) {
168
+ if (!entity || typeof entity !== 'object') return false;
169
+ return toArray(entity.url).length > 0 || hasEmail(entity.contact);
170
+ }
171
+
172
+ function hasEmail(people) {
173
+ return toArray(people).some((p) => p && p.email);
174
+ }
175
+
176
+ /** SHA-512 in `hashes` or on the `distribution` reference (TR-03183 mapping). */
177
+ function hasCdxSha512(comp) {
178
+ const isSha512 = (h) => h && h.alg === 'SHA-512' && h.content;
179
+ if (toArray(comp.hashes).some(isSha512)) return true;
180
+ return toArray(comp.externalReferences).some((ref) => ref && toArray(ref.hashes).some(isSha512));
181
+ }
182
+
183
+ function hasCdxLicense(comp) {
184
+ return toArray(comp.licenses).length > 0;
185
+ }
186
+
187
+ function isAssertion(value) {
188
+ return Boolean(value) && value !== 'NOASSERTION' && value !== 'NONE';
189
+ }
190
+
191
+ /** Compares dotted numeric versions ("1.6" vs "1.5"); missing parts are 0. */
192
+ function compareVersions(a, b) {
193
+ if (!a) return -1;
194
+ const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
195
+ const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
196
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
197
+ const diff = (pa[i] || 0) - (pb[i] || 0);
198
+ if (diff !== 0) return diff;
199
+ }
200
+ return 0;
201
+ }
202
+
203
+ function toArray(value) {
204
+ return Array.isArray(value) ? value : [];
205
+ }
206
+
207
+ function pushCheck(checks, id, label, passed) {
208
+ checks.push({ id, label, passed: Boolean(passed) });
209
+ }
210
+
211
+ function summarize(format, checks, issues, stats) {
212
+ const failed = checks.filter((c) => !c.passed);
213
+ return {
214
+ ok: true,
215
+ format,
216
+ valid: failed.length === 0,
217
+ checks,
218
+ failedChecks: failed,
219
+ issues,
220
+ stats,
221
+ };
222
+ }
223
+
224
+ function invalid(message) {
225
+ return { ok: false, valid: false, error: message, checks: [], issues: [message] };
226
+ }
227
+
228
+ module.exports = { validateSbom };
@@ -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,
@@ -16,10 +16,10 @@ const SEVERITY_COLORS = {
16
16
  * @param {import('../core/auditor').AuditResult} report
17
17
  */
18
18
  function reportConsole(report) {
19
- logger.heading(' CRA Audit — Cyber Resilience Act (UE 2024/2847) · BSI TR-03183');
20
- logger.detail(`Proyecto: ${report.project.name}@${report.project.version}`);
19
+ logger.heading(' CRA Audit — Cyber Resilience Act (EU 2024/2847) · BSI TR-03183');
20
+ logger.detail(`Project: ${report.project.name}@${report.project.version}`);
21
21
  if (report.policySource) {
22
- logger.detail(`Política: ${report.policySource}`);
22
+ logger.detail(`Policy: ${report.policySource}`);
23
23
  }
24
24
 
25
25
  if (report.sections.vulnerabilities) renderVulnerabilities(report.sections.vulnerabilities);
@@ -30,7 +30,7 @@ function reportConsole(report) {
30
30
  }
31
31
 
32
32
  function renderVulnerabilities(section) {
33
- logger.heading('1) Vulnerabilidades conocidas (Art. 13 CRA)');
33
+ logger.heading('1) Known vulnerabilities (CRA Art. 13)');
34
34
  if (!section.ok) {
35
35
  logger.error(section.error);
36
36
  return;
@@ -38,13 +38,13 @@ function renderVulnerabilities(section) {
38
38
 
39
39
  const c = section.counts;
40
40
  if (c.total === 0) {
41
- logger.success('Sin vulnerabilidades conocidas. Cumple el requisito de "cero vulnerabilidades explotables".');
41
+ logger.success('No known vulnerabilities. Meets the "zero exploitable vulnerabilities" requirement.');
42
42
  return;
43
43
  }
44
44
 
45
45
  logger.log(
46
- ` ${color.red(`${c.critical} críticas`)} · ${color.red(`${c.high} altas`)} · ` +
47
- `${color.yellow(`${c.moderate} moderadas`)} · ${color.yellow(`${c.low} bajas`)} · ${color.gray(`${c.info} info`)}`
46
+ ` ${color.red(`${c.critical} critical`)} · ${color.red(`${c.high} high`)} · ` +
47
+ `${color.yellow(`${c.moderate} moderate`)} · ${color.yellow(`${c.low} low`)} · ${color.gray(`${c.info} info`)}`
48
48
  );
49
49
 
50
50
  const top = section.vulnerabilities.slice(0, 15);
@@ -56,25 +56,25 @@ function renderVulnerabilities(section) {
56
56
  if (adv && adv.title) logger.detail(`${adv.title}${adv.url ? ' — ' + adv.url : ''}`);
57
57
  }
58
58
  if (section.vulnerabilities.length > top.length) {
59
- logger.detail(`… y ${section.vulnerabilities.length - top.length} más.`);
59
+ logger.detail(`… and ${section.vulnerabilities.length - top.length} more.`);
60
60
  }
61
61
  }
62
62
 
63
63
  function describeFix(fix) {
64
- if (fix === true) return color.green('(fix disponible)');
64
+ if (fix === true) return color.green('(fix available)');
65
65
  if (fix && typeof fix === 'object') {
66
66
  return color.green(`(fix: ${fix.name}@${fix.version}${fix.breaking ? ', breaking' : ''})`);
67
67
  }
68
- return color.gray('(sin fix)');
68
+ return color.gray('(no fix)');
69
69
  }
70
70
 
71
71
  function renderSbom(section) {
72
- logger.heading('2) Lista de Materiales de Software · SBOM (Anexo I CRA)');
72
+ logger.heading('2) Software Bill of Materials · SBOM (CRA Annex I)');
73
73
  if (!section.ok) {
74
74
  logger.error(section.error);
75
75
  return;
76
76
  }
77
- logger.detail(`Formato: ${section.format} · Componentes: ${section.componentCount}`);
77
+ logger.detail(`Format: ${section.format} · Components: ${section.componentCount}`);
78
78
 
79
79
  if (section.validation) {
80
80
  const v = section.validation;
@@ -83,53 +83,53 @@ function renderSbom(section) {
83
83
  logger.log(` ${mark} ${check.label}`);
84
84
  }
85
85
  if (v.valid) {
86
- logger.success('El SBOM cumple los elementos mínimos de TR-03183 §6.');
86
+ logger.success('The SBOM meets the TR-03183-2 required data fields.');
87
87
  } else {
88
- logger.warn(`El SBOM no cumple ${v.failedChecks.length} requisito(s) mínimo(s).`);
88
+ logger.warn(`The SBOM does not meet ${v.failedChecks.length} minimum requirement(s).`);
89
89
  }
90
90
  }
91
91
  if (section.outputPath) {
92
- logger.detail(`SBOM escrito en: ${section.outputPath}`);
92
+ logger.detail(`SBOM written to: ${section.outputPath}`);
93
93
  }
94
94
  }
95
95
 
96
96
  function renderLicenses(section) {
97
- logger.heading('3) Licencias de componentes de terceros (TR-03183 §6)');
97
+ logger.heading('3) Third-party component licenses (TR-03183-2 §5.2)');
98
98
  if (!section.ok) {
99
99
  logger.error(section.error);
100
100
  return;
101
101
  }
102
102
  const s = section.summary;
103
- logger.detail(`Documentadas: ${s.documented}/${s.total}`);
103
+ logger.detail(`Documented: ${s.documented}/${s.total}`);
104
104
 
105
105
  if (s.missing.length) {
106
- logger.warn(`${s.missing.length} componente(s) sin licencia documentada:`);
106
+ logger.warn(`${s.missing.length} component(s) without a documented license:`);
107
107
  for (const c of s.missing.slice(0, 10)) logger.log(` ${color.gray('•')} ${c.name}@${c.version}`);
108
108
  }
109
109
  if (s.denied.length) {
110
- logger.error(`${s.denied.length} componente(s) con licencia prohibida por la política:`);
110
+ logger.error(`${s.denied.length} component(s) with a license denied by the policy:`);
111
111
  for (const c of s.denied) logger.log(` ${color.red('•')} ${c.name}@${c.version} (${c.license})`);
112
112
  }
113
113
  if (s.notAllowed.length) {
114
- logger.warn(`${s.notAllowed.length} componente(s) con licencia fuera de la allowlist:`);
114
+ logger.warn(`${s.notAllowed.length} component(s) with a license outside the allowlist:`);
115
115
  for (const c of s.notAllowed.slice(0, 10)) logger.log(` ${color.yellow('•')} ${c.name}@${c.version} (${c.license})`);
116
116
  }
117
117
  if (!s.missing.length && !s.denied.length && !s.notAllowed.length) {
118
- logger.success('Todas las licencias están documentadas y permitidas.');
118
+ logger.success('All licenses are documented and allowed.');
119
119
  }
120
120
  }
121
121
 
122
122
  function renderSummary(report) {
123
- logger.heading('Resultado');
123
+ logger.heading('Result');
124
124
  for (const item of report.gate.reasons) {
125
125
  const mark = item.passed ? color.green('✔') : color.red('✖');
126
126
  logger.log(` ${mark} ${item.label}`);
127
127
  }
128
128
  logger.log('');
129
129
  if (report.gate.passed) {
130
- logger.success(color.bold('AUDITORÍA SUPERADA — el proyecto cumple la política CRA configurada.'));
130
+ logger.success(color.bold('AUDIT PASSED — the project meets the configured CRA policy.'));
131
131
  } else {
132
- logger.error(color.bold('AUDITORÍA FALLIDA — se han detectado incumplimientos del CRA.'));
132
+ logger.error(color.bold('AUDIT FAILED — CRA compliance issues were detected.'));
133
133
  }
134
134
  logger.log('');
135
135
  }
@@ -80,6 +80,25 @@ const logger = {
80
80
  detail(msg) {
81
81
  this.log(` ${color.gray(msg)}`);
82
82
  },
83
+ /**
84
+ * Renders an in-place progress bar with a percentage and a done/total count.
85
+ * On a TTY the same line is rewritten on each call; a trailing newline is
86
+ * emitted once `done >= total`. On non-TTY streams nothing is written to
87
+ * avoid flooding logs (CI pipelines), so callers should log a final summary.
88
+ */
89
+ progress(label, done, total) {
90
+ if (!process.stdout.isTTY) return;
91
+ const ratio = total > 0 ? Math.min(1, done / total) : 1;
92
+ const pct = Math.floor(ratio * 100);
93
+ const width = 24;
94
+ const filled = Math.round(ratio * width);
95
+ const bar = '█'.repeat(filled) + '░'.repeat(width - filled);
96
+ const line =
97
+ ` ${color.gray(label)} ${color.cyan(`[${bar}]`)} ` +
98
+ `${String(pct).padStart(3)}% ${color.gray(`(${done}/${total})`)}`;
99
+ process.stdout.write(`\r\x1b[K${line}`);
100
+ if (done >= total) process.stdout.write('\n');
101
+ },
83
102
  };
84
103
 
85
104
  module.exports = { logger, color, SYMBOLS };