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/auditor.js
CHANGED
|
@@ -4,6 +4,7 @@ const { scanVulnerabilities, SEVERITY_ORDER } = require('./vuln-scanner');
|
|
|
4
4
|
const { generateSbom } = require('./sbom-generator');
|
|
5
5
|
const { validateSbom } = require('./sbom-validator');
|
|
6
6
|
const { checkLicenses } = require('./license-checker');
|
|
7
|
+
const { acceptance } = require('./vex');
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* @typedef {object} AuditResult
|
|
@@ -21,21 +22,29 @@ const { checkLicenses } = require('./license-checker');
|
|
|
21
22
|
* @param {object} policy
|
|
22
23
|
* @param {string|null} policySource
|
|
23
24
|
* @param {{ only?: 'vulnerabilities'|'sbom'|'licenses' }} [options]
|
|
24
|
-
* @returns {AuditResult}
|
|
25
|
+
* @returns {Promise<AuditResult>}
|
|
25
26
|
*/
|
|
26
|
-
function runAudit(projectRoot, policy, policySource, options = {}) {
|
|
27
|
+
async function runAudit(projectRoot, policy, policySource, options = {}) {
|
|
27
28
|
const { only } = options;
|
|
28
29
|
const sections = {};
|
|
29
30
|
const reasons = [];
|
|
30
31
|
|
|
31
32
|
// --- 1. Vulnerabilities -------------------------------------------------
|
|
32
33
|
if (!only || only === 'vulnerabilities') {
|
|
33
|
-
const vulns = scanVulnerabilities(projectRoot, {
|
|
34
|
+
const vulns = await scanVulnerabilities(projectRoot, {
|
|
35
|
+
production: policy.productionOnly,
|
|
36
|
+
source: policy.vulnerabilitySource,
|
|
37
|
+
});
|
|
34
38
|
sections.vulnerabilities = vulns;
|
|
35
39
|
|
|
36
40
|
if (!vulns.ok) {
|
|
37
41
|
reasons.push({ label: `Vulnerabilities could not be analyzed: ${vulns.error}`, passed: false });
|
|
38
42
|
} else {
|
|
43
|
+
for (const warning of vulns.warnings || []) {
|
|
44
|
+
reasons.push({ label: warning, passed: true, warning: true });
|
|
45
|
+
}
|
|
46
|
+
reasons.push(...exploitationReasons(vulns, policy));
|
|
47
|
+
reasons.push(...justificationReasons(vulns, policy));
|
|
39
48
|
const blocking = countBlocking(vulns, policy);
|
|
40
49
|
reasons.push({
|
|
41
50
|
label: blocking === 0
|
|
@@ -97,22 +106,78 @@ function runAudit(projectRoot, policy, policySource, options = {}) {
|
|
|
97
106
|
};
|
|
98
107
|
}
|
|
99
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Gate reasons for the OSV source: malicious packages always block (they are
|
|
111
|
+
* compromised releases, not bugs, so the allowlist cannot accept them), and
|
|
112
|
+
* actively exploited vulnerabilities (CISA KEV) block unless `failOnKev` is off.
|
|
113
|
+
*/
|
|
114
|
+
function exploitationReasons(vulns, policy) {
|
|
115
|
+
if (vulns.source !== 'osv') return [];
|
|
116
|
+
const reasons = [];
|
|
117
|
+
|
|
118
|
+
const malicious = vulns.vulnerabilities.filter((v) => v.malicious);
|
|
119
|
+
reasons.push({
|
|
120
|
+
label: malicious.length === 0
|
|
121
|
+
? 'No malicious packages (OpenSSF malicious-packages via OSV.dev)'
|
|
122
|
+
: `${malicious.length} malicious package(s): ${malicious.map((v) => `${v.name}@${v.version}`).join(', ')}`,
|
|
123
|
+
passed: malicious.length === 0,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
if (!vulns.kev || !vulns.kev.checked) {
|
|
127
|
+
reasons.push({
|
|
128
|
+
label: `Actively exploited vulnerabilities not checked: ${(vulns.kev && vulns.kev.error) || 'CISA KEV unavailable'}`,
|
|
129
|
+
passed: true,
|
|
130
|
+
warning: true,
|
|
131
|
+
});
|
|
132
|
+
return reasons;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const exploited = vulns.vulnerabilities.filter((v) => v.kev && !isAllowlisted(v, policy));
|
|
136
|
+
reasons.push({
|
|
137
|
+
label: exploited.length === 0
|
|
138
|
+
? 'No actively exploited vulnerabilities (CISA KEV)'
|
|
139
|
+
: `${exploited.length} component(s) with actively exploited vulnerabilities (CISA KEV) — CRA Art. 14 reporting may apply`,
|
|
140
|
+
passed: exploited.length === 0 || policy.failOnKev === false,
|
|
141
|
+
warning: exploited.length > 0 && policy.failOnKev === false,
|
|
142
|
+
});
|
|
143
|
+
return reasons;
|
|
144
|
+
}
|
|
145
|
+
|
|
100
146
|
function countBlocking(vulns, policy) {
|
|
101
147
|
const threshold = SEVERITY_ORDER.indexOf(policy.failOn);
|
|
102
148
|
if (threshold === -1) return 0;
|
|
103
|
-
const allowlist = new Set((policy.vulnerabilities && policy.vulnerabilities.allowlist) || []);
|
|
104
149
|
|
|
105
150
|
let count = 0;
|
|
106
151
|
for (const vuln of vulns.vulnerabilities) {
|
|
107
152
|
if (SEVERITY_ORDER.indexOf(vuln.severity) < threshold) continue;
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
) || allowlist.has(vuln.name);
|
|
111
|
-
if (!isAllowlisted) count++;
|
|
153
|
+
if (!vuln.malicious && isAllowlisted(vuln, policy)) continue;
|
|
154
|
+
count++;
|
|
112
155
|
}
|
|
113
156
|
return count;
|
|
114
157
|
}
|
|
115
158
|
|
|
159
|
+
/**
|
|
160
|
+
* A finding is accepted when every advisory on it is assessed as not_affected
|
|
161
|
+
* (or false_positive) in the policy allowlist. See ./vex.js.
|
|
162
|
+
*/
|
|
163
|
+
function isAllowlisted(vuln, policy) {
|
|
164
|
+
return acceptance(vuln, policy).accepted;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Accepted vulnerabilities must be documented (CRA Annex I Part II): warn
|
|
169
|
+
* when an allowlist entry carries no justification or detail for the VEX.
|
|
170
|
+
*/
|
|
171
|
+
function justificationReasons(vulns, policy) {
|
|
172
|
+
const unjustified = vulns.vulnerabilities.reduce((n, v) => n + acceptance(v, policy).unjustified, 0);
|
|
173
|
+
if (!unjustified) return [];
|
|
174
|
+
return [{
|
|
175
|
+
label: `${unjustified} accepted vulnerability(ies) without a justification — add "justification"/"detail" to the allowlist entry for the VEX`,
|
|
176
|
+
passed: true,
|
|
177
|
+
warning: true,
|
|
178
|
+
}];
|
|
179
|
+
}
|
|
180
|
+
|
|
116
181
|
function getProject(projectRoot, sections) {
|
|
117
182
|
// Derive project identity from any section that parsed the lockfile/root.
|
|
118
183
|
if (sections.sbom && sections.sbom.ok && sections.sbom.document) {
|
package/src/core/kev.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { fetchJson } = require('../utils/http');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* CISA Known Exploited Vulnerabilities catalogue: the most complete public
|
|
7
|
+
* list of vulnerabilities with evidence of active exploitation. Under CRA
|
|
8
|
+
* Art. 14 (applicable since 11 September 2026) manufacturers must report
|
|
9
|
+
* actively exploited vulnerabilities in their products within 24 hours.
|
|
10
|
+
*
|
|
11
|
+
* The GitHub mirror maintained by CISA is used when the primary feed fails.
|
|
12
|
+
*/
|
|
13
|
+
const KEV_SOURCES = [
|
|
14
|
+
'https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json',
|
|
15
|
+
'https://raw.githubusercontent.com/cisagov/kev-data/develop/known_exploited_vulnerabilities.json',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @returns {Promise<{ ok: true, catalogVersion: string|null, count: number, byCve: Map<string, object> }
|
|
20
|
+
* | { ok: false, error: string }>}
|
|
21
|
+
*/
|
|
22
|
+
async function loadKev({ timeout = 20000 } = {}) {
|
|
23
|
+
for (const url of KEV_SOURCES) {
|
|
24
|
+
const res = await fetchJson(url, { timeout });
|
|
25
|
+
const list = res && res.ok && res.json && res.json.vulnerabilities;
|
|
26
|
+
if (!Array.isArray(list)) continue;
|
|
27
|
+
|
|
28
|
+
const byCve = new Map();
|
|
29
|
+
for (const entry of list) {
|
|
30
|
+
if (!entry || !entry.cveID) continue;
|
|
31
|
+
byCve.set(entry.cveID.toUpperCase(), {
|
|
32
|
+
cve: entry.cveID,
|
|
33
|
+
name: entry.vulnerabilityName || null,
|
|
34
|
+
dateAdded: entry.dateAdded || null,
|
|
35
|
+
dueDate: entry.dueDate || null,
|
|
36
|
+
knownRansomwareCampaignUse: entry.knownRansomwareCampaignUse || null,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return { ok: true, catalogVersion: res.json.catalogVersion || null, count: byCve.size, byCve };
|
|
40
|
+
}
|
|
41
|
+
return { ok: false, error: 'CISA KEV catalogue unreachable' };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { loadKev };
|
package/src/core/osv.js
ADDED
|
@@ -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
|
+
};
|
package/src/core/policy.js
CHANGED
|
@@ -18,6 +18,11 @@ const DEFAULT_POLICY = {
|
|
|
18
18
|
// Email or URL of the SBOM creator (TR-03183-2 §5.2.1). Defaults to the
|
|
19
19
|
// project's package.json author, homepage or repository.
|
|
20
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,
|
|
21
26
|
// Audit only production dependencies (recommended for shipped products).
|
|
22
27
|
productionOnly: false,
|
|
23
28
|
vulnerabilities: {
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { readJson } = require('../utils/fs');
|
|
6
|
+
const { parseLockfile } = require('./lockfile-parser');
|
|
7
|
+
const { repositoryUrl } = require('./installed-metadata');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Organisational CRA duties that can be checked from the repository itself:
|
|
11
|
+
* the vulnerability disclosure policy, a contact for vulnerability reports,
|
|
12
|
+
* the support period, a security.txt and the ability to produce an SBOM.
|
|
13
|
+
*
|
|
14
|
+
* `required` checks fail the command; `recommended` ones only warn.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const SECURITY_MD_PATHS = ['SECURITY.md', '.github/SECURITY.md', 'docs/SECURITY.md'];
|
|
18
|
+
const SECURITY_TXT_PATHS = [
|
|
19
|
+
'.well-known/security.txt',
|
|
20
|
+
'public/.well-known/security.txt',
|
|
21
|
+
'static/.well-known/security.txt',
|
|
22
|
+
'src/.well-known/security.txt',
|
|
23
|
+
'security.txt',
|
|
24
|
+
];
|
|
25
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
const EMAIL = /[\w.+-]+@[\w-]+\.[\w.-]+/;
|
|
28
|
+
const URL = /https?:\/\/\S+/;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {string} projectRoot
|
|
32
|
+
* @param {{ now?: Date }} [options]
|
|
33
|
+
* @returns {{ checks: Array<object>, passed: boolean, files: object }}
|
|
34
|
+
*/
|
|
35
|
+
function checkReadiness(projectRoot, { now = new Date() } = {}) {
|
|
36
|
+
const checks = [];
|
|
37
|
+
const add = (id, label, level, passed, detail, reference) => checks.push({ id, label, level, passed: Boolean(passed), detail, reference });
|
|
38
|
+
|
|
39
|
+
const securityMdPath = firstExisting(projectRoot, SECURITY_MD_PATHS);
|
|
40
|
+
const securityMd = securityMdPath ? read(path.join(projectRoot, securityMdPath)) : '';
|
|
41
|
+
const readme = read(path.join(projectRoot, 'README.md'));
|
|
42
|
+
const pkg = readJson(path.join(projectRoot, 'package.json')) || {};
|
|
43
|
+
|
|
44
|
+
// --- Coordinated vulnerability disclosure policy --------------------------
|
|
45
|
+
add('security-policy', 'Vulnerability disclosure policy (SECURITY.md)', 'required', securityMdPath,
|
|
46
|
+
securityMdPath ? `Found ${securityMdPath}` : `None of ${SECURITY_MD_PATHS.join(', ')} exists`,
|
|
47
|
+
'CRA Annex I Part II (5)');
|
|
48
|
+
|
|
49
|
+
const reportingChannel = EMAIL.test(securityMd) || /security\/advisories|report[^\n]*vulnerabilit[^\n]*https?:\/\//i.test(securityMd) || URL.test(securityMd);
|
|
50
|
+
add('security-contact', 'Contact address for vulnerability reports', 'required', securityMdPath && reportingChannel,
|
|
51
|
+
!securityMdPath ? 'No SECURITY.md' : reportingChannel ? 'Email address or reporting URL found' : 'SECURITY.md has no email address or reporting URL',
|
|
52
|
+
'CRA Annex I Part II (6) · Annex II (2)');
|
|
53
|
+
|
|
54
|
+
// --- Support period ---------------------------------------------------------
|
|
55
|
+
const supportText = `${securityMd}\n${readme}`;
|
|
56
|
+
const supportMention = /support(ed)?\s+period|supported\s+until|end\s+of\s+(security\s+)?support|end[-\s]of[-\s]life|\bEOL\b/i.test(supportText);
|
|
57
|
+
const supportDate = supportMention && /\b(19|20)\d{2}-\d{2}(-\d{2})?\b|\b\d+\s+years?\b/i.test(supportText);
|
|
58
|
+
add('support-period', 'Support period and its end date stated', 'required', supportMention && supportDate,
|
|
59
|
+
!supportMention ? 'No mention of the support period in SECURITY.md or README.md'
|
|
60
|
+
: supportDate ? 'Support period with an end date or duration found' : 'Support period mentioned without an end date',
|
|
61
|
+
'CRA Art. 13(8) · Annex II (7)');
|
|
62
|
+
|
|
63
|
+
// --- Recommended content ------------------------------------------------------
|
|
64
|
+
add('supported-versions', 'Supported versions listed', 'recommended',
|
|
65
|
+
/supported\s+versions/i.test(securityMd), 'A "Supported Versions" section in SECURITY.md', 'CRA Annex II (7)');
|
|
66
|
+
add('response-timeline', 'Response timeline for reporters', 'recommended',
|
|
67
|
+
/\b\d+\s*(business\s+)?(hours?|days?|weeks?)\b/i.test(securityMd), 'Acknowledgement / fix time frames in SECURITY.md',
|
|
68
|
+
'CRA Annex I Part II (5)');
|
|
69
|
+
add('art14-process', 'Art. 14 reporting process (CSIRT / ENISA, 24 h / 72 h)', 'recommended',
|
|
70
|
+
/ENISA|CSIRT|single\s+reporting\s+platform|24\s*h(ours)?/i.test(securityMd),
|
|
71
|
+
'How actively exploited vulnerabilities and severe incidents are reported', 'CRA Art. 14');
|
|
72
|
+
|
|
73
|
+
// --- security.txt (RFC 9116) --------------------------------------------------
|
|
74
|
+
const securityTxtPath = firstExisting(projectRoot, SECURITY_TXT_PATHS);
|
|
75
|
+
const txt = parseSecurityTxt(securityTxtPath ? read(path.join(projectRoot, securityTxtPath)) : '');
|
|
76
|
+
if (!securityTxtPath) {
|
|
77
|
+
add('security-txt', 'security.txt published (RFC 9116)', 'recommended', false,
|
|
78
|
+
'No .well-known/security.txt found (recommended for products with a web presence)', 'RFC 9116 · CRA Annex II (2)');
|
|
79
|
+
} else {
|
|
80
|
+
const expires = txt.expires ? new Date(txt.expires) : null;
|
|
81
|
+
const validExpiry = expires && !Number.isNaN(expires.getTime());
|
|
82
|
+
add('security-txt', 'security.txt published (RFC 9116)', 'recommended', true, `Found ${securityTxtPath}`, 'RFC 9116');
|
|
83
|
+
add('security-txt-contact', 'security.txt has a Contact field', 'required', txt.contact.length > 0,
|
|
84
|
+
txt.contact.length ? txt.contact.join(', ') : 'Missing Contact:', 'RFC 9116 §2.5.3');
|
|
85
|
+
add('security-txt-expires', 'security.txt Expires is set and in the future', 'required', validExpiry && expires > now,
|
|
86
|
+
!txt.expires ? 'Missing Expires:' : !validExpiry ? `Invalid date: ${txt.expires}` : expires > now ? `Expires ${txt.expires}` : `Expired on ${txt.expires}`,
|
|
87
|
+
'RFC 9116 §2.5.5');
|
|
88
|
+
if (validExpiry && expires > now) {
|
|
89
|
+
add('security-txt-expiry-window', 'security.txt Expires is less than a year away', 'recommended',
|
|
90
|
+
expires - now <= 366 * DAY, `Expires in ${Math.round((expires - now) / DAY)} days`, 'RFC 9116 §2.5.5');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// --- Unfilled templates (`readiness --init`) ------------------------------------
|
|
95
|
+
const securityTxtText = securityTxtPath ? read(path.join(projectRoot, securityTxtPath)) : '';
|
|
96
|
+
const todo = [[securityMdPath, securityMd], [securityTxtPath, securityTxtText]]
|
|
97
|
+
.filter(([file, text]) => file && /\bTODO\b/.test(text))
|
|
98
|
+
.map(([file, text]) => `${file} (${text.match(/\bTODO\b/g).length})`);
|
|
99
|
+
if (securityMdPath || securityTxtPath) {
|
|
100
|
+
add('placeholders', 'No unfilled TODO placeholders', 'required', todo.length === 0,
|
|
101
|
+
todo.length ? `TODO placeholders left in ${todo.join(', ')}` : 'No placeholders', 'CRA Annex II');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- SBOM ---------------------------------------------------------------------
|
|
105
|
+
const parsed = parseLockfile(projectRoot);
|
|
106
|
+
add('sbom', 'A lockfile to generate the SBOM from', 'required', parsed.ok,
|
|
107
|
+
parsed.ok ? `${parsed.lockfileName} (${parsed.components.length} components)` : parsed.error, 'CRA Annex I Part II (1)');
|
|
108
|
+
|
|
109
|
+
add('package-repository', 'package.json links the source repository', 'recommended', Boolean(repositoryUrl(pkg.repository)),
|
|
110
|
+
repositoryUrl(pkg.repository) || 'No "repository" field', 'TR-03183-2 §5.2.4');
|
|
111
|
+
|
|
112
|
+
const failed = checks.filter((c) => c.level === 'required' && !c.passed);
|
|
113
|
+
return { checks, passed: failed.length === 0, files: { securityMd: securityMdPath, securityTxt: securityTxtPath } };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Parses the fields of a security.txt that the checks need. */
|
|
117
|
+
function parseSecurityTxt(text) {
|
|
118
|
+
const out = { contact: [], expires: null, policy: [] };
|
|
119
|
+
for (const raw of String(text).split(/\r?\n/)) {
|
|
120
|
+
const m = raw.match(/^\s*([A-Za-z-]+)\s*:\s*(.+?)\s*$/);
|
|
121
|
+
if (!m) continue;
|
|
122
|
+
const field = m[1].toLowerCase();
|
|
123
|
+
if (field === 'contact') out.contact.push(m[2]);
|
|
124
|
+
else if (field === 'expires') out.expires = m[2];
|
|
125
|
+
else if (field === 'policy') out.policy.push(m[2]);
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --- Templates (`readiness --init`) --------------------------------------------
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Writes SECURITY.md and .well-known/security.txt templates prefilled from
|
|
134
|
+
* package.json. Existing files are never overwritten.
|
|
135
|
+
*
|
|
136
|
+
* @returns {Array<{ file: string, created: boolean }>}
|
|
137
|
+
*/
|
|
138
|
+
function writeTemplates(projectRoot, { now = new Date() } = {}) {
|
|
139
|
+
const pkg = readJson(path.join(projectRoot, 'package.json')) || {};
|
|
140
|
+
const repo = repositoryUrl(pkg.repository);
|
|
141
|
+
const advisories = repo && /github\.com/.test(repo) ? `${repo}/security/advisories/new` : null;
|
|
142
|
+
const name = pkg.name || path.basename(projectRoot);
|
|
143
|
+
const major = String(pkg.version || '1.0.0').split('.')[0];
|
|
144
|
+
const results = [];
|
|
145
|
+
|
|
146
|
+
const existingMd = firstExisting(projectRoot, SECURITY_MD_PATHS);
|
|
147
|
+
if (existingMd) {
|
|
148
|
+
results.push({ file: existingMd, created: false });
|
|
149
|
+
} else {
|
|
150
|
+
fs.writeFileSync(path.join(projectRoot, 'SECURITY.md'), securityMdTemplate({ name, major, advisories }));
|
|
151
|
+
results.push({ file: 'SECURITY.md', created: true });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const existingTxt = firstExisting(projectRoot, SECURITY_TXT_PATHS);
|
|
155
|
+
if (existingTxt) {
|
|
156
|
+
results.push({ file: existingTxt, created: false });
|
|
157
|
+
} else {
|
|
158
|
+
const base = fs.existsSync(path.join(projectRoot, 'public')) ? 'public/.well-known' : '.well-known';
|
|
159
|
+
fs.mkdirSync(path.join(projectRoot, base), { recursive: true });
|
|
160
|
+
const expires = new Date(now.getTime() + 364 * DAY).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
161
|
+
fs.writeFileSync(path.join(projectRoot, base, 'security.txt'), securityTxtTemplate({ advisories, repo, expires }));
|
|
162
|
+
results.push({ file: `${base}/security.txt`, created: true });
|
|
163
|
+
}
|
|
164
|
+
return results;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function securityMdTemplate({ name, major, advisories }) {
|
|
168
|
+
const channel = advisories
|
|
169
|
+
? `Report it privately through [GitHub Security Advisories](${advisories}), or by email to TODO: security@example.com.`
|
|
170
|
+
: 'Report it privately by email to TODO: security@example.com.';
|
|
171
|
+
return `# Security Policy
|
|
172
|
+
|
|
173
|
+
## Supported Versions
|
|
174
|
+
|
|
175
|
+
| Version | Supported | End of security support |
|
|
176
|
+
| ------- | --------- | ----------------------- |
|
|
177
|
+
| ${major}.x | ✅ | TODO: YYYY-MM-DD |
|
|
178
|
+
| < ${major}.0 | ❌ | — |
|
|
179
|
+
|
|
180
|
+
**Support period:** security updates for ${name} ${major}.x are provided until TODO: YYYY-MM-DD
|
|
181
|
+
(the EU Cyber Resilience Act expects at least 5 years, or the expected time of use of the product).
|
|
182
|
+
|
|
183
|
+
## Reporting a Vulnerability
|
|
184
|
+
|
|
185
|
+
Please do **not** open a public issue for security problems.
|
|
186
|
+
|
|
187
|
+
${channel}
|
|
188
|
+
|
|
189
|
+
Include the affected version, a description of the issue and, if possible, steps to reproduce it.
|
|
190
|
+
|
|
191
|
+
## Our Process (Coordinated Vulnerability Disclosure)
|
|
192
|
+
|
|
193
|
+
- We acknowledge reports within **3 business days** and send a first assessment within **10 business days**.
|
|
194
|
+
- We agree a disclosure date with the reporter, normally within **90 days**, and credit reporters who wish to be named.
|
|
195
|
+
- Fixes are released as security updates, separate from feature updates where possible, and announced in the release notes and a GitHub Security Advisory.
|
|
196
|
+
|
|
197
|
+
## EU Cyber Resilience Act — Reporting (Art. 14)
|
|
198
|
+
|
|
199
|
+
When we become aware of an actively exploited vulnerability in ${name}, or a severe incident affecting its security,
|
|
200
|
+
we notify the coordinating CSIRT and ENISA through the Single Reporting Platform:
|
|
201
|
+
|
|
202
|
+
- an **early warning within 24 hours**,
|
|
203
|
+
- a **notification within 72 hours**,
|
|
204
|
+
- a **final report within 14 days** after a corrective measure is available (one month for severe incidents).
|
|
205
|
+
|
|
206
|
+
Affected users are informed of the issue and of the corrective measures to take.
|
|
207
|
+
`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function securityTxtTemplate({ advisories, repo, expires }) {
|
|
211
|
+
const lines = ['# RFC 9116 — https://securitytxt.org', 'Contact: mailto:TODO-security@example.com'];
|
|
212
|
+
if (advisories) lines.push(`Contact: ${advisories}`);
|
|
213
|
+
lines.push(`Expires: ${expires}`);
|
|
214
|
+
if (repo) lines.push(`Policy: ${repo}/blob/HEAD/SECURITY.md`);
|
|
215
|
+
lines.push('Preferred-Languages: en', '');
|
|
216
|
+
return lines.join('\n');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function firstExisting(root, candidates) {
|
|
220
|
+
return candidates.find((rel) => fs.existsSync(path.join(root, rel))) || null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function read(file) {
|
|
224
|
+
try {
|
|
225
|
+
return fs.readFileSync(file, 'utf8');
|
|
226
|
+
} catch {
|
|
227
|
+
return '';
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
module.exports = { checkReadiness, writeTemplates, parseSecurityTxt };
|