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,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 };
|
package/src/core/policy.js
CHANGED
|
@@ -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 };
|
package/src/core/readiness.js
CHANGED
|
@@ -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
|
-
|
|
57
|
-
const
|
|
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
|
-
|
|
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}`
|
|
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(
|
|
305
|
+
lines.push(`Preferred-Languages: ${lang === 'es' ? 'es, en' : 'en'}`, '');
|
|
221
306
|
return lines.join('\n');
|
|
222
307
|
}
|
|
223
308
|
|