cra-audit 0.1.0 → 1.0.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/README.md +4 -4
- package/package.json +2 -3
- package/src/cli.js +42 -42
- package/src/commands/audit.js +1 -1
- package/src/commands/sbom.js +8 -8
- package/src/commands/visualize.js +16 -16
- package/src/core/auditor.js +9 -9
- package/src/core/enrich.js +27 -1
- package/src/core/lockfile-parser.js +261 -20
- package/src/core/policy.js +1 -1
- package/src/core/sbom-generator.js +1 -1
- package/src/core/sbom-validator.js +33 -33
- package/src/core/vuln-scanner.js +62 -6
- package/src/reporters/console.js +24 -24
- package/src/utils/logger.js +19 -0
- package/src/utils/yaml.js +225 -0
|
@@ -1,33 +1,50 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const fs = require('node:fs');
|
|
3
4
|
const path = require('node:path');
|
|
4
5
|
const { readJson, exists } = require('../utils/fs');
|
|
6
|
+
const { parseYaml } = require('../utils/yaml');
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
9
|
+
* Detection order: npm lockfiles take precedence (they carry the richest
|
|
10
|
+
* metadata and let `npm audit` run natively), followed by Yarn (classic v1
|
|
11
|
+
* and Berry v2+) and pnpm.
|
|
12
|
+
*/
|
|
13
|
+
const LOCKFILES = [
|
|
14
|
+
{ name: 'package-lock.json', manager: 'npm' },
|
|
15
|
+
{ name: 'npm-shrinkwrap.json', manager: 'npm' },
|
|
16
|
+
{ name: 'yarn.lock', manager: 'yarn' },
|
|
17
|
+
{ name: 'pnpm-lock.yaml', manager: 'pnpm' },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parses the project lockfile (npm `package-lock.json`/`npm-shrinkwrap.json`,
|
|
22
|
+
* Yarn `yarn.lock` classic or Berry, or pnpm `pnpm-lock.yaml`) into a
|
|
23
|
+
* normalized list of components. CRA / TR-03183 require traceability of every
|
|
24
|
+
* direct and transitive dependency, including integrity hashes.
|
|
10
25
|
*
|
|
11
26
|
* @param {string} projectRoot
|
|
12
27
|
* @returns {{
|
|
13
28
|
* ok: boolean,
|
|
29
|
+
* manager: 'npm'|'yarn'|'pnpm'|null,
|
|
14
30
|
* lockfileName: string|null,
|
|
15
|
-
* lockfileVersion: number|null,
|
|
31
|
+
* lockfileVersion: number|string|null,
|
|
16
32
|
* root: { name: string, version: string },
|
|
17
33
|
* components: Array<object>,
|
|
18
34
|
* error?: string
|
|
19
35
|
* }}
|
|
20
36
|
*/
|
|
21
37
|
function parseLockfile(projectRoot) {
|
|
22
|
-
const candidates = ['package-lock.json', 'npm-shrinkwrap.json'];
|
|
23
38
|
let lockPath = null;
|
|
24
39
|
let lockfileName = null;
|
|
40
|
+
let manager = null;
|
|
25
41
|
|
|
26
|
-
for (const candidate of
|
|
27
|
-
const full = path.join(projectRoot, candidate);
|
|
42
|
+
for (const candidate of LOCKFILES) {
|
|
43
|
+
const full = path.join(projectRoot, candidate.name);
|
|
28
44
|
if (exists(full)) {
|
|
29
45
|
lockPath = full;
|
|
30
|
-
lockfileName = candidate;
|
|
46
|
+
lockfileName = candidate.name;
|
|
47
|
+
manager = candidate.manager;
|
|
31
48
|
break;
|
|
32
49
|
}
|
|
33
50
|
}
|
|
@@ -42,16 +59,39 @@ function parseLockfile(projectRoot) {
|
|
|
42
59
|
if (!lockPath) {
|
|
43
60
|
return {
|
|
44
61
|
ok: false,
|
|
62
|
+
manager: null,
|
|
45
63
|
lockfileName: null,
|
|
46
64
|
lockfileVersion: null,
|
|
47
65
|
root,
|
|
48
66
|
components: [],
|
|
49
67
|
error:
|
|
50
|
-
'No
|
|
51
|
-
'
|
|
68
|
+
'No lockfile found (package-lock.json, npm-shrinkwrap.json, ' +
|
|
69
|
+
'yarn.lock or pnpm-lock.yaml). The CRA requires deterministic dependency ' +
|
|
70
|
+
'traceability: run `npm install` (or `yarn` / `pnpm install`) to generate the lockfile.',
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
if (manager === 'npm') return parseNpmLock(lockPath, lockfileName, root);
|
|
76
|
+
if (manager === 'yarn') return parseYarnLock(lockPath, lockfileName, root);
|
|
77
|
+
if (manager === 'pnpm') return parsePnpmLock(lockPath, lockfileName, root);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
manager,
|
|
82
|
+
lockfileName,
|
|
83
|
+
lockfileVersion: null,
|
|
84
|
+
root,
|
|
85
|
+
components: [],
|
|
86
|
+
error: `Could not parse ${lockfileName}: ${err.message}`,
|
|
52
87
|
};
|
|
53
88
|
}
|
|
54
89
|
|
|
90
|
+
return { ok: true, manager, lockfileName, lockfileVersion: null, root, components: [] };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** npm: package-lock.json (v1/v2/v3) or npm-shrinkwrap.json. */
|
|
94
|
+
function parseNpmLock(lockPath, lockfileName, root) {
|
|
55
95
|
const lock = readJson(lockPath);
|
|
56
96
|
const lockfileVersion = lock.lockfileVersion || 1;
|
|
57
97
|
|
|
@@ -64,13 +104,7 @@ function parseLockfile(projectRoot) {
|
|
|
64
104
|
components = [];
|
|
65
105
|
}
|
|
66
106
|
|
|
67
|
-
return {
|
|
68
|
-
ok: true,
|
|
69
|
-
lockfileName,
|
|
70
|
-
lockfileVersion,
|
|
71
|
-
root,
|
|
72
|
-
components,
|
|
73
|
-
};
|
|
107
|
+
return { ok: true, manager: 'npm', lockfileName, lockfileVersion, root, components };
|
|
74
108
|
}
|
|
75
109
|
|
|
76
110
|
/** Lockfile v2/v3 store everything under the `packages` map keyed by path. */
|
|
@@ -126,6 +160,164 @@ function nameFromPath(pkgPath) {
|
|
|
126
160
|
return pkgPath.slice(idx + 'node_modules/'.length);
|
|
127
161
|
}
|
|
128
162
|
|
|
163
|
+
// --- Yarn ------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
/** Yarn: dispatches between the classic v1 text format and Berry's YAML. */
|
|
166
|
+
function parseYarnLock(lockPath, lockfileName, root) {
|
|
167
|
+
const content = fs.readFileSync(lockPath, 'utf8');
|
|
168
|
+
const isBerry = /^__metadata:/m.test(content) || /\n {2}resolution:/.test(content);
|
|
169
|
+
const components = isBerry ? parseYarnBerry(content) : parseYarnClassic(content);
|
|
170
|
+
return {
|
|
171
|
+
ok: true,
|
|
172
|
+
manager: 'yarn',
|
|
173
|
+
lockfileName,
|
|
174
|
+
lockfileVersion: isBerry ? 'berry' : 1,
|
|
175
|
+
root,
|
|
176
|
+
components: dedupe(components),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Yarn classic (v1) lockfiles use a custom, non-YAML text format: a block per
|
|
182
|
+
* resolved entry whose header lists one or more `name@range` descriptors.
|
|
183
|
+
*/
|
|
184
|
+
function parseYarnClassic(content) {
|
|
185
|
+
const components = [];
|
|
186
|
+
const blocks = content.split(/\r?\n(?=\S)/);
|
|
187
|
+
|
|
188
|
+
for (const block of blocks) {
|
|
189
|
+
const lines = block.split(/\r?\n/);
|
|
190
|
+
const header = lines[0];
|
|
191
|
+
if (!header || header.startsWith('#') || !header.trim().endsWith(':')) continue;
|
|
192
|
+
|
|
193
|
+
const descriptors = header.replace(/:\s*$/, '').split(',').map((d) => stripQuotes(d.trim()));
|
|
194
|
+
const name = packageNameFromDescriptor(descriptors[0]);
|
|
195
|
+
if (!name) continue;
|
|
196
|
+
|
|
197
|
+
let version = null;
|
|
198
|
+
let resolved = null;
|
|
199
|
+
let integrity = null;
|
|
200
|
+
for (const raw of lines.slice(1)) {
|
|
201
|
+
const line = raw.trim();
|
|
202
|
+
if (line.startsWith('version ')) version = stripQuotes(line.slice('version '.length).trim());
|
|
203
|
+
else if (line.startsWith('resolved ')) resolved = stripQuotes(line.slice('resolved '.length).trim());
|
|
204
|
+
else if (line.startsWith('integrity ')) integrity = stripQuotes(line.slice('integrity '.length).trim());
|
|
205
|
+
}
|
|
206
|
+
if (!version) continue;
|
|
207
|
+
|
|
208
|
+
components.push(normalizeComponent({ name, version, resolved, integrity, path: `node_modules/${name}` }));
|
|
209
|
+
}
|
|
210
|
+
return components;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Yarn Berry (v2+) lockfiles are YAML keyed by descriptor lists. */
|
|
214
|
+
function parseYarnBerry(content) {
|
|
215
|
+
const doc = parseYaml(content) || {};
|
|
216
|
+
const components = [];
|
|
217
|
+
|
|
218
|
+
for (const [key, info] of Object.entries(doc)) {
|
|
219
|
+
if (key === '__metadata' || !info || typeof info !== 'object') continue;
|
|
220
|
+
if (!info.version) continue;
|
|
221
|
+
|
|
222
|
+
const resolution = typeof info.resolution === 'string' ? info.resolution : '';
|
|
223
|
+
const name = packageNameFromDescriptor(resolution || String(key).split(',')[0].trim());
|
|
224
|
+
if (!name) continue;
|
|
225
|
+
|
|
226
|
+
components.push(normalizeComponent({
|
|
227
|
+
name,
|
|
228
|
+
version: info.version,
|
|
229
|
+
// Berry's `checksum` is a cache key, not an npm SRI hash, so we omit it.
|
|
230
|
+
integrity: null,
|
|
231
|
+
path: `node_modules/${name}`,
|
|
232
|
+
}));
|
|
233
|
+
}
|
|
234
|
+
return components;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// --- pnpm ------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
/** pnpm: `pnpm-lock.yaml` (lockfileVersion 5.x / 6.x / 9.x). */
|
|
240
|
+
function parsePnpmLock(lockPath, lockfileName, root) {
|
|
241
|
+
const doc = parseYaml(fs.readFileSync(lockPath, 'utf8')) || {};
|
|
242
|
+
const packages = doc.packages && typeof doc.packages === 'object' ? doc.packages : {};
|
|
243
|
+
const components = [];
|
|
244
|
+
|
|
245
|
+
for (const [key, info] of Object.entries(packages)) {
|
|
246
|
+
const parsed = packageKeyToNameVersion(key);
|
|
247
|
+
if (!parsed) continue;
|
|
248
|
+
const resolution = info && typeof info === 'object' ? info.resolution : null;
|
|
249
|
+
const integrity = resolution && typeof resolution === 'object' ? resolution.integrity || null : null;
|
|
250
|
+
components.push(normalizeComponent({
|
|
251
|
+
name: parsed.name,
|
|
252
|
+
version: parsed.version,
|
|
253
|
+
integrity,
|
|
254
|
+
path: `node_modules/${parsed.name}`,
|
|
255
|
+
}));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
ok: true,
|
|
260
|
+
manager: 'pnpm',
|
|
261
|
+
lockfileName,
|
|
262
|
+
lockfileVersion: doc.lockfileVersion || null,
|
|
263
|
+
root,
|
|
264
|
+
components: dedupe(components),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Extracts a package name from a Yarn/pnpm descriptor or resolution string,
|
|
270
|
+
* e.g. `lodash@^4.17.21`, `@babel/core@npm:7.0.0`, `left-pad@1.3.0`.
|
|
271
|
+
*/
|
|
272
|
+
function packageNameFromDescriptor(descriptor) {
|
|
273
|
+
if (!descriptor) return null;
|
|
274
|
+
let d = stripQuotes(String(descriptor).trim());
|
|
275
|
+
// Drop a protocol/range part: take everything before the version separator.
|
|
276
|
+
const scoped = d.startsWith('@');
|
|
277
|
+
const at = d.indexOf('@', scoped ? 1 : 0);
|
|
278
|
+
return at === -1 ? d : d.slice(0, at);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Splits a pnpm package map key into { name, version }. Handles the historical
|
|
283
|
+
* variants: `/lodash/4.17.21`, `/lodash@4.17.21`, `lodash@4.17.21` and peer
|
|
284
|
+
* suffixes like `@babel/core@7.0.0(react@18.0.0)`.
|
|
285
|
+
*/
|
|
286
|
+
function packageKeyToNameVersion(key) {
|
|
287
|
+
let k = stripQuotes(String(key).trim());
|
|
288
|
+
if (!k) return null;
|
|
289
|
+
// Strip peer-dependency suffix: name@version(peer@x)(...).
|
|
290
|
+
const peerIdx = k.indexOf('(');
|
|
291
|
+
if (peerIdx !== -1) k = k.slice(0, peerIdx);
|
|
292
|
+
|
|
293
|
+
if (k.startsWith('/')) {
|
|
294
|
+
k = k.slice(1);
|
|
295
|
+
// v5 uses `/` as separator: name/version (scoped: @scope/name/version).
|
|
296
|
+
if (!k.includes('@', k.startsWith('@') ? 1 : 0) && k.includes('/')) {
|
|
297
|
+
const idx = k.lastIndexOf('/');
|
|
298
|
+
return { name: k.slice(0, idx), version: k.slice(idx + 1) };
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const scoped = k.startsWith('@');
|
|
303
|
+
const at = k.lastIndexOf('@');
|
|
304
|
+
if (at <= 0) return null;
|
|
305
|
+
if (scoped && at === 0) return null;
|
|
306
|
+
const name = k.slice(0, at);
|
|
307
|
+
const version = k.slice(at + 1);
|
|
308
|
+
if (!name || !version) return null;
|
|
309
|
+
return { name, version };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function stripQuotes(value) {
|
|
313
|
+
if (typeof value !== 'string') return value;
|
|
314
|
+
const t = value.trim();
|
|
315
|
+
if (t.length >= 2 && ((t[0] === '"' && t[t.length - 1] === '"') || (t[0] === "'" && t[t.length - 1] === "'"))) {
|
|
316
|
+
return t.slice(1, -1);
|
|
317
|
+
}
|
|
318
|
+
return t;
|
|
319
|
+
}
|
|
320
|
+
|
|
129
321
|
function normalizeComponent(raw) {
|
|
130
322
|
const { algorithm, hash } = parseIntegrity(raw.integrity);
|
|
131
323
|
return {
|
|
@@ -138,8 +330,8 @@ function normalizeComponent(raw) {
|
|
|
138
330
|
hashValue: hash,
|
|
139
331
|
license: raw.license || null,
|
|
140
332
|
scope: raw.dev ? 'optional' : 'required',
|
|
141
|
-
dev: raw.dev,
|
|
142
|
-
optional: raw.optional,
|
|
333
|
+
dev: Boolean(raw.dev),
|
|
334
|
+
optional: Boolean(raw.optional),
|
|
143
335
|
path: raw.path,
|
|
144
336
|
};
|
|
145
337
|
}
|
|
@@ -195,4 +387,53 @@ function dedupe(components) {
|
|
|
195
387
|
);
|
|
196
388
|
}
|
|
197
389
|
|
|
198
|
-
|
|
390
|
+
/**
|
|
391
|
+
* Builds a synthetic npm `package-lock.json` (v3) and matching `package.json`
|
|
392
|
+
* from a parsed lockfile, so that `npm audit --package-lock-only` can scan a
|
|
393
|
+
* Yarn/pnpm project without an npm lockfile. The exact versions resolved by
|
|
394
|
+
* Yarn/pnpm are preserved; duplicate versions of a package are nested so each
|
|
395
|
+
* is present in the tree the auditor inspects.
|
|
396
|
+
*
|
|
397
|
+
* @param {{ root: {name:string, version:string}, components: Array<object> }} parsed
|
|
398
|
+
* @returns {{ packageJson: object, packageLock: object }}
|
|
399
|
+
*/
|
|
400
|
+
function toNpmLockfile(parsed) {
|
|
401
|
+
const packages = { '': { name: parsed.root.name, version: parsed.root.version } };
|
|
402
|
+
const rootDeps = {};
|
|
403
|
+
const placed = new Map();
|
|
404
|
+
|
|
405
|
+
for (const c of parsed.components) {
|
|
406
|
+
if (!c.name || !c.version) continue;
|
|
407
|
+
const count = placed.get(c.name) || 0;
|
|
408
|
+
placed.set(c.name, count + 1);
|
|
409
|
+
|
|
410
|
+
let key;
|
|
411
|
+
if (count === 0) {
|
|
412
|
+
key = `node_modules/${c.name}`;
|
|
413
|
+
rootDeps[c.name] = c.version;
|
|
414
|
+
} else {
|
|
415
|
+
key = `node_modules/${c.name}` + `/node_modules/${c.name}`.repeat(count);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const entry = { version: c.version };
|
|
419
|
+
if (c.resolved) entry.resolved = c.resolved;
|
|
420
|
+
if (c.integrity) entry.integrity = c.integrity;
|
|
421
|
+
if (c.license) entry.license = c.license;
|
|
422
|
+
packages[key] = entry;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
packages[''].dependencies = rootDeps;
|
|
426
|
+
|
|
427
|
+
return {
|
|
428
|
+
packageJson: { name: parsed.root.name, version: parsed.root.version, dependencies: rootDeps },
|
|
429
|
+
packageLock: {
|
|
430
|
+
name: parsed.root.name,
|
|
431
|
+
version: parsed.root.version,
|
|
432
|
+
lockfileVersion: 3,
|
|
433
|
+
requires: true,
|
|
434
|
+
packages,
|
|
435
|
+
},
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
module.exports = { parseLockfile, buildPurl, parseIntegrity, toNpmLockfile };
|
package/src/core/policy.js
CHANGED
|
@@ -44,7 +44,7 @@ function loadPolicy(projectRoot, explicitPath) {
|
|
|
44
44
|
? explicitPath
|
|
45
45
|
: path.join(projectRoot, explicitPath);
|
|
46
46
|
if (!exists(configPath)) {
|
|
47
|
-
throw new Error(`
|
|
47
|
+
throw new Error(`Configuration file not found: ${explicitPath}`);
|
|
48
48
|
}
|
|
49
49
|
} else {
|
|
50
50
|
for (const name of CONFIG_FILENAMES) {
|
|
@@ -24,7 +24,7 @@ function generateSbom(projectRoot, { format = 'cyclonedx' } = {}) {
|
|
|
24
24
|
} else if (normalizedFormat === 'cyclonedx' || normalizedFormat === 'cdx') {
|
|
25
25
|
document = buildCycloneDx(parsed);
|
|
26
26
|
} else {
|
|
27
|
-
return { ok: false, error: `
|
|
27
|
+
return { ok: false, error: `Unsupported SBOM format: "${format}". Use "cyclonedx" or "spdx".` };
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
return {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
function validateSbom(document) {
|
|
16
16
|
if (!document || typeof document !== 'object') {
|
|
17
|
-
return invalid('
|
|
17
|
+
return invalid('The SBOM is not a valid JSON object.');
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
if (document.bomFormat === 'CycloneDX' || document.specVersion) {
|
|
@@ -24,38 +24,38 @@ function validateSbom(document) {
|
|
|
24
24
|
return validateSpdx(document);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
return invalid('
|
|
27
|
+
return invalid('Unrecognized SBOM format (neither CycloneDX nor SPDX).');
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
function validateCycloneDx(doc) {
|
|
31
31
|
const issues = [];
|
|
32
32
|
const checks = [];
|
|
33
33
|
|
|
34
|
-
pushCheck(checks, 'format', '
|
|
35
|
-
pushCheck(checks, 'specVersion', '
|
|
36
|
-
pushCheck(checks, 'serialNumber', '
|
|
37
|
-
pushCheck(checks, 'timestamp', '
|
|
38
|
-
pushCheck(checks, 'author', '
|
|
34
|
+
pushCheck(checks, 'format', 'Format declared (CycloneDX)', doc.bomFormat === 'CycloneDX');
|
|
35
|
+
pushCheck(checks, 'specVersion', 'Specification version present', Boolean(doc.specVersion));
|
|
36
|
+
pushCheck(checks, 'serialNumber', 'Unique document identifier (serialNumber)', Boolean(doc.serialNumber));
|
|
37
|
+
pushCheck(checks, 'timestamp', 'Creation timestamp', Boolean(doc.metadata && doc.metadata.timestamp));
|
|
38
|
+
pushCheck(checks, 'author', 'Author / generating tool declared',
|
|
39
39
|
Boolean(doc.metadata && (doc.metadata.tools || doc.metadata.authors)));
|
|
40
|
-
pushCheck(checks, 'rootComponent', '
|
|
40
|
+
pushCheck(checks, 'rootComponent', 'Root component (product) identified',
|
|
41
41
|
Boolean(doc.metadata && doc.metadata.component && doc.metadata.component.name));
|
|
42
42
|
|
|
43
43
|
const components = Array.isArray(doc.components) ? doc.components : [];
|
|
44
|
-
pushCheck(checks, 'components', '
|
|
44
|
+
pushCheck(checks, 'components', 'Contains at least one component', components.length > 0);
|
|
45
45
|
|
|
46
46
|
const stats = { total: components.length, missingVersion: 0, missingHash: 0, missingLicense: 0, missingPurl: 0 };
|
|
47
47
|
for (const comp of components) {
|
|
48
|
-
const label = comp.name || comp['bom-ref'] || '
|
|
49
|
-
if (!comp.version) { stats.missingVersion++; issues.push(`
|
|
50
|
-
if (!hasCdxHash(comp)) { stats.missingHash++; issues.push(`
|
|
51
|
-
if (!hasCdxLicense(comp)) { stats.missingLicense++; issues.push(`
|
|
52
|
-
if (!comp.purl && !comp['bom-ref']) { stats.missingPurl++; issues.push(`
|
|
48
|
+
const label = comp.name || comp['bom-ref'] || 'unknown';
|
|
49
|
+
if (!comp.version) { stats.missingVersion++; issues.push(`Component without a version: ${label}`); }
|
|
50
|
+
if (!hasCdxHash(comp)) { stats.missingHash++; issues.push(`Component without a cryptographic hash: ${label}`); }
|
|
51
|
+
if (!hasCdxLicense(comp)) { stats.missingLicense++; issues.push(`Component without a license: ${label}`); }
|
|
52
|
+
if (!comp.purl && !comp['bom-ref']) { stats.missingPurl++; issues.push(`Component without a unique identifier (purl): ${label}`); }
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
pushCheck(checks, 'componentVersions', '
|
|
56
|
-
pushCheck(checks, 'componentHashes', '
|
|
57
|
-
pushCheck(checks, 'componentLicenses', '
|
|
58
|
-
pushCheck(checks, 'componentIdentifiers', '
|
|
55
|
+
pushCheck(checks, 'componentVersions', 'All components have a version', stats.missingVersion === 0);
|
|
56
|
+
pushCheck(checks, 'componentHashes', 'All components have a cryptographic hash', stats.missingHash === 0);
|
|
57
|
+
pushCheck(checks, 'componentLicenses', 'All components have a license', stats.missingLicense === 0);
|
|
58
|
+
pushCheck(checks, 'componentIdentifiers', 'All components have a unique identifier', stats.missingPurl === 0);
|
|
59
59
|
|
|
60
60
|
return summarize('cyclonedx', checks, issues, stats);
|
|
61
61
|
}
|
|
@@ -64,30 +64,30 @@ function validateSpdx(doc) {
|
|
|
64
64
|
const issues = [];
|
|
65
65
|
const checks = [];
|
|
66
66
|
|
|
67
|
-
pushCheck(checks, 'format', '
|
|
68
|
-
pushCheck(checks, 'dataLicense', '
|
|
69
|
-
pushCheck(checks, 'serialNumber', '
|
|
70
|
-
pushCheck(checks, 'timestamp', '
|
|
71
|
-
pushCheck(checks, 'author', '
|
|
67
|
+
pushCheck(checks, 'format', 'Format declared (SPDX)', Boolean(doc.spdxVersion));
|
|
68
|
+
pushCheck(checks, 'dataLicense', 'Data license declared', Boolean(doc.dataLicense));
|
|
69
|
+
pushCheck(checks, 'serialNumber', 'Unique document namespace', Boolean(doc.documentNamespace));
|
|
70
|
+
pushCheck(checks, 'timestamp', 'Creation timestamp', Boolean(doc.creationInfo && doc.creationInfo.created));
|
|
71
|
+
pushCheck(checks, 'author', 'Author / creator declared',
|
|
72
72
|
Boolean(doc.creationInfo && Array.isArray(doc.creationInfo.creators) && doc.creationInfo.creators.length));
|
|
73
73
|
|
|
74
74
|
const packages = Array.isArray(doc.packages) ? doc.packages : [];
|
|
75
|
-
pushCheck(checks, 'components', '
|
|
75
|
+
pushCheck(checks, 'components', 'Contains at least one package', packages.length > 0);
|
|
76
76
|
|
|
77
77
|
const stats = { total: packages.length, missingVersion: 0, missingHash: 0, missingLicense: 0, missingPurl: 0 };
|
|
78
78
|
for (const pkg of packages) {
|
|
79
|
-
const label = pkg.name || pkg.SPDXID || '
|
|
79
|
+
const label = pkg.name || pkg.SPDXID || 'unknown';
|
|
80
80
|
if (pkg.SPDXID === 'SPDXRef-Package-root') continue;
|
|
81
|
-
if (!pkg.versionInfo || pkg.versionInfo === 'NOASSERTION') { stats.missingVersion++; issues.push(`
|
|
82
|
-
if (!Array.isArray(pkg.checksums) || pkg.checksums.length === 0) { stats.missingHash++; issues.push(`
|
|
83
|
-
if (!pkg.licenseConcluded || pkg.licenseConcluded === 'NOASSERTION') { stats.missingLicense++; issues.push(`
|
|
84
|
-
if (!Array.isArray(pkg.externalRefs) || !pkg.externalRefs.some((r) => r.referenceType === 'purl')) { stats.missingPurl++; issues.push(`
|
|
81
|
+
if (!pkg.versionInfo || pkg.versionInfo === 'NOASSERTION') { stats.missingVersion++; issues.push(`Package without a version: ${label}`); }
|
|
82
|
+
if (!Array.isArray(pkg.checksums) || pkg.checksums.length === 0) { stats.missingHash++; issues.push(`Package without a checksum: ${label}`); }
|
|
83
|
+
if (!pkg.licenseConcluded || pkg.licenseConcluded === 'NOASSERTION') { stats.missingLicense++; issues.push(`Package without a license: ${label}`); }
|
|
84
|
+
if (!Array.isArray(pkg.externalRefs) || !pkg.externalRefs.some((r) => r.referenceType === 'purl')) { stats.missingPurl++; issues.push(`Package without a purl: ${label}`); }
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
pushCheck(checks, 'componentVersions', '
|
|
88
|
-
pushCheck(checks, 'componentHashes', '
|
|
89
|
-
pushCheck(checks, 'componentLicenses', '
|
|
90
|
-
pushCheck(checks, 'componentIdentifiers', '
|
|
87
|
+
pushCheck(checks, 'componentVersions', 'All packages have a version', stats.missingVersion === 0);
|
|
88
|
+
pushCheck(checks, 'componentHashes', 'All packages have a checksum', stats.missingHash === 0);
|
|
89
|
+
pushCheck(checks, 'componentLicenses', 'All packages have a license', stats.missingLicense === 0);
|
|
90
|
+
pushCheck(checks, 'componentIdentifiers', 'All packages have a purl', stats.missingPurl === 0);
|
|
91
91
|
|
|
92
92
|
return summarize('spdx', checks, issues, stats);
|
|
93
93
|
}
|
package/src/core/vuln-scanner.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
3
6
|
const { runJson } = require('../utils/exec');
|
|
7
|
+
const { exists } = require('../utils/fs');
|
|
8
|
+
const { parseLockfile, toNpmLockfile } = require('./lockfile-parser');
|
|
4
9
|
|
|
5
10
|
const SEVERITY_ORDER = ['info', 'low', 'moderate', 'high', 'critical'];
|
|
6
11
|
|
|
@@ -9,22 +14,47 @@ const SEVERITY_ORDER = ['info', 'low', 'moderate', 'high', 'critical'];
|
|
|
9
14
|
* `npm audit --json`. The CRA requires products to ship "without known
|
|
10
15
|
* exploitable vulnerabilities", so this is the core gate of the audit.
|
|
11
16
|
*
|
|
17
|
+
* npm projects are audited in place. Yarn/pnpm projects (which have no npm
|
|
18
|
+
* lockfile) are audited against a synthetic `package-lock.json` generated from
|
|
19
|
+
* their lockfile, preserving the exact installed versions.
|
|
20
|
+
*
|
|
12
21
|
* @param {string} projectRoot
|
|
13
22
|
* @param {{ production?: boolean }} [options]
|
|
14
23
|
*/
|
|
15
24
|
function scanVulnerabilities(projectRoot, { production = false } = {}) {
|
|
25
|
+
if (hasNpmLockfile(projectRoot)) {
|
|
26
|
+
return runNpmAudit(projectRoot, { production });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const parsed = parseLockfile(projectRoot);
|
|
30
|
+
if (parsed.ok && parsed.components.length > 0) {
|
|
31
|
+
return auditSynthesized(parsed, { production });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// No usable lockfile: run npm audit in place so the user gets npm's own error.
|
|
35
|
+
return runNpmAudit(projectRoot, { production });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function hasNpmLockfile(projectRoot) {
|
|
39
|
+
return exists(path.join(projectRoot, 'package-lock.json')) ||
|
|
40
|
+
exists(path.join(projectRoot, 'npm-shrinkwrap.json'));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Runs `npm audit --json` in the given directory. */
|
|
44
|
+
function runNpmAudit(cwd, { production = false, packageLockOnly = false } = {}) {
|
|
16
45
|
const args = ['audit', '--json'];
|
|
17
46
|
if (production) args.push('--omit=dev');
|
|
47
|
+
if (packageLockOnly) args.push('--package-lock-only');
|
|
18
48
|
|
|
19
|
-
const result = runJson('npm', args, { cwd
|
|
49
|
+
const result = runJson('npm', args, { cwd });
|
|
20
50
|
|
|
21
51
|
if (!result.data) {
|
|
22
52
|
return {
|
|
23
53
|
ok: false,
|
|
24
54
|
error:
|
|
25
|
-
'
|
|
26
|
-
'
|
|
27
|
-
(result.stderr ? `
|
|
55
|
+
'Could not run `npm audit`. Make sure npm is installed and a ' +
|
|
56
|
+
'lockfile is present. ' +
|
|
57
|
+
(result.stderr ? `Detail: ${result.stderr.trim()}` : ''),
|
|
28
58
|
counts: emptyCounts(),
|
|
29
59
|
vulnerabilities: [],
|
|
30
60
|
};
|
|
@@ -46,6 +76,32 @@ function scanVulnerabilities(projectRoot, { production = false } = {}) {
|
|
|
46
76
|
};
|
|
47
77
|
}
|
|
48
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Audits a Yarn/pnpm project by materializing a synthetic npm lockfile in a
|
|
81
|
+
* temporary directory and running `npm audit --package-lock-only` there.
|
|
82
|
+
*/
|
|
83
|
+
function auditSynthesized(parsed, { production = false } = {}) {
|
|
84
|
+
const { packageJson, packageLock } = toNpmLockfile(parsed);
|
|
85
|
+
let tmpDir;
|
|
86
|
+
try {
|
|
87
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cra-audit-'));
|
|
88
|
+
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(packageJson, null, 2));
|
|
89
|
+
fs.writeFileSync(path.join(tmpDir, 'package-lock.json'), JSON.stringify(packageLock, null, 2));
|
|
90
|
+
return runNpmAudit(tmpDir, { production, packageLockOnly: true });
|
|
91
|
+
} catch (err) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
error: `Could not analyze vulnerabilities from lockfile ${parsed.lockfileName || ''}: ${err.message}`.trim(),
|
|
95
|
+
counts: emptyCounts(),
|
|
96
|
+
vulnerabilities: [],
|
|
97
|
+
};
|
|
98
|
+
} finally {
|
|
99
|
+
if (tmpDir) {
|
|
100
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
49
105
|
function parseModernReport(data) {
|
|
50
106
|
const counts = emptyCounts();
|
|
51
107
|
const vulnerabilities = [];
|
|
@@ -63,14 +119,14 @@ function parseModernReport(data) {
|
|
|
63
119
|
const advisories = via.filter((v) => typeof v === 'object');
|
|
64
120
|
const sources = advisories.length
|
|
65
121
|
? advisories.map((adv) => ({
|
|
66
|
-
title: adv.title || '
|
|
122
|
+
title: adv.title || 'Known vulnerability',
|
|
67
123
|
url: adv.url || null,
|
|
68
124
|
cwe: adv.cwe || [],
|
|
69
125
|
cvss: adv.cvss ? adv.cvss.score : null,
|
|
70
126
|
source: adv.source || null,
|
|
71
127
|
range: adv.range || null,
|
|
72
128
|
}))
|
|
73
|
-
: [{ title: `
|
|
129
|
+
: [{ title: `Dependency vulnerable through ${via.join(', ')}`, url: null, cwe: [], cvss: null }];
|
|
74
130
|
|
|
75
131
|
vulnerabilities.push({
|
|
76
132
|
name,
|