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.
@@ -0,0 +1,211 @@
1
+ 'use strict';
2
+
3
+ const { fetchJson, mapLimit } = require('../utils/http');
4
+
5
+ const OSV_API = 'https://api.osv.dev/v1';
6
+ const BATCH_SIZE = 1000; // OSV querybatch limit
7
+
8
+ /**
9
+ * Queries OSV.dev (https://osv.dev) for the advisories affecting each exact
10
+ * `name@version`. OSV aggregates the GitHub Advisory Database (GHSA) and the
11
+ * OpenSSF malicious-packages feed (MAL-*), which lists compromised releases
12
+ * such as the ones published by the Shai-Hulud worm.
13
+ *
14
+ * Only package names and versions leave the machine.
15
+ *
16
+ * @param {Array<{ name: string, version: string }>} components
17
+ * @param {{ timeout?: number, concurrency?: number }} [options]
18
+ * @returns {Promise<{ ok: true, byComponent: Map<string, object[]> } | { ok: false, error: string }>}
19
+ * `byComponent` is keyed by `name@version` and holds full OSV records.
20
+ */
21
+ async function queryOsv(components, { timeout = 30000, concurrency = 8 } = {}) {
22
+ const idsByKey = new Map();
23
+
24
+ for (let i = 0; i < components.length; i += BATCH_SIZE) {
25
+ const chunk = components.slice(i, i + BATCH_SIZE);
26
+ const res = await fetchJson(`${OSV_API}/querybatch`, {
27
+ timeout,
28
+ body: { queries: chunk.map((c) => ({ package: { name: c.name, ecosystem: 'npm' }, version: c.version })) },
29
+ });
30
+ if (!res || !res.ok || !res.json || !Array.isArray(res.json.results)) {
31
+ return { ok: false, error: `OSV.dev query failed${res ? ` (HTTP ${res.status})` : ' (network unreachable)'}` };
32
+ }
33
+
34
+ for (let j = 0; j < chunk.length; j++) {
35
+ const result = res.json.results[j] || {};
36
+ const ids = (result.vulns || []).map((v) => v.id);
37
+ // A package with many advisories is paginated: follow the token.
38
+ let pageToken = result.next_page_token;
39
+ while (pageToken) {
40
+ const page = await fetchJson(`${OSV_API}/query`, {
41
+ timeout,
42
+ body: { package: { name: chunk[j].name, ecosystem: 'npm' }, version: chunk[j].version, page_token: pageToken },
43
+ });
44
+ if (!page || !page.ok || !page.json) break;
45
+ ids.push(...(page.json.vulns || []).map((v) => v.id));
46
+ pageToken = page.json.next_page_token;
47
+ }
48
+ if (ids.length) idsByKey.set(`${chunk[j].name}@${chunk[j].version}`, ids);
49
+ }
50
+ }
51
+
52
+ // querybatch only returns ids: fetch each distinct record once.
53
+ const uniqueIds = [...new Set([].concat(...idsByKey.values()))];
54
+ const records = new Map();
55
+ let failed = 0;
56
+ await mapLimit(uniqueIds, async (id) => {
57
+ const res = await fetchJson(`${OSV_API}/vulns/${encodeURIComponent(id)}`, { timeout });
58
+ if (res && res.ok && res.json) records.set(id, res.json);
59
+ else failed++;
60
+ }, concurrency);
61
+ if (failed) {
62
+ return { ok: false, error: `Could not download ${failed} advisory record(s) from OSV.dev` };
63
+ }
64
+
65
+ const byComponent = new Map();
66
+ for (const [key, ids] of idsByKey) {
67
+ const list = ids.map((id) => records.get(id)).filter((r) => r && !r.withdrawn);
68
+ // The same advisory can appear under two ids (e.g. GHSA and its CVE).
69
+ const seen = new Set();
70
+ const unique = [];
71
+ for (const record of list) {
72
+ if (seen.has(record.id)) continue;
73
+ [record.id, ...(record.aliases || [])].forEach((a) => seen.add(a));
74
+ unique.push(record);
75
+ }
76
+ byComponent.set(key, unique);
77
+ }
78
+ return { ok: true, byComponent };
79
+ }
80
+
81
+ /**
82
+ * Severity of an OSV record in npm-audit terms. Malicious packages are always
83
+ * critical; GHSA records carry a reviewed rating; otherwise the CVSS v3 base
84
+ * score is computed from the vector.
85
+ *
86
+ * @returns {'low'|'moderate'|'high'|'critical'|'unknown'}
87
+ */
88
+ function osvSeverity(record) {
89
+ if (isMalicious(record)) return 'critical';
90
+
91
+ const rated = record.database_specific && record.database_specific.severity;
92
+ if (typeof rated === 'string') {
93
+ const s = rated.toLowerCase();
94
+ if (s === 'medium') return 'moderate';
95
+ if (['low', 'moderate', 'high', 'critical'].includes(s)) return s;
96
+ }
97
+
98
+ const score = cvssScore(record);
99
+ if (score === null) return 'unknown';
100
+ if (score >= 9) return 'critical';
101
+ if (score >= 7) return 'high';
102
+ if (score >= 4) return 'moderate';
103
+ return 'low';
104
+ }
105
+
106
+ /** CVSS v3 base score of the record, or null when it has no v3 vector. */
107
+ function cvssScore(record) {
108
+ const v3 = (record.severity || []).find((s) => s.type === 'CVSS_V3');
109
+ return v3 ? cvss3BaseScore(v3.score) : null;
110
+ }
111
+
112
+ /** OpenSSF malicious-packages advisories use the MAL- prefix. */
113
+ function isMalicious(record) {
114
+ return typeof record.id === 'string' && record.id.startsWith('MAL-');
115
+ }
116
+
117
+ /**
118
+ * Lowest version that fixes the advisory for the installed version, taken from
119
+ * the OSV `affected[].ranges[].events` of this package.
120
+ *
121
+ * @returns {string|null}
122
+ */
123
+ function fixedVersion(record, name, version) {
124
+ let best = null;
125
+ for (const affected of record.affected || []) {
126
+ if (!affected.package || affected.package.name !== name) continue;
127
+ for (const range of affected.ranges || []) {
128
+ if (range.type !== 'SEMVER' && range.type !== 'ECOSYSTEM') continue;
129
+ let introduced = null;
130
+ for (const event of range.events || []) {
131
+ if (event.introduced !== undefined) introduced = event.introduced;
132
+ if (event.fixed === undefined) continue;
133
+ const inRange = (introduced === '0' || introduced === null || compareSemver(version, introduced) >= 0) &&
134
+ compareSemver(version, event.fixed) < 0;
135
+ if (inRange && (!best || compareSemver(event.fixed, best) < 0)) best = event.fixed;
136
+ }
137
+ }
138
+ }
139
+ return best;
140
+ }
141
+
142
+ /**
143
+ * Compares two semver strings (major.minor.patch[-prerelease]).
144
+ * Build metadata is ignored.
145
+ */
146
+ function compareSemver(a, b) {
147
+ const parse = (v) => {
148
+ const [core, pre] = String(v).split('+')[0].split(/-(.*)/s);
149
+ return { nums: core.split('.').map((n) => parseInt(n, 10) || 0), pre: pre ? pre.split('.') : [] };
150
+ };
151
+ const pa = parse(a);
152
+ const pb = parse(b);
153
+ for (let i = 0; i < 3; i++) {
154
+ const diff = (pa.nums[i] || 0) - (pb.nums[i] || 0);
155
+ if (diff) return diff;
156
+ }
157
+ // A version without prerelease is greater than one with it.
158
+ if (!pa.pre.length || !pb.pre.length) return pb.pre.length - pa.pre.length;
159
+ for (let i = 0; i < Math.max(pa.pre.length, pb.pre.length); i++) {
160
+ const x = pa.pre[i];
161
+ const y = pb.pre[i];
162
+ if (x === undefined) return -1;
163
+ if (y === undefined) return 1;
164
+ const nx = /^\d+$/.test(x);
165
+ const ny = /^\d+$/.test(y);
166
+ if (nx && ny && Number(x) !== Number(y)) return Number(x) - Number(y);
167
+ if (nx !== ny) return nx ? -1 : 1;
168
+ if (x !== y) return x < y ? -1 : 1;
169
+ }
170
+ return 0;
171
+ }
172
+
173
+ const CVSS3_WEIGHTS = {
174
+ AV: { N: 0.85, A: 0.62, L: 0.55, P: 0.2 },
175
+ AC: { L: 0.77, H: 0.44 },
176
+ UI: { N: 0.85, R: 0.62 },
177
+ C: { H: 0.56, L: 0.22, N: 0 },
178
+ I: { H: 0.56, L: 0.22, N: 0 },
179
+ A: { H: 0.56, L: 0.22, N: 0 },
180
+ };
181
+
182
+ /** CVSS v3.0/v3.1 base score from a vector string (FIRST specification). */
183
+ function cvss3BaseScore(vector) {
184
+ const m = {};
185
+ for (const part of String(vector).split('/')) {
186
+ const [k, v] = part.split(':');
187
+ m[k] = v;
188
+ }
189
+ const scopeChanged = m.S === 'C';
190
+ const pr = { N: 0.85, L: scopeChanged ? 0.68 : 0.62, H: scopeChanged ? 0.5 : 0.27 }[m.PR];
191
+ const w = CVSS3_WEIGHTS;
192
+ const values = [w.AV[m.AV], w.AC[m.AC], pr, w.UI[m.UI], w.C[m.C], w.I[m.I], w.A[m.A]];
193
+ if (values.some((x) => x === undefined)) return null;
194
+ const [av, ac, , ui, c, i, a] = values;
195
+
196
+ const iss = 1 - (1 - c) * (1 - i) * (1 - a);
197
+ const impact = scopeChanged ? 7.52 * (iss - 0.029) - 3.25 * Math.pow(iss - 0.02, 15) : 6.42 * iss;
198
+ if (impact <= 0) return 0;
199
+ const exploitability = 8.22 * av * ac * pr * ui;
200
+ const raw = scopeChanged ? Math.min(1.08 * (impact + exploitability), 10) : Math.min(impact + exploitability, 10);
201
+ return roundUp(raw);
202
+ }
203
+
204
+ function roundUp(value) {
205
+ const int = Math.round(value * 100000);
206
+ return int % 10000 === 0 ? int / 100000 : (Math.floor(int / 10000) + 1) / 10;
207
+ }
208
+
209
+ module.exports = {
210
+ queryOsv, osvSeverity, cvssScore, isMalicious, fixedVersion, compareSemver, cvss3BaseScore,
211
+ };
@@ -15,6 +15,14 @@ const DEFAULT_POLICY = {
15
15
  // Require an SBOM that satisfies the minimum TR-03183 elements.
16
16
  requireSbom: true,
17
17
  sbomFormat: 'cyclonedx', // cyclonedx | spdx
18
+ // Email or URL of the SBOM creator (TR-03183-2 §5.2.1). Defaults to the
19
+ // project's package.json author, homepage or repository.
20
+ sbomCreator: null,
21
+ // Vulnerability source: `osv` (OSV.dev + CISA KEV) or `npm` (npm audit).
22
+ vulnerabilitySource: 'osv',
23
+ // Fail when a dependency has an actively exploited vulnerability (CISA KEV),
24
+ // the trigger of the CRA Art. 14 reporting obligation.
25
+ failOnKev: true,
18
26
  // Audit only production dependencies (recommended for shipped products).
19
27
  productionOnly: false,
20
28
  vulnerabilities: {