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.
@@ -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 };
@@ -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 using
14
- * `npm audit --json`. The CRA requires products to ship "without known
15
- * exploitable vulnerabilities", so this is the core gate of the audit.
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
- * 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.
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
  };
@@ -30,36 +30,78 @@ function reportConsole(report) {
30
30
  }
31
31
 
32
32
  function renderVulnerabilities(section) {
33
- logger.heading('1) Known vulnerabilities (CRA Art. 13)');
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 "zero exploitable vulnerabilities" requirement.');
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
- const top = section.vulnerabilities.slice(0, 15);
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 fix = describeFix(vuln.fixAvailable);
54
- logger.log(` ${sev} ${color.bold(vuln.name)} ${color.gray(vuln.range || '')} ${fix}`);
55
- const adv = vuln.sources[0];
56
- if (adv && adv.title) logger.detail(`${adv.title}${adv.url ? ' — ' + adv.url : ''}`);
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
- if (section.vulnerabilities.length > top.length) {
59
- logger.detail(`… and ${section.vulnerabilities.length - top.length} more.`);
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.green('✔') : color.red('✖');
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('');
@@ -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(`Informe JSON escrito en: ${outputPath}`);
14
+ logger.success(`JSON report written to: ${outputPath}`);
15
15
  } else {
16
16
  process.stdout.write(JSON.stringify(report, null, 2) + '\n');
17
17
  }