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.
@@ -32,6 +32,7 @@ function checkLicenses(projectRoot, policy = {}, { parsed: given } = {}) {
32
32
  name: c.name,
33
33
  version: c.version,
34
34
  purl: c.purl || null,
35
+ location: c.location || null,
35
36
  license: normalized,
36
37
  status, // 'ok' | 'missing' | 'denied' | 'not-allowed'
37
38
  };
@@ -0,0 +1,374 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ /**
7
+ * Reads the dependency manifests of non-npm ecosystems straight from the
8
+ * repository, without an SBOM generator:
9
+ *
10
+ * - Python: poetry.lock, uv.lock, Pipfile.lock (exact, preferred) or
11
+ * requirements*.txt (only `==` pins; `-r` includes are followed)
12
+ * - Go: go.mod (every required module, direct and `// indirect`)
13
+ * - Java: gradle.lockfile (exact), pom.xml (declared dependencies with
14
+ * `${property}` and dependencyManagement resolution)
15
+ *
16
+ * Returns the same shape as readSbom(), so the OSV/KEV scan, VEX and SARIF
17
+ * work unchanged. Every component records the file and line it came from.
18
+ * Versions that are not pinned cannot be audited reliably: they are skipped
19
+ * and reported, never guessed.
20
+ *
21
+ * @param {string} projectRoot
22
+ * @returns {null | {
23
+ * ok: true, manager: 'manifest', purlBased: true, format: 'manifest',
24
+ * lockfileName: string, sourceFile: null, files: string[],
25
+ * root: { name: string, version: string, purl: string, dependsOn: string[] },
26
+ * components: object[], unidentified: number, notes: string[],
27
+ * dependencyGraphComplete: false, ecosystems: string[]
28
+ * }} null when no supported manifest exists.
29
+ */
30
+ function readManifests(projectRoot) {
31
+ const found = [];
32
+ const notes = [];
33
+ let skipped = 0;
34
+
35
+ const collect = (result) => {
36
+ if (!result) return;
37
+ found.push(result);
38
+ skipped += result.skipped || 0;
39
+ if (result.note) notes.push(result.note);
40
+ };
41
+
42
+ // Python: an exact lockfile wins over requirements files.
43
+ const pyLock = firstExisting(projectRoot, ['poetry.lock', 'uv.lock', 'Pipfile.lock']);
44
+ if (pyLock) {
45
+ collect(pyLock === 'Pipfile.lock' ? readPipfileLock(projectRoot, pyLock) : readTomlLock(projectRoot, pyLock));
46
+ } else {
47
+ for (const file of requirementsFiles(projectRoot)) collect(readRequirements(projectRoot, file));
48
+ }
49
+
50
+ if (exists(projectRoot, 'go.mod')) collect(readGoMod(projectRoot, 'go.mod'));
51
+ if (exists(projectRoot, 'gradle.lockfile')) collect(readGradleLock(projectRoot, 'gradle.lockfile'));
52
+ if (exists(projectRoot, 'pom.xml')) collect(readPom(projectRoot, 'pom.xml'));
53
+
54
+ if (!found.length) return null;
55
+
56
+ const components = dedupe(found.flatMap((r) => r.components));
57
+ const files = found.map((r) => r.file);
58
+ const name = path.basename(path.resolve(projectRoot));
59
+ const rootName = found.map((r) => r.rootName).find(Boolean) || name;
60
+ const rootVersion = found.map((r) => r.rootVersion).find(Boolean) || '0.0.0';
61
+ return {
62
+ ok: true,
63
+ manager: 'manifest',
64
+ purlBased: true,
65
+ format: 'manifest',
66
+ lockfileName: files.join(', '),
67
+ sourceFile: null,
68
+ files,
69
+ root: {
70
+ name: rootName,
71
+ version: rootVersion,
72
+ purl: `pkg:generic/${encodeURIComponent(rootName)}@${encodeURIComponent(rootVersion)}`,
73
+ dependsOn: components.filter((c) => c.direct).map((c) => c.key),
74
+ },
75
+ components,
76
+ unidentified: skipped,
77
+ notes,
78
+ dependencyGraphComplete: false,
79
+ ecosystems: [...new Set(components.map((c) => c.ecosystem))].sort(),
80
+ };
81
+ }
82
+
83
+ /** True when the folder holds a manifest this reader understands. */
84
+ function hasManifests(projectRoot) {
85
+ return ['poetry.lock', 'uv.lock', 'Pipfile.lock', 'go.mod', 'gradle.lockfile', 'pom.xml']
86
+ .some((f) => exists(projectRoot, f)) || requirementsFiles(projectRoot).length > 0;
87
+ }
88
+
89
+ // --- Python ------------------------------------------------------------------------
90
+
91
+ function requirementsFiles(root) {
92
+ let names = [];
93
+ try {
94
+ names = fs.readdirSync(root);
95
+ } catch {
96
+ return [];
97
+ }
98
+ return names.filter((n) => /^requirements([-_.][\w.-]+)?\.txt$/i.test(n)).sort();
99
+ }
100
+
101
+ /**
102
+ * pip requirements: `name==1.2.3`, extras, environment markers, `--hash`
103
+ * continuation lines and `-r other.txt` includes. Anything that is not an
104
+ * exact pin (ranges, URLs, editable installs) is skipped and counted.
105
+ */
106
+ function readRequirements(root, file, seen = new Set()) {
107
+ const full = path.join(root, file);
108
+ if (seen.has(full)) return { file, components: [], skipped: 0 };
109
+ seen.add(full);
110
+ const components = [];
111
+ let skipped = 0;
112
+ const dev = /dev|test|lint|doc/i.test(path.basename(file));
113
+
114
+ lines(full).forEach((raw, index) => {
115
+ const line = raw.replace(/\s+#.*$/, '').replace(/\\$/, '').trim();
116
+ if (!line || line.startsWith('#') || line.startsWith('--hash')) return;
117
+ const include = line.match(/^(?:-r|--requirement)\s+(.+)$/);
118
+ if (include) {
119
+ const nested = readRequirements(root, path.join(path.dirname(file), include[1].trim()), seen);
120
+ components.push(...nested.components);
121
+ skipped += nested.skipped;
122
+ return;
123
+ }
124
+ if (line.startsWith('-')) return; // pip options: -c, -i, --index-url, -e…
125
+ const spec = line.split(';')[0].trim();
126
+ const m = spec.match(/^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?\s*===?\s*([^\s,]+)$/);
127
+ if (m) components.push(pypi(m[1], m[2], file, index + 1, { dev, direct: true }));
128
+ else skipped++;
129
+ });
130
+ const note = skipped ? `${file}: ${skipped} requirement(s) without an exact == pin were not audited` : null;
131
+ return { file, components, skipped, note };
132
+ }
133
+
134
+ /** poetry.lock / uv.lock: `[[package]]` tables with `name` and `version`. */
135
+ function readTomlLock(root, file) {
136
+ const components = [];
137
+ let current = null;
138
+ let rootName = null;
139
+ let rootVersion = null;
140
+ const flush = () => {
141
+ if (!current || !current.name || !current.version) return;
142
+ if (current.local) {
143
+ rootName = rootName || current.name;
144
+ rootVersion = rootVersion || current.version;
145
+ } else {
146
+ components.push(pypi(current.name, current.version, file, current.line, { direct: false }));
147
+ }
148
+ };
149
+ lines(path.join(root, file)).forEach((raw, index) => {
150
+ const line = raw.trim();
151
+ if (line === '[[package]]') {
152
+ flush();
153
+ current = { line: index + 1 };
154
+ return;
155
+ }
156
+ if (!current) return;
157
+ if (/^\[/.test(line)) { // a sub-table: [package.dependencies], [package.source]…
158
+ if (!line.startsWith('[package.')) { flush(); current = null; }
159
+ return;
160
+ }
161
+ const kv = line.match(/^(name|version)\s*=\s*"([^"]+)"/);
162
+ if (kv && !current[kv[1]]) current[kv[1]] = kv[2];
163
+ // uv marks the project itself with an editable/virtual source.
164
+ if (/^source\s*=\s*\{\s*(editable|virtual)\s*=/.test(line)) current.local = true;
165
+ });
166
+ flush();
167
+ return { file, components, rootName, rootVersion };
168
+ }
169
+
170
+ /** Pipfile.lock (JSON): `default` and `develop` maps of `"==version"`. */
171
+ function readPipfileLock(root, file) {
172
+ let doc;
173
+ try {
174
+ doc = JSON.parse(fs.readFileSync(path.join(root, file), 'utf8'));
175
+ } catch {
176
+ return { file, components: [], skipped: 0, note: `${file} could not be parsed` };
177
+ }
178
+ const text = lines(path.join(root, file));
179
+ const components = [];
180
+ for (const [group, dev] of [['default', false], ['develop', true]]) {
181
+ for (const [name, info] of Object.entries(doc[group] || {})) {
182
+ const version = info && typeof info.version === 'string' ? info.version.replace(/^===?/, '') : null;
183
+ if (!version) continue;
184
+ const line = text.findIndex((l) => l.includes(`"${name}": {`)) + 1 || 1;
185
+ components.push(pypi(name, version, file, line, { dev, direct: true }));
186
+ }
187
+ }
188
+ return { file, components };
189
+ }
190
+
191
+ function pypi(name, version, file, line, { dev = false, direct = false } = {}) {
192
+ // PEP 503 normalisation, as purl and OSV use it.
193
+ const normalized = name.toLowerCase().replace(/[-_.]+/g, '-');
194
+ return component({
195
+ ecosystem: 'pypi', name: normalized, osvName: normalized, version,
196
+ purl: `pkg:pypi/${normalized}@${version}`, file, line, dev, direct,
197
+ });
198
+ }
199
+
200
+ // --- Go ------------------------------------------------------------------------------
201
+
202
+ /** go.mod `require` directives (single and block form); `replace` is applied. */
203
+ function readGoMod(root, file) {
204
+ const text = lines(path.join(root, file));
205
+ const requires = [];
206
+ const replaces = new Map();
207
+ let block = null;
208
+ let moduleName = null;
209
+
210
+ text.forEach((raw, index) => {
211
+ const line = raw.replace(/\/\/(?!\s*indirect).*$/, '').trim();
212
+ const mod = line.match(/^module\s+(\S+)/);
213
+ if (mod) moduleName = mod[1];
214
+ if (/^(require|replace|exclude|retract)\s*\($/.test(line)) { block = line.split(/\s/)[0]; return; }
215
+ if (line === ')') { block = null; return; }
216
+ const single = line.match(/^(require|replace)\s+(.+)$/);
217
+ const kind = single ? single[1] : block;
218
+ const body = single ? single[2] : line;
219
+ if (kind === 'require') {
220
+ const m = body.match(/^(\S+)\s+(v\S+)(\s+\/\/\s*indirect)?/);
221
+ if (m) requires.push({ module: m[1], version: m[2], indirect: Boolean(m[3]), line: index + 1 });
222
+ } else if (kind === 'replace') {
223
+ const m = body.match(/^(\S+)(?:\s+v\S+)?\s+=>\s+(\S+)\s+(v\S+)/);
224
+ if (m) replaces.set(m[1], { module: m[2], version: m[3] });
225
+ }
226
+ });
227
+
228
+ const components = requires.map((r) => {
229
+ const target = replaces.get(r.module) || { module: r.module, version: r.version };
230
+ return component({
231
+ ecosystem: 'golang', name: target.module, osvName: target.module, version: target.version,
232
+ purl: `pkg:golang/${target.module}@${target.version}`, file, line: r.line, direct: !r.indirect,
233
+ });
234
+ });
235
+ return { file, components, rootName: moduleName ? moduleName.split('/').pop() : null };
236
+ }
237
+
238
+ // --- Java --------------------------------------------------------------------------------
239
+
240
+ /** gradle.lockfile: `group:artifact:version=configurations`. */
241
+ function readGradleLock(root, file) {
242
+ const components = [];
243
+ lines(path.join(root, file)).forEach((raw, index) => {
244
+ const m = raw.trim().match(/^([^:#\s]+):([^:\s]+):([^=\s]+)=(.*)$/);
245
+ if (!m) return;
246
+ const configs = m[4];
247
+ const dev = configs && !/(runtimeClasspath|compileClasspath)/.test(configs);
248
+ components.push(maven(m[1], m[2], m[3], file, index + 1, { dev, direct: false }));
249
+ });
250
+ return { file, components };
251
+ }
252
+
253
+ /**
254
+ * pom.xml declared dependencies. Versions come from the dependency itself,
255
+ * `${property}` values in <properties> (and project.version) or the
256
+ * <dependencyManagement> section. Transitive dependencies need Maven to be
257
+ * resolved, so only the declared ones are audited.
258
+ */
259
+ function readPom(root, file) {
260
+ const xml = fs.readFileSync(path.join(root, file), 'utf8');
261
+ const stripped = xml.replace(/<!--[\s\S]*?-->/g, (c) => c.replace(/[^\n]/g, ' '));
262
+
263
+ const tag = (block, name) => {
264
+ const m = block.match(new RegExp(`<${name}>\\s*([^<]*?)\\s*</${name}>`));
265
+ return m ? m[1] : null;
266
+ };
267
+ // Top-level project fields, ignoring <parent>, <dependencies> and the like.
268
+ const topLevel = stripped
269
+ .replace(/<parent>[\s\S]*?<\/parent>/, '')
270
+ .replace(/<(dependencies|dependencyManagement|build|profiles|plugins|reporting)>[\s\S]*?<\/\1>/g, '');
271
+ const parentBlock = (stripped.match(/<parent>([\s\S]*?)<\/parent>/) || [])[1] || '';
272
+ const props = {
273
+ 'project.version': tag(topLevel, 'version') || tag(parentBlock, 'version'),
274
+ 'project.groupId': tag(topLevel, 'groupId') || tag(parentBlock, 'groupId'),
275
+ };
276
+ const propsBlock = (stripped.match(/<properties>([\s\S]*?)<\/properties>/) || [])[1] || '';
277
+ for (const m of propsBlock.matchAll(/<([\w.-]+)>\s*([^<]*?)\s*<\/\1>/g)) props[m[1]] = m[2];
278
+ const resolve = (value) => {
279
+ let v = value;
280
+ for (let i = 0; i < 5 && v && /\$\{([^}]+)\}/.test(v); i++) {
281
+ v = v.replace(/\$\{([^}]+)\}/g, (_, key) => (props[key] !== undefined && props[key] !== null ? props[key] : `\${${key}}`));
282
+ }
283
+ return v;
284
+ };
285
+
286
+ const managedBlock = (stripped.match(/<dependencyManagement>([\s\S]*?)<\/dependencyManagement>/) || [])[1] || '';
287
+ const managed = new Map();
288
+ for (const m of managedBlock.matchAll(/<dependency>([\s\S]*?)<\/dependency>/g)) {
289
+ const g = resolve(tag(m[1], 'groupId'));
290
+ const a = resolve(tag(m[1], 'artifactId'));
291
+ const v = resolve(tag(m[1], 'version'));
292
+ if (g && a && v) managed.set(`${g}:${a}`, v);
293
+ }
294
+
295
+ const components = [];
296
+ let skipped = 0;
297
+ const outside = stripped.replace(/<dependencyManagement>[\s\S]*?<\/dependencyManagement>/, (c) => c.replace(/[^\n]/g, ' '));
298
+ for (const m of outside.matchAll(/<dependency>([\s\S]*?)<\/dependency>/g)) {
299
+ const block = m[1];
300
+ const g = resolve(tag(block, 'groupId'));
301
+ const a = resolve(tag(block, 'artifactId'));
302
+ let v = resolve(tag(block, 'version')) || managed.get(`${g}:${a}`) || null;
303
+ if (!g || !a || !v || /\$\{|[[\]()]/.test(v)) { skipped++; continue; } // unresolved or a version range
304
+ const scope = tag(block, 'scope');
305
+ const line = outside.slice(0, m.index).split('\n').length + block.slice(0, block.indexOf('<artifactId>')).split('\n').length - 1;
306
+ components.push(maven(g, a, v, file, line, { dev: scope === 'test' || scope === 'provided', direct: true }));
307
+ }
308
+ const notes = ['pom.xml: declared dependencies only; commit a Maven-generated SBOM (cyclonedx-maven-plugin) to audit transitive ones'];
309
+ if (skipped) notes.push(`pom.xml: ${skipped} dependency(ies) with a version inherited from a parent/BOM or a range were not audited`);
310
+ return {
311
+ file, components, skipped, note: notes.join('; '),
312
+ rootName: tag(topLevel, 'artifactId'), rootVersion: props['project.version'],
313
+ };
314
+ }
315
+
316
+ function maven(group, artifact, version, file, line, { dev = false, direct = false } = {}) {
317
+ return component({
318
+ ecosystem: 'maven', name: `${group}:${artifact}`, osvName: `${group}:${artifact}`, version,
319
+ purl: `pkg:maven/${group}/${artifact}@${version}`, file, line, dev, direct,
320
+ });
321
+ }
322
+
323
+ // --- Shared ------------------------------------------------------------------------------
324
+
325
+ function component({ ecosystem, name, osvName, version, purl, file, line, dev = false, direct = false }) {
326
+ return {
327
+ key: purl,
328
+ name,
329
+ version,
330
+ purl,
331
+ ecosystem,
332
+ osvName,
333
+ license: null,
334
+ scope: dev ? 'optional' : 'required',
335
+ dev,
336
+ optional: dev,
337
+ direct,
338
+ dependsOn: [],
339
+ location: { file, line },
340
+ };
341
+ }
342
+
343
+ function dedupe(components) {
344
+ const seen = new Map();
345
+ for (const c of components) {
346
+ const existing = seen.get(c.key);
347
+ if (!existing) seen.set(c.key, c);
348
+ else {
349
+ existing.direct = existing.direct || c.direct;
350
+ existing.optional = existing.optional && c.optional;
351
+ existing.dev = existing.optional;
352
+ existing.scope = existing.optional ? 'optional' : 'required';
353
+ }
354
+ }
355
+ return [...seen.values()].sort((a, b) => a.key.localeCompare(b.key));
356
+ }
357
+
358
+ function lines(file) {
359
+ try {
360
+ return fs.readFileSync(file, 'utf8').split(/\r?\n/);
361
+ } catch {
362
+ return [];
363
+ }
364
+ }
365
+
366
+ function exists(root, file) {
367
+ return fs.existsSync(path.join(root, file));
368
+ }
369
+
370
+ function firstExisting(root, files) {
371
+ return files.find((f) => exists(root, f)) || null;
372
+ }
373
+
374
+ module.exports = { readManifests, hasManifests };
@@ -23,6 +23,9 @@ const DEFAULT_POLICY = {
23
23
  // Fail when a dependency has an actively exploited vulnerability (CISA KEV),
24
24
  // the trigger of the CRA Art. 14 reporting obligation.
25
25
  failOnKev: true,
26
+ // ISO 3166-1 alpha-2 country of the manufacturer's main establishment: it
27
+ // decides the CSIRT that receives CRA Art. 14 notifications (e.g. "ES").
28
+ country: null,
26
29
  // Audit only production dependencies (recommended for shipped products).
27
30
  productionOnly: false,
28
31
  vulnerabilities: {
@@ -0,0 +1,62 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { parseLockfile } = require('./lockfile-parser');
6
+ const { readSbom } = require('./sbom-reader');
7
+ const { readManifests, hasManifests } = require('./manifest-reader');
8
+
9
+ /**
10
+ * Decides where the components of a project come from:
11
+ *
12
+ * - `sbom`: an input SBOM (`-i`), any ecosystem
13
+ * - `npm`: the npm/Yarn/pnpm lockfile (native, richest data)
14
+ * - `manifest`: requirements.txt / poetry.lock / go.mod / pom.xml… when
15
+ * there is no npm lockfile
16
+ * - `npm+manifest`: both in the same folder (e.g. a JS front end next to a
17
+ * Python service); vulnerabilities cover both
18
+ *
19
+ * `parsed` is what the scanner, license check, VEX and SARIF consume; it is
20
+ * null for plain npm projects, which keep their existing code paths.
21
+ *
22
+ * @param {string} projectRoot
23
+ * @param {{ input?: string|null }} [options]
24
+ */
25
+ function loadProject(projectRoot, { input = null } = {}) {
26
+ if (input) {
27
+ const parsed = readSbom(input);
28
+ return { mode: 'sbom', parsed, manifests: null };
29
+ }
30
+
31
+ const lock = parseLockfile(projectRoot);
32
+ const manifests = hasManifests(projectRoot) ? readManifests(projectRoot) : null;
33
+
34
+ if (lock.ok && manifests) {
35
+ // npm components keep their name-based lookups; manifest ones use purls.
36
+ const parsed = {
37
+ ...lock,
38
+ purlBased: false,
39
+ components: [...lock.components, ...manifests.components],
40
+ root: { ...lock.root, dependsOn: [...lock.root.dependsOn, ...manifests.root.dependsOn] },
41
+ };
42
+ return { mode: 'npm+manifest', parsed, manifests };
43
+ }
44
+ if (!lock.ok && manifests) return { mode: 'manifest', parsed: manifests, manifests };
45
+ return { mode: 'npm', parsed: null, manifests: null };
46
+ }
47
+
48
+ /**
49
+ * The nearest folder, walking up from `start`, with a package.json or a
50
+ * supported manifest of another ecosystem. Null when there is none.
51
+ */
52
+ function findAnyProjectRoot(start) {
53
+ let dir = path.resolve(start);
54
+ for (;;) {
55
+ if (fs.existsSync(path.join(dir, 'package.json')) || hasManifests(dir)) return dir;
56
+ const parent = path.dirname(dir);
57
+ if (parent === dir) return null;
58
+ dir = parent;
59
+ }
60
+ }
61
+
62
+ module.exports = { loadProject, findAnyProjectRoot };
@@ -5,6 +5,8 @@ const path = require('node:path');
5
5
  const { readJson } = require('../utils/fs');
6
6
  const { parseLockfile } = require('./lockfile-parser');
7
7
  const { repositoryUrl } = require('./installed-metadata');
8
+ const { readManifests } = require('./manifest-reader');
9
+ const { reportingGuide } = require('./reporting');
8
10
 
9
11
  /**
10
12
  * Organisational CRA duties that can be checked from the repository itself:
@@ -29,10 +31,11 @@ const URL = /https?:\/\/\S+/;
29
31
 
30
32
  /**
31
33
  * @param {string} projectRoot
32
- * @param {{ now?: Date }} [options]
34
+ * @param {{ now?: Date, country?: string|null }} [options]
35
+ * `country`: main establishment (ISO alpha-2); adds its CSIRT's checks.
33
36
  * @returns {{ checks: Array<object>, passed: boolean, files: object }}
34
37
  */
35
- function checkReadiness(projectRoot, { now = new Date() } = {}) {
38
+ function checkReadiness(projectRoot, { now = new Date(), country = null } = {}) {
36
39
  const checks = [];
37
40
  const add = (id, label, level, passed, detail, reference) => checks.push({ id, label, level, passed: Boolean(passed), detail, reference });
38
41
 
@@ -53,8 +56,9 @@ function checkReadiness(projectRoot, { now = new Date() } = {}) {
53
56
 
54
57
  // --- Support period ---------------------------------------------------------
55
58
  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);
59
+ // English and Spanish wording ("periodo de soporte", "fin del soporte"…).
60
+ const supportMention = /support(ed)?\s+period|supported\s+until|end\s+of\s+(security\s+)?support|end[-\s]of[-\s]life|\bEOL\b|periodo\s+de\s+soporte|soporte\s+(de\s+seguridad\s+)?hasta|fin\s+del\s+soporte|fin\s+de\s+vida/i.test(supportText);
61
+ const supportDate = supportMention && /\b(19|20)\d{2}-\d{2}(-\d{2})?\b|\b\d+\s+(years?|años?)\b/i.test(supportText);
58
62
  add('support-period', 'Support period and its end date stated', 'required', supportMention && supportDate,
59
63
  !supportMention ? 'No mention of the support period in SECURITY.md or README.md'
60
64
  : supportDate ? 'Support period with an end date or duration found' : 'Support period mentioned without an end date',
@@ -62,14 +66,23 @@ function checkReadiness(projectRoot, { now = new Date() } = {}) {
62
66
 
63
67
  // --- Recommended content ------------------------------------------------------
64
68
  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)');
69
+ /supported\s+versions|versiones\s+(con\s+soporte|soportadas)/i.test(securityMd), 'A "Supported Versions" section in SECURITY.md', 'CRA Annex II (7)');
66
70
  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',
71
+ /\b\d+\s*(business\s+)?(hours?|days?|weeks?|horas?|d[ií]as?|semanas?)\b/i.test(securityMd), 'Acknowledgement / fix time frames in SECURITY.md',
68
72
  'CRA Annex I Part II (5)');
69
73
  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),
74
+ /ENISA|CSIRT|single\s+reporting\s+platform|24\s*h(ours|oras)?/i.test(securityMd),
71
75
  'How actively exploited vulnerabilities and severe incidents are reported', 'CRA Art. 14');
72
76
 
77
+ // The coordinating CSIRT of the manufacturer's country (Art. 14(7)).
78
+ const local = reportingGuide(country).local;
79
+ if (local) {
80
+ add('coordinating-csirt', `Coordinating CSIRT named (${local.csirt})`, 'recommended',
81
+ new RegExp(local.csirt.split('-')[0], 'i').test(securityMd),
82
+ `SECURITY.md should say Art. 14 notifications go to ${local.csirt} via the ENISA SRP (access: ${local.srpAccess.contact})`,
83
+ 'CRA Art. 14(7)');
84
+ }
85
+
73
86
  // --- security.txt (RFC 9116) --------------------------------------------------
74
87
  const securityTxtPath = firstExisting(projectRoot, SECURITY_TXT_PATHS);
75
88
  const txt = parseSecurityTxt(securityTxtPath ? read(path.join(projectRoot, securityTxtPath)) : '');
@@ -106,9 +119,12 @@ function checkReadiness(projectRoot, { now = new Date() } = {}) {
106
119
  // an SBOM produced by their own tooling (Syft, cdxgen…) kept in the repo.
107
120
  const parsed = parseLockfile(projectRoot);
108
121
  const sbomFile = parsed.ok ? null : findSbomFile(projectRoot);
109
- add('sbom', 'SBOM available (npm lockfile or an SBOM file)', 'required', parsed.ok || sbomFile,
122
+ const manifests = parsed.ok || sbomFile ? null : readManifests(projectRoot);
123
+ add('sbom', 'Dependency inventory for the SBOM (lockfile, manifest or SBOM file)', 'required', parsed.ok || sbomFile || manifests,
110
124
  parsed.ok ? `${parsed.lockfileName} (${parsed.components.length} components)`
111
- : sbomFile ? `Found ${sbomFile}` : `${parsed.error} For other ecosystems, commit an SBOM (e.g. sbom.cdx.json).`,
125
+ : sbomFile ? `Found ${sbomFile}`
126
+ : manifests ? `${manifests.files.join(', ')} (${manifests.components.length} components)`
127
+ : 'No lockfile, supported manifest or SBOM file found.',
112
128
  'CRA Annex I Part II (1)');
113
129
 
114
130
  add('package-repository', 'package.json links the source repository', 'recommended', Boolean(repositoryUrl(pkg.repository)),
@@ -140,7 +156,7 @@ function parseSecurityTxt(text) {
140
156
  *
141
157
  * @returns {Array<{ file: string, created: boolean }>}
142
158
  */
143
- function writeTemplates(projectRoot, { now = new Date() } = {}) {
159
+ function writeTemplates(projectRoot, { now = new Date(), lang = 'en', country = null } = {}) {
144
160
  const pkg = readJson(path.join(projectRoot, 'package.json')) || {};
145
161
  const repo = repositoryUrl(pkg.repository);
146
162
  const advisories = repo && /github\.com/.test(repo) ? `${repo}/security/advisories/new` : null;
@@ -152,7 +168,7 @@ function writeTemplates(projectRoot, { now = new Date() } = {}) {
152
168
  if (existingMd) {
153
169
  results.push({ file: existingMd, created: false });
154
170
  } else {
155
- fs.writeFileSync(path.join(projectRoot, 'SECURITY.md'), securityMdTemplate({ name, major, advisories }));
171
+ fs.writeFileSync(path.join(projectRoot, 'SECURITY.md'), securityMdTemplate({ name, major, advisories, lang, country }));
156
172
  results.push({ file: 'SECURITY.md', created: true });
157
173
  }
158
174
 
@@ -163,16 +179,26 @@ function writeTemplates(projectRoot, { now = new Date() } = {}) {
163
179
  const base = fs.existsSync(path.join(projectRoot, 'public')) ? 'public/.well-known' : '.well-known';
164
180
  fs.mkdirSync(path.join(projectRoot, base), { recursive: true });
165
181
  const expires = new Date(now.getTime() + 364 * DAY).toISOString().replace(/\.\d{3}Z$/, 'Z');
166
- fs.writeFileSync(path.join(projectRoot, base, 'security.txt'), securityTxtTemplate({ advisories, repo, expires }));
182
+ fs.writeFileSync(path.join(projectRoot, base, 'security.txt'), securityTxtTemplate({ advisories, repo, expires, lang }));
167
183
  results.push({ file: `${base}/security.txt`, created: true });
168
184
  }
169
185
  return results;
170
186
  }
171
187
 
172
- function securityMdTemplate({ name, major, advisories }) {
188
+ function securityMdTemplate({ name, major, advisories, lang, country }) {
189
+ if (lang === 'es') return securityMdTemplateEs({ name, major, advisories, country });
173
190
  const channel = advisories
174
191
  ? `Report it privately through [GitHub Security Advisories](${advisories}), or by email to TODO: security@example.com.`
175
192
  : 'Report it privately by email to TODO: security@example.com.';
193
+ const local = reportingGuide(country).local;
194
+ const csirtSection = local && local.country === 'ES' ? `
195
+ ### Spain — INCIBE-CERT
196
+
197
+ Our main establishment is in Spain, so our coordinating CSIRT is **INCIBE-CERT**. Access to the ENISA
198
+ Single Reporting Platform is requested from INCIBE (${local.srpAccess.contact}), which validates the
199
+ manufacturer before registration. Cybersecurity incidents outside the CRA go to ${local.otherChannels[0].contact};
200
+ if we are unsure whether a case is in CRA scope, we report it through INCIBE-CERT's usual channels first.
201
+ ` : '';
176
202
  return `# Security Policy
177
203
 
178
204
  ## Supported Versions
@@ -209,15 +235,74 @@ we notify the coordinating CSIRT and ENISA through the Single Reporting Platform
209
235
  - a **final report within 14 days** after a corrective measure is available (one month for severe incidents).
210
236
 
211
237
  Affected users are informed of the issue and of the corrective measures to take.
212
- `;
238
+ ${csirtSection}`;
239
+ }
240
+
241
+ function securityMdTemplateEs({ name, major, advisories, country }) {
242
+ const channel = advisories
243
+ ? `Comunícalo de forma privada mediante [GitHub Security Advisories](${advisories}) o por correo a TODO: security@example.com.`
244
+ : 'Comunícalo de forma privada por correo a TODO: security@example.com.';
245
+ const local = reportingGuide(country).local;
246
+ const csirt = local && local.country === 'ES'
247
+ ? `nuestro CSIRT coordinador, **INCIBE-CERT**, y a ENISA a través de la plataforma única de notificación (SRP)`
248
+ : 'el CSIRT coordinador de nuestro Estado miembro y a ENISA a través de la plataforma única de notificación (SRP)';
249
+ const spain = local && local.country === 'ES' ? `
250
+ ### España — INCIBE-CERT
251
+
252
+ Nuestro establecimiento principal está en España, por lo que nuestro CSIRT coordinador es **INCIBE-CERT**:
253
+
254
+ - El acceso a la plataforma SRP de ENISA se solicita a INCIBE (${local.srpAccess.contact}), que valida al
255
+ fabricante antes de completar el registro.
256
+ - Los incidentes de ciberseguridad que no entran en el CRA se comunican a ${local.otherChannels[0].contact}.
257
+ - Si dudamos de si un caso entra en el ámbito del CRA, lo notificamos primero por los canales habituales
258
+ de INCIBE-CERT, que nos indica si debe registrarse formalmente en la SRP.
259
+ ` : '';
260
+ return `# Política de seguridad
261
+
262
+ ## Versiones con soporte
263
+
264
+ | Versión | Soporte | Fin del soporte de seguridad |
265
+ | ------- | ------- | ---------------------------- |
266
+ | ${major}.x | ✅ | TODO: AAAA-MM-DD |
267
+ | < ${major}.0 | ❌ | — |
268
+
269
+ **Periodo de soporte:** ${name} ${major}.x recibe actualizaciones de seguridad hasta el TODO: AAAA-MM-DD
270
+ (el Reglamento de Ciberresiliencia de la UE prevé al menos 5 años, o el tiempo de uso previsto del producto).
271
+
272
+ ## Cómo comunicar una vulnerabilidad
273
+
274
+ Por favor, **no** abras una incidencia pública para problemas de seguridad.
275
+
276
+ ${channel}
277
+
278
+ Indica la versión afectada, una descripción del problema y, si es posible, los pasos para reproducirlo.
279
+
280
+ ## Nuestro proceso (divulgación coordinada de vulnerabilidades)
281
+
282
+ - Acusamos recibo en un máximo de **3 días hábiles** y enviamos una primera valoración en **10 días hábiles**.
283
+ - Acordamos con quien informa una fecha de publicación, normalmente en **90 días**, y le damos crédito si lo desea.
284
+ - Las correcciones se publican como actualizaciones de seguridad, separadas de las de funcionalidad cuando es
285
+ posible, y se anuncian en las notas de versión y en un GitHub Security Advisory.
286
+
287
+ ## Reglamento de Ciberresiliencia de la UE — Notificación (art. 14)
288
+
289
+ Cuando tenemos conocimiento de una vulnerabilidad activamente explotada en ${name}, o de un incidente grave que
290
+ afecte a su seguridad, lo notificamos a ${csirt}:
291
+
292
+ - una **alerta temprana en 24 horas**,
293
+ - una **notificación en 72 horas**,
294
+ - un **informe final en 14 días** desde que haya una medida correctora (en incidentes graves, un mes tras la notificación).
295
+
296
+ Informamos a los usuarios afectados del problema y de las medidas que deben aplicar.
297
+ ${spain}`;
213
298
  }
214
299
 
215
- function securityTxtTemplate({ advisories, repo, expires }) {
300
+ function securityTxtTemplate({ advisories, repo, expires, lang }) {
216
301
  const lines = ['# RFC 9116 — https://securitytxt.org', 'Contact: mailto:TODO-security@example.com'];
217
302
  if (advisories) lines.push(`Contact: ${advisories}`);
218
303
  lines.push(`Expires: ${expires}`);
219
304
  if (repo) lines.push(`Policy: ${repo}/blob/HEAD/SECURITY.md`);
220
- lines.push('Preferred-Languages: en', '');
305
+ lines.push(`Preferred-Languages: ${lang === 'es' ? 'es, en' : 'en'}`, '');
221
306
  return lines.join('\n');
222
307
  }
223
308