cra-audit 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/README.md +60 -10
- package/package.json +16 -2
- package/src/cli.js +10 -2
- package/src/commands/audit.js +6 -3
- package/src/commands/sbom.js +17 -6
- package/src/commands/visualize.js +8 -4
- package/src/core/auditor.js +63 -10
- package/src/core/installed-metadata.js +179 -0
- package/src/core/kev.js +44 -0
- package/src/core/license-checker.js +2 -27
- package/src/core/lockfile-parser.js +306 -67
- package/src/core/osv.js +211 -0
- package/src/core/policy.js +8 -0
- package/src/core/sbom-generator.js +330 -146
- package/src/core/sbom-validator.js +228 -124
- package/src/core/vuln-scanner.js +161 -11
- package/src/index.js +1 -1
- package/src/reporters/console.js +55 -13
- package/src/reporters/json.js +1 -1
- package/src/utils/http.js +11 -4
|
@@ -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
|
@@ -4,24 +4,174 @@ const fs = require('node:fs');
|
|
|
4
4
|
const os = require('node:os');
|
|
5
5
|
const path = require('node:path');
|
|
6
6
|
const { runJson } = require('../utils/exec');
|
|
7
|
-
const { exists } = require('../utils/fs');
|
|
8
|
-
const { parseLockfile, toNpmLockfile } = require('./lockfile-parser');
|
|
7
|
+
const { exists, readJson } = require('../utils/fs');
|
|
8
|
+
const { parseLockfile, toNpmLockfile, componentKey } = require('./lockfile-parser');
|
|
9
|
+
const {
|
|
10
|
+
queryOsv, osvSeverity, cvssScore, isMalicious, fixedVersion, compareSemver,
|
|
11
|
+
} = require('./osv');
|
|
12
|
+
const { loadKev } = require('./kev');
|
|
9
13
|
|
|
10
14
|
const SEVERITY_ORDER = ['info', 'low', 'moderate', 'high', 'critical'];
|
|
11
15
|
|
|
12
16
|
/**
|
|
13
|
-
* Scans direct and transitive dependencies for known vulnerabilities
|
|
14
|
-
*
|
|
15
|
-
*
|
|
17
|
+
* Scans direct and transitive dependencies for known vulnerabilities. The CRA
|
|
18
|
+
* requires products to ship "without known exploitable vulnerabilities"
|
|
19
|
+
* (Annex I) and, since 11 September 2026, the reporting of actively exploited
|
|
20
|
+
* ones (Art. 14), so this is the core gate of the audit.
|
|
16
21
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
22
|
+
* Sources:
|
|
23
|
+
* - `osv` (default): every exact `name@version` from the lockfile is checked
|
|
24
|
+
* against OSV.dev (GitHub advisories + OpenSSF malicious packages), and the
|
|
25
|
+
* CVEs found are cross-checked with the CISA KEV catalogue.
|
|
26
|
+
* - `npm`: `npm audit`. Also the fallback when OSV.dev is unreachable.
|
|
20
27
|
*
|
|
21
28
|
* @param {string} projectRoot
|
|
22
|
-
* @param {{ production?: boolean }} [options]
|
|
29
|
+
* @param {{ production?: boolean, source?: 'osv'|'npm', kev?: boolean }} [options]
|
|
30
|
+
* @returns {Promise<object>}
|
|
23
31
|
*/
|
|
24
|
-
function scanVulnerabilities(projectRoot, { production = false } = {}) {
|
|
32
|
+
async function scanVulnerabilities(projectRoot, { production = false, source = 'osv', kev = true } = {}) {
|
|
33
|
+
if (source === 'npm') return npmAuditSection(projectRoot, { production });
|
|
34
|
+
|
|
35
|
+
const parsed = parseLockfile(projectRoot);
|
|
36
|
+
if (!parsed.ok) {
|
|
37
|
+
return { ok: false, error: parsed.error, counts: emptyCounts(), vulnerabilities: [] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const rootPkg = readJson(path.join(projectRoot, 'package.json')) || {};
|
|
41
|
+
const components = production ? productionComponents(parsed, rootPkg) : parsed.components;
|
|
42
|
+
const scannable = components.filter((c) => c.name && c.version);
|
|
43
|
+
|
|
44
|
+
const [osv, catalogue] = await Promise.all([
|
|
45
|
+
queryOsv(scannable),
|
|
46
|
+
kev ? loadKev() : Promise.resolve(null),
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
if (!osv.ok) {
|
|
50
|
+
const fallback = npmAuditSection(projectRoot, { production });
|
|
51
|
+
fallback.warnings = [`${osv.error}; fell back to \`npm audit\` (no malicious-package or KEV check).`];
|
|
52
|
+
return fallback;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const direct = new Set(parsed.root.dependsOn);
|
|
56
|
+
const vulnerabilities = [];
|
|
57
|
+
for (const c of scannable) {
|
|
58
|
+
const key = componentKey(c.name, c.version);
|
|
59
|
+
const records = osv.byComponent.get(key);
|
|
60
|
+
if (!records || !records.length) continue;
|
|
61
|
+
vulnerabilities.push(toFinding(c, records, direct.has(key), catalogue));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const counts = emptyCounts();
|
|
65
|
+
for (const v of vulnerabilities) {
|
|
66
|
+
counts[v.severity] = (counts[v.severity] || 0) + 1;
|
|
67
|
+
if (v.malicious) counts.malicious++;
|
|
68
|
+
if (v.kev) counts.kev++;
|
|
69
|
+
}
|
|
70
|
+
counts.total = vulnerabilities.length;
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
ok: true,
|
|
74
|
+
source: 'osv',
|
|
75
|
+
scanned: scannable.length,
|
|
76
|
+
kev: catalogue
|
|
77
|
+
? (catalogue.ok
|
|
78
|
+
? { checked: true, catalogVersion: catalogue.catalogVersion, entries: catalogue.count }
|
|
79
|
+
: { checked: false, error: catalogue.error })
|
|
80
|
+
: { checked: false, error: 'KEV check disabled' },
|
|
81
|
+
counts,
|
|
82
|
+
vulnerabilities: sortBySeverity(vulnerabilities),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** One finding per vulnerable component, aggregating its OSV records. */
|
|
87
|
+
function toFinding(component, records, isDirect, catalogue) {
|
|
88
|
+
const sources = records.map((record) => {
|
|
89
|
+
const aliases = record.aliases || [];
|
|
90
|
+
const cves = [record.id, ...aliases].filter((id) => /^CVE-/i.test(id));
|
|
91
|
+
const kevEntry = catalogue && catalogue.ok
|
|
92
|
+
? cves.map((cve) => catalogue.byCve.get(cve.toUpperCase())).find(Boolean) || null
|
|
93
|
+
: null;
|
|
94
|
+
return {
|
|
95
|
+
id: record.id,
|
|
96
|
+
aliases,
|
|
97
|
+
title: record.summary || (isMalicious(record) ? 'Malicious package' : record.id),
|
|
98
|
+
url: `https://osv.dev/vulnerability/${record.id}`,
|
|
99
|
+
severity: osvSeverity(record),
|
|
100
|
+
cvss: cvssScore(record),
|
|
101
|
+
cwe: (record.database_specific && record.database_specific.cwe_ids) || [],
|
|
102
|
+
malicious: isMalicious(record),
|
|
103
|
+
fixed: fixedVersion(record, component.name, component.version),
|
|
104
|
+
kev: kevEntry,
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const severity = sources.map((s) => s.severity)
|
|
109
|
+
.reduce((max, s) => (severityRank(s) > severityRank(max) ? s : max), 'unknown');
|
|
110
|
+
const fixes = sources.map((s) => s.fixed);
|
|
111
|
+
// Only claim a fix when every advisory has one; suggest the highest of them.
|
|
112
|
+
const fix = fixes.every(Boolean)
|
|
113
|
+
? fixes.reduce((a, b) => (compareSemver(a, b) >= 0 ? a : b))
|
|
114
|
+
: null;
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
name: component.name,
|
|
118
|
+
version: component.version,
|
|
119
|
+
severity,
|
|
120
|
+
direct: isDirect,
|
|
121
|
+
range: null,
|
|
122
|
+
malicious: sources.some((s) => s.malicious),
|
|
123
|
+
kev: sources.some((s) => s.kev),
|
|
124
|
+
fixAvailable: fix
|
|
125
|
+
? { name: component.name, version: fix, breaking: major(fix) !== major(component.version) }
|
|
126
|
+
: false,
|
|
127
|
+
// Malicious and actively exploited advisories first, then by severity.
|
|
128
|
+
sources: sources.sort((a, b) => Number(b.malicious) - Number(a.malicious) ||
|
|
129
|
+
Number(Boolean(b.kev)) - Number(Boolean(a.kev)) ||
|
|
130
|
+
severityRank(b.severity) - severityRank(a.severity)),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Components reachable from the production dependencies declared in
|
|
136
|
+
* package.json (`dependencies` + `optionalDependencies`), following the graph.
|
|
137
|
+
*/
|
|
138
|
+
function productionComponents(parsed, rootPkg) {
|
|
139
|
+
const prodNames = new Set([
|
|
140
|
+
...Object.keys(rootPkg.dependencies || {}),
|
|
141
|
+
...Object.keys(rootPkg.optionalDependencies || {}),
|
|
142
|
+
]);
|
|
143
|
+
const byKey = new Map(parsed.components.map((c) => [componentKey(c.name, c.version), c]));
|
|
144
|
+
const queue = parsed.root.dependsOn.filter((key) => prodNames.has(byKey.has(key) ? byKey.get(key).name : null));
|
|
145
|
+
const seen = new Set(queue);
|
|
146
|
+
while (queue.length) {
|
|
147
|
+
const comp = byKey.get(queue.shift());
|
|
148
|
+
if (!comp) continue;
|
|
149
|
+
for (const dep of comp.dependsOn) {
|
|
150
|
+
if (!seen.has(dep)) { seen.add(dep); queue.push(dep); }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return parsed.components.filter((c) => seen.has(componentKey(c.name, c.version)));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function severityRank(severity) {
|
|
157
|
+
return SEVERITY_ORDER.indexOf(severity);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function major(version) {
|
|
161
|
+
return parseInt(String(version).split('.')[0], 10);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** `npm audit` source, used on request or as the OSV fallback. */
|
|
165
|
+
function npmAuditSection(projectRoot, { production }) {
|
|
166
|
+
const result = npmAudit(projectRoot, { production });
|
|
167
|
+
return {
|
|
168
|
+
...result,
|
|
169
|
+
source: 'npm-audit',
|
|
170
|
+
kev: { checked: false, error: 'npm audit reports no CVE identifiers' },
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function npmAudit(projectRoot, { production = false } = {}) {
|
|
25
175
|
if (hasNpmLockfile(projectRoot)) {
|
|
26
176
|
return runNpmAudit(projectRoot, { production });
|
|
27
177
|
}
|
|
@@ -187,7 +337,7 @@ function normalizeFix(fixAvailable) {
|
|
|
187
337
|
}
|
|
188
338
|
|
|
189
339
|
function emptyCounts() {
|
|
190
|
-
return { info: 0, low: 0, moderate: 0, high: 0, critical: 0, total: 0 };
|
|
340
|
+
return { info: 0, low: 0, moderate: 0, high: 0, critical: 0, unknown: 0, malicious: 0, kev: 0, total: 0 };
|
|
191
341
|
}
|
|
192
342
|
|
|
193
343
|
function sumCounts(counts) {
|
package/src/index.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Example:
|
|
7
7
|
* const { runAudit, loadPolicy } = require('cra-audit');
|
|
8
8
|
* const { policy, source } = loadPolicy(process.cwd());
|
|
9
|
-
* const report = runAudit(process.cwd(), policy, source);
|
|
9
|
+
* const report = await runAudit(process.cwd(), policy, source);
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
const { runAudit } = require('./core/auditor');
|