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.
@@ -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 };
@@ -45,6 +45,7 @@ function readSbom(file) {
45
45
  return {
46
46
  ok: true,
47
47
  manager: 'sbom',
48
+ purlBased: true,
48
49
  format: result.format,
49
50
  lockfileName: path.basename(file),
50
51
  sourceFile: file,
@@ -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
- * An existing SBOM of any ecosystem can be scanned instead of the lockfile by
29
- * passing the result of readSbom() as `parsed`; its components are looked up
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.manager === 'sbom');
37
+ const fromSbom = Boolean(given && given.purlBased);
38
38
  if (source === 'npm') {
39
39
  if (fromSbom) {
40
- return { ok: false, error: '`npm audit` cannot scan an input SBOM; use the default OSV source.', counts: emptyCounts(), vulnerabilities: [] };
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 ? { file: parsed.sourceFile, format: parsed.format, unidentified: parsed.unidentified } : null,
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
- return parsed.components.filter((c) => seen.has(componentKey(c.name, c.version)));
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) {
@@ -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) {
@@ -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
- const inputFile = vulns && vulns.input ? vulns.input.file : null;
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
- if (!list.length) continue;
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 list) {
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
- return { repoRoot, locate };
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) {