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.
- package/README.md +29 -7
- package/package.json +16 -4
- package/src/cli.js +44 -42
- package/src/commands/audit.js +1 -1
- package/src/commands/sbom.js +23 -12
- package/src/commands/visualize.js +16 -16
- package/src/core/auditor.js +10 -10
- package/src/core/enrich.js +27 -1
- package/src/core/installed-metadata.js +179 -0
- package/src/core/license-checker.js +2 -27
- package/src/core/lockfile-parser.js +527 -47
- package/src/core/policy.js +4 -1
- package/src/core/sbom-generator.js +330 -146
- package/src/core/sbom-validator.js +228 -124
- package/src/core/vuln-scanner.js +62 -6
- package/src/reporters/console.js +24 -24
- package/src/utils/logger.js +19 -0
- package/src/utils/yaml.js +225 -0
|
@@ -1,124 +1,228 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Validates an SBOM document against the
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
11
|
-
* -
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
pushCheck(checks, '
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
pushCheck(checks, '
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (!
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
pushCheck(checks, '
|
|
90
|
-
pushCheck(checks, '
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
checks
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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 };
|
package/src/core/vuln-scanner.js
CHANGED
|
@@ -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
|
|
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
|
-
'
|
|
26
|
-
'
|
|
27
|
-
(result.stderr ? `
|
|
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 || '
|
|
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: `
|
|
129
|
+
: [{ title: `Dependency vulnerable through ${via.join(', ')}`, url: null, cwe: [], cvss: null }];
|
|
74
130
|
|
|
75
131
|
vulnerabilities.push({
|
|
76
132
|
name,
|
package/src/reporters/console.js
CHANGED
|
@@ -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 (
|
|
20
|
-
logger.detail(`
|
|
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(`
|
|
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)
|
|
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('
|
|
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}
|
|
47
|
-
`${color.yellow(`${c.moderate}
|
|
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(`…
|
|
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
|
|
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('(
|
|
68
|
+
return color.gray('(no fix)');
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
function renderSbom(section) {
|
|
72
|
-
logger.heading('2)
|
|
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(`
|
|
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('
|
|
86
|
+
logger.success('The SBOM meets the TR-03183-2 required data fields.');
|
|
87
87
|
} else {
|
|
88
|
-
logger.warn(`
|
|
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
|
|
92
|
+
logger.detail(`SBOM written to: ${section.outputPath}`);
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
function renderLicenses(section) {
|
|
97
|
-
logger.heading('3)
|
|
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(`
|
|
103
|
+
logger.detail(`Documented: ${s.documented}/${s.total}`);
|
|
104
104
|
|
|
105
105
|
if (s.missing.length) {
|
|
106
|
-
logger.warn(`${s.missing.length}
|
|
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}
|
|
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}
|
|
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('
|
|
118
|
+
logger.success('All licenses are documented and allowed.');
|
|
119
119
|
}
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
function renderSummary(report) {
|
|
123
|
-
logger.heading('
|
|
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('
|
|
130
|
+
logger.success(color.bold('AUDIT PASSED — the project meets the configured CRA policy.'));
|
|
131
131
|
} else {
|
|
132
|
-
logger.error(color.bold('
|
|
132
|
+
logger.error(color.bold('AUDIT FAILED — CRA compliance issues were detected.'));
|
|
133
133
|
}
|
|
134
134
|
logger.log('');
|
|
135
135
|
}
|
package/src/utils/logger.js
CHANGED
|
@@ -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 };
|