cra-audit 2.2.0 → 2.4.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 +23 -0
- package/README.md +41 -3
- package/package.json +3 -2
- package/src/cli.js +4 -0
- package/src/commands/audit.js +11 -6
- package/src/commands/readiness.js +14 -2
- package/src/commands/sbom.js +122 -111
- package/src/commands/vex.js +7 -11
- package/src/core/auditor.js +36 -9
- package/src/core/license-checker.js +1 -0
- package/src/core/manifest-reader.js +374 -0
- package/src/core/policy.js +3 -0
- package/src/core/project-source.js +62 -0
- package/src/core/readiness.js +101 -16
- package/src/core/reporting.js +79 -0
- package/src/core/sbom-reader.js +1 -0
- package/src/core/vuln-scanner.js +11 -7
- package/src/reporters/console.js +26 -8
- package/src/reporters/sarif.js +17 -6
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where and how a manufacturer reports under CRA Art. 14. Notifications go
|
|
5
|
+
* through ENISA's Single Reporting Platform (SRP) to the CSIRT designated as
|
|
6
|
+
* coordinator of the Member State of the manufacturer's main establishment.
|
|
7
|
+
*
|
|
8
|
+
* Country-specific procedures are added only when they have been verified
|
|
9
|
+
* against the CSIRT's own publications (see `source`).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const SRP = {
|
|
13
|
+
url: 'https://portal.cra-srp.enisa.europa.eu',
|
|
14
|
+
faq: 'https://www.enisa.europa.eu/topics/product-security/single-reporting-platform-srp/frequently-asked-questions',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const DEADLINES = {
|
|
18
|
+
vulnerability: [
|
|
19
|
+
{ step: 'early-warning', deadline: '24 hours after becoming aware' },
|
|
20
|
+
{ step: 'notification', deadline: '72 hours after becoming aware' },
|
|
21
|
+
{ step: 'final-report', deadline: '14 days after a corrective or mitigating measure is available' },
|
|
22
|
+
],
|
|
23
|
+
incident: [
|
|
24
|
+
{ step: 'early-warning', deadline: '24 hours after becoming aware' },
|
|
25
|
+
{ step: 'notification', deadline: '72 hours after becoming aware' },
|
|
26
|
+
{ step: 'final-report', deadline: '1 month after the 72-hour notification' },
|
|
27
|
+
],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const CSIRTS = {
|
|
31
|
+
ES: {
|
|
32
|
+
country: 'ES',
|
|
33
|
+
csirt: 'INCIBE-CERT',
|
|
34
|
+
website: 'https://www.incibe.es/incibe-cert',
|
|
35
|
+
srpAccess: {
|
|
36
|
+
contact: 'cve-coordination@incibe.es',
|
|
37
|
+
steps: [
|
|
38
|
+
'Email cve-coordination@incibe.es asking for a user on the ENISA Single Reporting Platform (SRP).',
|
|
39
|
+
'INCIBE replies with the information and documents it needs, and validates that you are a manufacturer bound by the CRA.',
|
|
40
|
+
'Once validated, INCIBE sends the instructions to complete the SRP registration and start notifying.',
|
|
41
|
+
],
|
|
42
|
+
advice: 'Request SRP access before you need it: the validation happens before you can notify, and the 24-hour clock does not wait.',
|
|
43
|
+
},
|
|
44
|
+
otherChannels: [
|
|
45
|
+
{ purpose: 'Other cybersecurity incidents (outside CRA Art. 14)', contact: 'incidencias@incibe-cert.es' },
|
|
46
|
+
{ purpose: 'Undisclosed (0-day) vulnerabilities needing coordination and a CVE ID (INCIBE CNA)', contact: 'cve-coordination@incibe.es' },
|
|
47
|
+
],
|
|
48
|
+
whenInDoubt: 'If you are not sure a case is in CRA scope, report it through INCIBE-CERT\'s usual channels; INCIBE tells you whether to file it formally in the SRP.',
|
|
49
|
+
source: 'https://www.incibe.es/incibe-cert/blog/reglamento-de-ciberresiliencia-cra-que-es-quien-afecta-y-como-prepararse',
|
|
50
|
+
verified: '2026-09-09',
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Reporting guidance for a country (ISO 3166-1 alpha-2), or the generic
|
|
56
|
+
* EU procedure when the country is unknown or not yet covered.
|
|
57
|
+
*
|
|
58
|
+
* @param {string|null|undefined} country
|
|
59
|
+
*/
|
|
60
|
+
function reportingGuide(country) {
|
|
61
|
+
const code = typeof country === 'string' ? country.trim().toUpperCase() : null;
|
|
62
|
+
const local = code ? CSIRTS[code] || null : null;
|
|
63
|
+
return {
|
|
64
|
+
country: code,
|
|
65
|
+
platform: 'ENISA Single Reporting Platform (SRP)',
|
|
66
|
+
srp: SRP,
|
|
67
|
+
recipients: local
|
|
68
|
+
? `${local.csirt} (CSIRT designated as coordinator) and ENISA`
|
|
69
|
+
: 'the CSIRT designated as coordinator of your main establishment\'s Member State, and ENISA',
|
|
70
|
+
deadlines: DEADLINES,
|
|
71
|
+
local,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function supportedCountries() {
|
|
76
|
+
return Object.keys(CSIRTS);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { reportingGuide, supportedCountries };
|
package/src/core/sbom-reader.js
CHANGED
package/src/core/vuln-scanner.js
CHANGED
|
@@ -25,19 +25,19 @@ const SEVERITY_ORDER = ['info', 'low', 'moderate', 'high', 'critical'];
|
|
|
25
25
|
* CVEs found are cross-checked with the CISA KEV catalogue.
|
|
26
26
|
* - `npm`: `npm audit`. Also the fallback when OSV.dev is unreachable.
|
|
27
27
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* in OSV.dev by Package URL.
|
|
28
|
+
* Other ecosystems are scanned by passing, as `parsed`, the result of
|
|
29
|
+
* readSbom() (an input SBOM) or readManifests() (requirements.txt, go.mod,
|
|
30
|
+
* pom.xml…); their components are looked up in OSV.dev by Package URL.
|
|
31
31
|
*
|
|
32
32
|
* @param {string} projectRoot
|
|
33
33
|
* @param {{ production?: boolean, source?: 'osv'|'npm', kev?: boolean, parsed?: object }} [options]
|
|
34
34
|
* @returns {Promise<object>}
|
|
35
35
|
*/
|
|
36
36
|
async function scanVulnerabilities(projectRoot, { production = false, source = 'osv', kev = true, parsed: given } = {}) {
|
|
37
|
-
const fromSbom = Boolean(given && given.
|
|
37
|
+
const fromSbom = Boolean(given && given.purlBased);
|
|
38
38
|
if (source === 'npm') {
|
|
39
39
|
if (fromSbom) {
|
|
40
|
-
return { ok: false, error: '`npm audit`
|
|
40
|
+
return { ok: false, error: '`npm audit` only scans npm projects; use the default OSV source.', counts: emptyCounts(), vulnerabilities: [] };
|
|
41
41
|
}
|
|
42
42
|
return npmAuditSection(projectRoot, { production });
|
|
43
43
|
}
|
|
@@ -88,7 +88,9 @@ async function scanVulnerabilities(projectRoot, { production = false, source = '
|
|
|
88
88
|
ok: true,
|
|
89
89
|
source: 'osv',
|
|
90
90
|
scanned: scannable.length,
|
|
91
|
-
input: fromSbom
|
|
91
|
+
input: fromSbom
|
|
92
|
+
? { file: parsed.sourceFile || parsed.lockfileName, format: parsed.format, unidentified: parsed.unidentified, notes: parsed.notes || [] }
|
|
93
|
+
: null,
|
|
92
94
|
ecosystems: [...new Set(scannable.map((c) => c.ecosystem || 'npm'))].sort(),
|
|
93
95
|
kev: catalogue
|
|
94
96
|
? (catalogue.ok
|
|
@@ -135,6 +137,7 @@ function toFinding(component, records, isDirect, catalogue) {
|
|
|
135
137
|
version: component.version,
|
|
136
138
|
purl: component.purl || buildPurl(component.name, component.version),
|
|
137
139
|
ecosystem: component.ecosystem || 'npm',
|
|
140
|
+
location: component.location || null,
|
|
138
141
|
severity,
|
|
139
142
|
direct: isDirect,
|
|
140
143
|
range: null,
|
|
@@ -169,7 +172,8 @@ function productionComponents(parsed, rootPkg) {
|
|
|
169
172
|
if (!seen.has(dep)) { seen.add(dep); queue.push(dep); }
|
|
170
173
|
}
|
|
171
174
|
}
|
|
172
|
-
|
|
175
|
+
// Components of other ecosystems (npm+manifest projects) use their own scope.
|
|
176
|
+
return parsed.components.filter((c) => (c.ecosystem ? !c.optional : seen.has(componentKey(c.name, c.version))));
|
|
173
177
|
}
|
|
174
178
|
|
|
175
179
|
function severityRank(severity) {
|
package/src/reporters/console.js
CHANGED
|
@@ -23,14 +23,14 @@ function reportConsole(report) {
|
|
|
23
23
|
logger.detail(`Policy: ${report.policySource}`);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
if (report.sections.vulnerabilities) renderVulnerabilities(report.sections.vulnerabilities);
|
|
26
|
+
if (report.sections.vulnerabilities) renderVulnerabilities(report.sections.vulnerabilities, report.reporting);
|
|
27
27
|
if (report.sections.sbom) renderSbom(report.sections.sbom);
|
|
28
28
|
if (report.sections.licenses) renderLicenses(report.sections.licenses);
|
|
29
29
|
|
|
30
30
|
renderSummary(report);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
function renderVulnerabilities(section) {
|
|
33
|
+
function renderVulnerabilities(section, reporting) {
|
|
34
34
|
logger.heading('1) Known vulnerabilities (CRA Annex I · Art. 14)');
|
|
35
35
|
if (!section.ok) {
|
|
36
36
|
logger.error(section.error);
|
|
@@ -38,6 +38,7 @@ function renderVulnerabilities(section) {
|
|
|
38
38
|
}
|
|
39
39
|
logger.detail(describeSources(section));
|
|
40
40
|
for (const warning of section.warnings || []) logger.warn(warning);
|
|
41
|
+
for (const note of (section.input && section.input.notes) || []) logger.detail(note);
|
|
41
42
|
|
|
42
43
|
const c = section.counts;
|
|
43
44
|
if (c.total === 0) {
|
|
@@ -74,7 +75,7 @@ function renderVulnerabilities(section) {
|
|
|
74
75
|
logger.detail(`… and ${ordered.length - top.length} more.`);
|
|
75
76
|
}
|
|
76
77
|
|
|
77
|
-
if (c.kev) renderArticle14Notice();
|
|
78
|
+
if (c.kev) renderArticle14Notice(reporting);
|
|
78
79
|
if (c.malicious) {
|
|
79
80
|
logger.log('');
|
|
80
81
|
logger.error(color.bold('Malicious package(s) detected: treat this as a security incident.'));
|
|
@@ -89,22 +90,33 @@ function describeSources(section) {
|
|
|
89
90
|
? `CISA KEV ${section.kev.catalogVersion || ''} (${section.kev.entries} entries)`.replace(' ', ' ')
|
|
90
91
|
: `CISA KEV not checked (${(section.kev && section.kev.error) || 'unavailable'})`;
|
|
91
92
|
const scope = section.input
|
|
92
|
-
? `${section.scanned} components from ${shortPath(section.input.file)} (${(section.ecosystems || []).join(', ')})` +
|
|
93
|
-
(section.input.unidentified ? ` · ${section.input.unidentified} without purl skipped` : '')
|
|
94
|
-
: `${section.scanned} components`;
|
|
93
|
+
? `${section.scanned} components from ${section.input.format === 'manifest' ? section.input.file : shortPath(section.input.file)} (${(section.ecosystems || []).join(', ')})` +
|
|
94
|
+
(section.input.unidentified ? ` · ${section.input.unidentified} ${section.input.format === 'manifest' ? 'not pinned, skipped' : 'without purl skipped'}` : '')
|
|
95
|
+
: `${section.scanned} components${(section.ecosystems || []).length > 1 ? ` (${section.ecosystems.join(', ')})` : ''}`;
|
|
95
96
|
return `Sources: OSV.dev (GitHub advisories + OpenSSF malicious packages) · ${kev} · ${scope}`;
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
/** CRA Art. 14 reporting clock, shown when a KEV-listed vulnerability is found. */
|
|
99
|
-
function renderArticle14Notice() {
|
|
100
|
+
function renderArticle14Notice(reporting) {
|
|
101
|
+
const local = reporting && reporting.local;
|
|
100
102
|
logger.log('');
|
|
101
103
|
logger.warn(color.bold('CRA Art. 14 — actively exploited vulnerability in a dependency'));
|
|
102
104
|
logger.detail('If it affects a product with digital elements you place on the EU market, notify');
|
|
103
|
-
logger.detail('the coordinating CSIRT and ENISA through the Single Reporting Platform
|
|
105
|
+
logger.detail(`${local ? `${local.csirt} and ENISA` : 'the coordinating CSIRT and ENISA'} through the Single Reporting Platform (SRP):`);
|
|
104
106
|
logger.detail(' • Early warning ........ within 24 hours of becoming aware');
|
|
105
107
|
logger.detail(' • Notification ......... within 72 hours');
|
|
106
108
|
logger.detail(' • Final report ......... within 14 days after a corrective measure is available');
|
|
107
109
|
logger.detail('Assess exploitability in your product first; document the decision either way.');
|
|
110
|
+
if (local) {
|
|
111
|
+
logger.log('');
|
|
112
|
+
logger.detail(color.bold(`${local.csirt} (${local.country}) — access to the SRP:`));
|
|
113
|
+
local.srpAccess.steps.forEach((step, i) => logger.detail(` ${i + 1}. ${step}`));
|
|
114
|
+
logger.detail(` ${local.srpAccess.advice}`);
|
|
115
|
+
logger.detail(` ${local.whenInDoubt}`);
|
|
116
|
+
logger.detail(` Source: ${local.source}`);
|
|
117
|
+
} else {
|
|
118
|
+
logger.detail(`SRP: ${reporting ? reporting.srp.url : 'https://portal.cra-srp.enisa.europa.eu'} · Set your country (--country, e.g. ES) for its CSIRT's steps.`);
|
|
119
|
+
}
|
|
108
120
|
}
|
|
109
121
|
|
|
110
122
|
function describeFix(fix) {
|
|
@@ -121,6 +133,12 @@ function renderSbom(section) {
|
|
|
121
133
|
logger.error(section.error);
|
|
122
134
|
return;
|
|
123
135
|
}
|
|
136
|
+
if (section.manifest) {
|
|
137
|
+
logger.detail(`Components read from ${section.files.join(', ')} (${section.ecosystems.join(', ')}): ${section.componentCount}`);
|
|
138
|
+
logger.warn('cra-audit builds TR-03183 SBOMs from npm lockfiles. For these ecosystems, commit the SBOM your');
|
|
139
|
+
logger.detail('build tooling produces (Syft, cdxgen, cyclonedx-maven-plugin, cyclonedx-py…) and audit it with -i.');
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
124
142
|
logger.detail(`${section.input ? `Input SBOM: ${shortPath(section.input)} · ` : ''}Format: ${section.format} · Components: ${section.componentCount}`);
|
|
125
143
|
|
|
126
144
|
if (section.validation) {
|
package/src/reporters/sarif.js
CHANGED
|
@@ -30,7 +30,8 @@ function buildSarif(report, projectRoot, policy = {}) {
|
|
|
30
30
|
const rules = new Map();
|
|
31
31
|
const results = [];
|
|
32
32
|
const vulns = report.sections.vulnerabilities;
|
|
33
|
-
|
|
33
|
+
// An input SBOM is searched for purls; manifest findings carry their own location.
|
|
34
|
+
const inputFile = vulns && vulns.input && vulns.input.format !== 'manifest' ? vulns.input.file : null;
|
|
34
35
|
const lock = lockfileLocator(projectRoot, inputFile);
|
|
35
36
|
const assessments = normalizeAllowlist(policy);
|
|
36
37
|
|
|
@@ -47,7 +48,7 @@ function buildSarif(report, projectRoot, policy = {}) {
|
|
|
47
48
|
: finding.malicious || source.kev || ['critical', 'high'].includes(source.severity || finding.severity) ? 'error'
|
|
48
49
|
: (source.severity || finding.severity) === 'moderate' ? 'warning' : 'note',
|
|
49
50
|
message: { text: vulnerabilityMessage(finding, source) },
|
|
50
|
-
locations: [lock.locate(finding.name, finding.version, finding.purl)],
|
|
51
|
+
locations: [finding.location ? lock.at(finding.location) : lock.locate(finding.name, finding.version, finding.purl)],
|
|
51
52
|
partialFingerprints: { 'craAudit/v1': `${ruleId}:${finding.name}@${finding.version || ''}` },
|
|
52
53
|
};
|
|
53
54
|
if (accepted) {
|
|
@@ -71,14 +72,17 @@ function buildSarif(report, projectRoot, policy = {}) {
|
|
|
71
72
|
['cra-audit/license-missing', s.missing, 'warning', (c) => `${c.name}@${c.version} has no documented license (TR-03183-2 §5.2.2).`],
|
|
72
73
|
];
|
|
73
74
|
for (const [ruleId, list, level, text] of groups) {
|
|
74
|
-
|
|
75
|
+
// Manifests (requirements.txt, go.mod…) never declare licenses: that gap is
|
|
76
|
+
// reported once by the audit, not as one alert per dependency.
|
|
77
|
+
const relevant = ruleId === 'cra-audit/license-missing' ? list.filter((c) => !c.location) : list;
|
|
78
|
+
if (!relevant.length) continue;
|
|
75
79
|
rules.set(ruleId, licenseRule(ruleId));
|
|
76
|
-
for (const c of
|
|
80
|
+
for (const c of relevant) {
|
|
77
81
|
results.push({
|
|
78
82
|
ruleId,
|
|
79
83
|
level,
|
|
80
84
|
message: { text: text(c) },
|
|
81
|
-
locations: [lock.locate(c.name, c.version, c.purl)],
|
|
85
|
+
locations: [c.location ? lock.at(c.location) : lock.locate(c.name, c.version, c.purl)],
|
|
82
86
|
partialFingerprints: { 'craAudit/v1': `${ruleId}:${c.name}@${c.version}` },
|
|
83
87
|
});
|
|
84
88
|
}
|
|
@@ -193,7 +197,14 @@ function lockfileLocator(projectRoot, inputFile = null) {
|
|
|
193
197
|
},
|
|
194
198
|
};
|
|
195
199
|
};
|
|
196
|
-
|
|
200
|
+
// A known file + line (components read from manifests).
|
|
201
|
+
const at = ({ file: rel, line }) => ({
|
|
202
|
+
physicalLocation: {
|
|
203
|
+
artifactLocation: { uri: path.relative(repoRoot, path.resolve(projectRoot, rel)).split(path.sep).join('/'), uriBaseId: '%SRCROOT%' },
|
|
204
|
+
region: { startLine: line || 1 },
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
return { repoRoot, locate, at };
|
|
197
208
|
}
|
|
198
209
|
|
|
199
210
|
function findEntryLine(lines, name, version) {
|