cra-audit 1.1.0 → 2.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/CHANGELOG.md +74 -0
- package/README.md +139 -9
- package/package.json +2 -1
- package/src/cli.js +29 -1
- package/src/commands/audit.js +10 -3
- package/src/commands/readiness.js +59 -0
- package/src/commands/vex.js +70 -0
- package/src/commands/visualize.js +8 -4
- package/src/core/auditor.js +73 -8
- package/src/core/kev.js +44 -0
- package/src/core/osv.js +211 -0
- package/src/core/policy.js +5 -0
- package/src/core/readiness.js +231 -0
- package/src/core/vex.js +264 -0
- package/src/core/vuln-scanner.js +161 -11
- package/src/index.js +7 -1
- package/src/reporters/console.js +53 -11
- package/src/reporters/json.js +1 -1
- package/src/reporters/sarif.js +238 -0
- package/src/utils/http.js +11 -4
package/src/core/vex.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const { buildPurl } = require('./lockfile-parser');
|
|
5
|
+
|
|
6
|
+
const TOOL_NAME = 'cra-audit';
|
|
7
|
+
const TOOL_VERSION = require('../../package.json').version;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* VEX (Vulnerability Exploitability eXchange) states the exploitability of
|
|
11
|
+
* each known vulnerability in the product. TR-03183-2 keeps vulnerability data
|
|
12
|
+
* out of the SBOM and points to VEX for it, and the CRA asks manufacturers to
|
|
13
|
+
* document how each vulnerability was assessed.
|
|
14
|
+
*
|
|
15
|
+
* The assessments come from the policy allowlist
|
|
16
|
+
* (`vulnerabilities.allowlist` in .cra-audit.json). Entries can be plain ids
|
|
17
|
+
* or package names (legacy), or objects:
|
|
18
|
+
*
|
|
19
|
+
* { "id": "CVE-2021-23337", "package": "lodash",
|
|
20
|
+
* "status": "not_affected", "justification": "code_not_reachable",
|
|
21
|
+
* "detail": "We never call _.template with user input." }
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Statuses, as written in the policy, and whether they accept the finding. */
|
|
25
|
+
const STATUSES = {
|
|
26
|
+
not_affected: { accepts: true, cdx: 'not_affected', openvex: 'not_affected' },
|
|
27
|
+
false_positive: { accepts: true, cdx: 'false_positive', openvex: 'not_affected' },
|
|
28
|
+
affected: { accepts: false, cdx: 'exploitable', openvex: 'affected' },
|
|
29
|
+
under_investigation: { accepts: false, cdx: 'in_triage', openvex: 'under_investigation' },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Justifications accepted in the policy (CycloneDX or OpenVEX vocabulary) and
|
|
34
|
+
* their equivalent in the other format.
|
|
35
|
+
*/
|
|
36
|
+
const JUSTIFICATIONS = {
|
|
37
|
+
code_not_present: { cdx: 'code_not_present', openvex: 'vulnerable_code_not_present' },
|
|
38
|
+
code_not_reachable: { cdx: 'code_not_reachable', openvex: 'vulnerable_code_not_in_execute_path' },
|
|
39
|
+
requires_configuration: { cdx: 'requires_configuration', openvex: 'vulnerable_code_cannot_be_controlled_by_adversary' },
|
|
40
|
+
requires_dependency: { cdx: 'requires_dependency', openvex: 'vulnerable_code_not_in_execute_path' },
|
|
41
|
+
requires_environment: { cdx: 'requires_environment', openvex: 'vulnerable_code_cannot_be_controlled_by_adversary' },
|
|
42
|
+
protected_by_compiler: { cdx: 'protected_by_compiler', openvex: 'inline_mitigations_already_exist' },
|
|
43
|
+
protected_at_runtime: { cdx: 'protected_at_runtime', openvex: 'inline_mitigations_already_exist' },
|
|
44
|
+
protected_at_perimeter: { cdx: 'protected_at_perimeter', openvex: 'inline_mitigations_already_exist' },
|
|
45
|
+
protected_by_mitigating_control: { cdx: 'protected_by_mitigating_control', openvex: 'inline_mitigations_already_exist' },
|
|
46
|
+
component_not_present: { cdx: 'code_not_present', openvex: 'component_not_present' },
|
|
47
|
+
vulnerable_code_not_present: { cdx: 'code_not_present', openvex: 'vulnerable_code_not_present' },
|
|
48
|
+
vulnerable_code_not_in_execute_path: { cdx: 'code_not_reachable', openvex: 'vulnerable_code_not_in_execute_path' },
|
|
49
|
+
vulnerable_code_cannot_be_controlled_by_adversary: { cdx: 'requires_environment', openvex: 'vulnerable_code_cannot_be_controlled_by_adversary' },
|
|
50
|
+
inline_mitigations_already_exist: { cdx: 'protected_by_mitigating_control', openvex: 'inline_mitigations_already_exist' },
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Normalizes the policy allowlist into assessment objects.
|
|
55
|
+
* @returns {Array<{ id: string|null, package: string|null, status: string, justification: string|null, detail: string|null }>}
|
|
56
|
+
*/
|
|
57
|
+
function normalizeAllowlist(policy) {
|
|
58
|
+
const list = (policy && policy.vulnerabilities && policy.vulnerabilities.allowlist) || [];
|
|
59
|
+
return list.map((entry) => {
|
|
60
|
+
if (typeof entry === 'string') {
|
|
61
|
+
// Legacy form: a package name, an advisory id/alias or an advisory URL fragment.
|
|
62
|
+
return { id: entry, package: null, status: 'not_affected', justification: null, detail: null, legacy: true };
|
|
63
|
+
}
|
|
64
|
+
const status = STATUSES[entry.status] ? entry.status : 'not_affected';
|
|
65
|
+
return {
|
|
66
|
+
id: entry.id || null,
|
|
67
|
+
package: entry.package || null,
|
|
68
|
+
status,
|
|
69
|
+
justification: JUSTIFICATIONS[entry.justification] ? entry.justification : null,
|
|
70
|
+
detail: entry.detail || null,
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Does an assessment apply to one advisory of a finding? */
|
|
76
|
+
function entryMatches(entry, finding, source) {
|
|
77
|
+
if (entry.legacy && entry.id === finding.name) return true;
|
|
78
|
+
if (entry.package && entry.package !== finding.name) return false;
|
|
79
|
+
if (!entry.id) return Boolean(entry.package);
|
|
80
|
+
const ids = [source.id, ...(source.aliases || [])].filter(Boolean);
|
|
81
|
+
return ids.includes(entry.id) || Boolean(source.url && source.url.includes(entry.id));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The assessment recorded for one advisory of a finding, if any. */
|
|
85
|
+
function assessmentFor(finding, source, entries) {
|
|
86
|
+
return entries.find((e) => entryMatches(e, finding, source)) || null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A finding is accepted when every advisory on it has an accepting assessment
|
|
91
|
+
* (not_affected / false_positive). Malicious packages are never accepted.
|
|
92
|
+
*
|
|
93
|
+
* @returns {{ accepted: boolean, unjustified: number }}
|
|
94
|
+
*/
|
|
95
|
+
function acceptance(finding, policy) {
|
|
96
|
+
const entries = normalizeAllowlist(policy);
|
|
97
|
+
if (!entries.length || finding.malicious) return { accepted: false, unjustified: 0 };
|
|
98
|
+
const assessments = finding.sources.map((s) => assessmentFor(finding, s, entries));
|
|
99
|
+
const accepted = assessments.every((a) => a && STATUSES[a.status].accepts);
|
|
100
|
+
const unjustified = accepted ? assessments.filter((a) => !a.justification && !a.detail).length : 0;
|
|
101
|
+
return { accepted, unjustified };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- Documents -------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Builds a VEX document for the vulnerability findings of an audit.
|
|
108
|
+
*
|
|
109
|
+
* @param {{ name: string, version: string }} product
|
|
110
|
+
* @param {object} vulnSection Result of scanVulnerabilities().
|
|
111
|
+
* @param {object} policy
|
|
112
|
+
* @param {{ format?: 'cyclonedx'|'openvex', author?: string|null }} [options]
|
|
113
|
+
*/
|
|
114
|
+
function buildVex(product, vulnSection, policy, { format = 'cyclonedx', author = null } = {}) {
|
|
115
|
+
const entries = normalizeAllowlist(policy);
|
|
116
|
+
const productPurl = buildPurl(product.name, product.version);
|
|
117
|
+
const statements = [];
|
|
118
|
+
|
|
119
|
+
for (const finding of vulnSection.vulnerabilities || []) {
|
|
120
|
+
const purl = buildPurl(finding.name, finding.version);
|
|
121
|
+
for (const source of finding.sources) {
|
|
122
|
+
const assessment = finding.malicious ? null : assessmentFor(finding, source, entries);
|
|
123
|
+
statements.push({ finding, source, purl, assessment });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return format === 'openvex'
|
|
128
|
+
? buildOpenVex(productPurl, statements, author)
|
|
129
|
+
: buildCycloneDxVex(product, productPurl, statements);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function buildCycloneDxVex(product, productPurl, statements) {
|
|
133
|
+
const vulnerabilities = statements.map(({ finding, source, purl, assessment }) => {
|
|
134
|
+
const { primary, others } = identifiers(source);
|
|
135
|
+
const analysis = { state: cdxState(finding, assessment) };
|
|
136
|
+
if (assessment && assessment.justification) analysis.justification = JUSTIFICATIONS[assessment.justification].cdx;
|
|
137
|
+
const detail = analysisDetail(finding, source, assessment);
|
|
138
|
+
if (detail) analysis.detail = detail;
|
|
139
|
+
if (analysis.state === 'exploitable' || analysis.state === 'in_triage') {
|
|
140
|
+
analysis.response = finding.malicious ? ['update'] : finding.fixAvailable ? ['update'] : ['can_not_fix'];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const vuln = {
|
|
144
|
+
'bom-ref': `${primary}@${purl}`,
|
|
145
|
+
id: primary,
|
|
146
|
+
source: { name: 'OSV', url: `https://osv.dev/vulnerability/${source.id}` },
|
|
147
|
+
references: others.map((id) => ({ id, source: { name: sourceName(id), url: advisoryUrl(id) } })),
|
|
148
|
+
ratings: [{
|
|
149
|
+
source: { name: 'OSV' },
|
|
150
|
+
severity: cdxSeverity(source.severity),
|
|
151
|
+
...(source.cvss !== null && source.cvss !== undefined ? { score: source.cvss, method: 'CVSSv31' } : {}),
|
|
152
|
+
}],
|
|
153
|
+
cwes: (source.cwe || []).map((c) => parseInt(String(c).replace(/^CWE-/i, ''), 10)).filter(Number.isFinite),
|
|
154
|
+
description: source.title || undefined,
|
|
155
|
+
advisories: source.url ? [{ url: source.url }] : undefined,
|
|
156
|
+
affects: [{ ref: purl }],
|
|
157
|
+
analysis,
|
|
158
|
+
};
|
|
159
|
+
if (source.kev) {
|
|
160
|
+
vuln.properties = [
|
|
161
|
+
{ name: 'cra-audit:kev', value: 'true' },
|
|
162
|
+
{ name: 'cra-audit:kev:dateAdded', value: String(source.kev.dateAdded) },
|
|
163
|
+
];
|
|
164
|
+
}
|
|
165
|
+
if (!vuln.references.length) delete vuln.references;
|
|
166
|
+
if (!vuln.cwes.length) delete vuln.cwes;
|
|
167
|
+
return vuln;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
bomFormat: 'CycloneDX',
|
|
172
|
+
specVersion: '1.6',
|
|
173
|
+
serialNumber: `urn:uuid:${crypto.randomUUID()}`,
|
|
174
|
+
version: 1,
|
|
175
|
+
metadata: {
|
|
176
|
+
timestamp: new Date().toISOString(),
|
|
177
|
+
tools: { components: [{ type: 'application', name: TOOL_NAME, version: TOOL_VERSION }] },
|
|
178
|
+
component: { type: 'application', 'bom-ref': productPurl, name: product.name, version: product.version, purl: productPurl },
|
|
179
|
+
},
|
|
180
|
+
vulnerabilities,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function buildOpenVex(productPurl, statements, author) {
|
|
185
|
+
return {
|
|
186
|
+
'@context': 'https://openvex.dev/ns/v0.2.0',
|
|
187
|
+
'@id': `https://openvex.dev/docs/public/cra-audit-${crypto.randomUUID()}`,
|
|
188
|
+
author: author || TOOL_NAME,
|
|
189
|
+
timestamp: new Date().toISOString(),
|
|
190
|
+
version: 1,
|
|
191
|
+
tooling: `${TOOL_NAME}/${TOOL_VERSION}`,
|
|
192
|
+
statements: statements.map(({ finding, source, purl, assessment }) => {
|
|
193
|
+
const { primary, others } = identifiers(source);
|
|
194
|
+
const status = finding.malicious ? 'affected' : assessment ? STATUSES[assessment.status].openvex : 'under_investigation';
|
|
195
|
+
const statement = {
|
|
196
|
+
vulnerability: { name: primary, ...(others.length ? { aliases: others } : {}) },
|
|
197
|
+
products: [{ '@id': productPurl, subcomponents: [{ '@id': purl }] }],
|
|
198
|
+
status,
|
|
199
|
+
};
|
|
200
|
+
const detail = analysisDetail(finding, source, assessment);
|
|
201
|
+
if (status === 'not_affected') {
|
|
202
|
+
if (assessment.justification) statement.justification = JUSTIFICATIONS[assessment.justification].openvex;
|
|
203
|
+
// OpenVEX requires a justification or an impact statement.
|
|
204
|
+
statement.impact_statement = detail || 'Assessed as not affected; justification not recorded in the cra-audit policy.';
|
|
205
|
+
} else if (status === 'affected') {
|
|
206
|
+
statement.action_statement = actionStatement(finding);
|
|
207
|
+
} else if (detail) {
|
|
208
|
+
statement.status_notes = detail;
|
|
209
|
+
}
|
|
210
|
+
return statement;
|
|
211
|
+
}),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function cdxState(finding, assessment) {
|
|
216
|
+
if (finding.malicious) return 'exploitable';
|
|
217
|
+
return assessment ? STATUSES[assessment.status].cdx : 'in_triage';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function analysisDetail(finding, source, assessment) {
|
|
221
|
+
const parts = [];
|
|
222
|
+
if (finding.malicious) parts.push('Malicious package (OpenSSF malicious-packages): remove it and rotate exposed credentials.');
|
|
223
|
+
if (source.kev) parts.push(`Listed in CISA KEV (actively exploited) since ${source.kev.dateAdded}.`);
|
|
224
|
+
if (assessment && assessment.detail) parts.push(assessment.detail);
|
|
225
|
+
if (assessment && !assessment.detail && !assessment.justification && STATUSES[assessment.status].accepts) {
|
|
226
|
+
parts.push('Accepted in the cra-audit policy without a recorded justification.');
|
|
227
|
+
}
|
|
228
|
+
return parts.join(' ') || undefined;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function actionStatement(finding) {
|
|
232
|
+
if (finding.malicious) return `Remove ${finding.name}@${finding.version}, reinstall from a clean lockfile and rotate exposed credentials.`;
|
|
233
|
+
if (finding.fixAvailable && typeof finding.fixAvailable === 'object') {
|
|
234
|
+
return `Update ${finding.name} to ${finding.fixAvailable.version} or later.`;
|
|
235
|
+
}
|
|
236
|
+
return `No fixed version of ${finding.name} is available: remove or replace the dependency, or mitigate.`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Prefer the CVE as the VEX id; keep the rest as references/aliases. */
|
|
240
|
+
function identifiers(source) {
|
|
241
|
+
const all = [source.id, ...(source.aliases || [])].filter(Boolean);
|
|
242
|
+
const primary = all.find((id) => /^CVE-/i.test(id)) || all[0] || 'UNKNOWN';
|
|
243
|
+
return { primary, others: all.filter((id) => id !== primary) };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function sourceName(id) {
|
|
247
|
+
if (/^CVE-/i.test(id)) return 'NVD';
|
|
248
|
+
if (/^GHSA-/i.test(id)) return 'GitHub';
|
|
249
|
+
return 'OSV';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function advisoryUrl(id) {
|
|
253
|
+
if (/^CVE-/i.test(id)) return `https://nvd.nist.gov/vuln/detail/${id}`;
|
|
254
|
+
if (/^GHSA-/i.test(id)) return `https://github.com/advisories/${id}`;
|
|
255
|
+
return `https://osv.dev/vulnerability/${id}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** CycloneDX uses "medium" where npm/GHSA say "moderate". */
|
|
259
|
+
function cdxSeverity(severity) {
|
|
260
|
+
if (severity === 'moderate') return 'medium';
|
|
261
|
+
return ['critical', 'high', 'medium', 'low', 'info', 'none'].includes(severity) ? severity : 'unknown';
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
module.exports = { buildVex, normalizeAllowlist, assessmentFor, acceptance, STATUSES, JUSTIFICATIONS };
|
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');
|
|
@@ -18,6 +18,9 @@ const { checkLicenses } = require('./core/license-checker');
|
|
|
18
18
|
const { parseLockfile } = require('./core/lockfile-parser');
|
|
19
19
|
const { enrichComponents } = require('./core/enrich');
|
|
20
20
|
const { buildHtml } = require('./reporters/html');
|
|
21
|
+
const { buildSarif } = require('./reporters/sarif');
|
|
22
|
+
const { buildVex } = require('./core/vex');
|
|
23
|
+
const { checkReadiness } = require('./core/readiness');
|
|
21
24
|
|
|
22
25
|
module.exports = {
|
|
23
26
|
runAudit,
|
|
@@ -30,4 +33,7 @@ module.exports = {
|
|
|
30
33
|
parseLockfile,
|
|
31
34
|
enrichComponents,
|
|
32
35
|
buildHtml,
|
|
36
|
+
buildSarif,
|
|
37
|
+
buildVex,
|
|
38
|
+
checkReadiness,
|
|
33
39
|
};
|
package/src/reporters/console.js
CHANGED
|
@@ -30,36 +30,78 @@ function reportConsole(report) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
function renderVulnerabilities(section) {
|
|
33
|
-
logger.heading('1) Known vulnerabilities (CRA Art.
|
|
33
|
+
logger.heading('1) Known vulnerabilities (CRA Annex I · Art. 14)');
|
|
34
34
|
if (!section.ok) {
|
|
35
35
|
logger.error(section.error);
|
|
36
36
|
return;
|
|
37
37
|
}
|
|
38
|
+
logger.detail(describeSources(section));
|
|
39
|
+
for (const warning of section.warnings || []) logger.warn(warning);
|
|
38
40
|
|
|
39
41
|
const c = section.counts;
|
|
40
42
|
if (c.total === 0) {
|
|
41
|
-
logger.success('No known vulnerabilities. Meets the "
|
|
43
|
+
logger.success('No known vulnerabilities. Meets the "no known exploitable vulnerabilities" requirement.');
|
|
42
44
|
return;
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
logger.log(
|
|
46
48
|
` ${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`)}`
|
|
49
|
+
`${color.yellow(`${c.moderate} moderate`)} · ${color.yellow(`${c.low} low`)} · ${color.gray(`${c.info} info`)}` +
|
|
50
|
+
(c.unknown ? color.gray(` · ${c.unknown} unrated`) : '') +
|
|
51
|
+
(c.kev ? ` · ${color.red(color.bold(`${c.kev} actively exploited (KEV)`))}` : '') +
|
|
52
|
+
(c.malicious ? ` · ${color.red(color.bold(`${c.malicious} MALICIOUS`))}` : '')
|
|
48
53
|
);
|
|
49
54
|
|
|
50
|
-
|
|
55
|
+
// Malicious and exploited findings first: they are the urgent ones.
|
|
56
|
+
const urgency = (v) => (v.malicious ? 2 : v.kev ? 1 : 0);
|
|
57
|
+
const ordered = [...section.vulnerabilities].sort((a, b) => urgency(b) - urgency(a));
|
|
58
|
+
const top = ordered.slice(0, 15);
|
|
51
59
|
for (const vuln of top) {
|
|
52
60
|
const sev = (SEVERITY_COLORS[vuln.severity] || color.gray)(vuln.severity.toUpperCase().padEnd(8));
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
61
|
+
const tags = (vuln.malicious ? color.red(color.bold('[MALICIOUS] ')) : '') + (vuln.kev ? color.red(color.bold('[KEV] ')) : '');
|
|
62
|
+
const label = vuln.version ? `${vuln.name}@${vuln.version}` : vuln.name;
|
|
63
|
+
const fix = vuln.malicious ? color.red('(remove it and rotate any exposed credentials)') : describeFix(vuln.fixAvailable);
|
|
64
|
+
logger.log(` ${sev} ${tags}${color.bold(label)} ${color.gray(vuln.range || '')} ${fix}`);
|
|
65
|
+
for (const adv of vuln.sources.slice(0, 3)) {
|
|
66
|
+
const kev = adv.kev ? color.red(` [KEV since ${adv.kev.dateAdded}]`) : '';
|
|
67
|
+
const id = adv.id ? `${adv.id}${(adv.aliases || []).filter((a) => a.startsWith('CVE-')).map((a) => ` / ${a}`).join('')} ` : '';
|
|
68
|
+
logger.detail(`${id}${adv.title || ''}${kev}${adv.url ? ' — ' + adv.url : ''}`);
|
|
69
|
+
}
|
|
70
|
+
if (vuln.sources.length > 3) logger.detail(`… and ${vuln.sources.length - 3} more advisories.`);
|
|
71
|
+
}
|
|
72
|
+
if (ordered.length > top.length) {
|
|
73
|
+
logger.detail(`… and ${ordered.length - top.length} more.`);
|
|
57
74
|
}
|
|
58
|
-
|
|
59
|
-
|
|
75
|
+
|
|
76
|
+
if (c.kev) renderArticle14Notice();
|
|
77
|
+
if (c.malicious) {
|
|
78
|
+
logger.log('');
|
|
79
|
+
logger.error(color.bold('Malicious package(s) detected: treat this as a security incident.'));
|
|
80
|
+
logger.detail('Remove the package, reinstall from a clean lockfile, and rotate every credential');
|
|
81
|
+
logger.detail('available to machines that installed it (npm/GitHub tokens, cloud keys, CI secrets).');
|
|
60
82
|
}
|
|
61
83
|
}
|
|
62
84
|
|
|
85
|
+
function describeSources(section) {
|
|
86
|
+
if (section.source !== 'osv') return 'Source: npm audit';
|
|
87
|
+
const kev = section.kev && section.kev.checked
|
|
88
|
+
? `CISA KEV ${section.kev.catalogVersion || ''} (${section.kev.entries} entries)`.replace(' ', ' ')
|
|
89
|
+
: `CISA KEV not checked (${(section.kev && section.kev.error) || 'unavailable'})`;
|
|
90
|
+
return `Sources: OSV.dev (GitHub advisories + OpenSSF malicious packages) · ${kev} · ${section.scanned} components`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** CRA Art. 14 reporting clock, shown when a KEV-listed vulnerability is found. */
|
|
94
|
+
function renderArticle14Notice() {
|
|
95
|
+
logger.log('');
|
|
96
|
+
logger.warn(color.bold('CRA Art. 14 — actively exploited vulnerability in a dependency'));
|
|
97
|
+
logger.detail('If it affects a product with digital elements you place on the EU market, notify');
|
|
98
|
+
logger.detail('the coordinating CSIRT and ENISA through the Single Reporting Platform:');
|
|
99
|
+
logger.detail(' • Early warning ........ within 24 hours of becoming aware');
|
|
100
|
+
logger.detail(' • Notification ......... within 72 hours');
|
|
101
|
+
logger.detail(' • Final report ......... within 14 days after a corrective measure is available');
|
|
102
|
+
logger.detail('Assess exploitability in your product first; document the decision either way.');
|
|
103
|
+
}
|
|
104
|
+
|
|
63
105
|
function describeFix(fix) {
|
|
64
106
|
if (fix === true) return color.green('(fix available)');
|
|
65
107
|
if (fix && typeof fix === 'object') {
|
|
@@ -122,7 +164,7 @@ function renderLicenses(section) {
|
|
|
122
164
|
function renderSummary(report) {
|
|
123
165
|
logger.heading('Result');
|
|
124
166
|
for (const item of report.gate.reasons) {
|
|
125
|
-
const mark = item.passed ? color.
|
|
167
|
+
const mark = !item.passed ? color.red('✖') : item.warning ? color.yellow('!') : color.green('✔');
|
|
126
168
|
logger.log(` ${mark} ${item.label}`);
|
|
127
169
|
}
|
|
128
170
|
logger.log('');
|
package/src/reporters/json.js
CHANGED
|
@@ -11,7 +11,7 @@ const { writeJson } = require('../utils/fs');
|
|
|
11
11
|
function reportJson(report, { outputPath } = {}) {
|
|
12
12
|
if (outputPath) {
|
|
13
13
|
writeJson(outputPath, report);
|
|
14
|
-
logger.success(`
|
|
14
|
+
logger.success(`JSON report written to: ${outputPath}`);
|
|
15
15
|
} else {
|
|
16
16
|
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
17
17
|
}
|