cra-audit 0.1.0 → 1.1.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 +29 -7
- package/package.json +16 -4
- package/src/cli.js +44 -42
- package/src/commands/audit.js +1 -1
- package/src/commands/sbom.js +23 -12
- package/src/commands/visualize.js +16 -16
- package/src/core/auditor.js +10 -10
- package/src/core/enrich.js +27 -1
- package/src/core/installed-metadata.js +179 -0
- package/src/core/license-checker.js +2 -27
- package/src/core/lockfile-parser.js +527 -47
- package/src/core/policy.js +4 -1
- package/src/core/sbom-generator.js +330 -146
- package/src/core/sbom-validator.js +228 -124
- 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,57 @@
|
|
|
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,
|
|
16
|
-
* root: { name: string, version: string },
|
|
31
|
+
* lockfileVersion: number|string|null,
|
|
32
|
+
* root: { name: string, version: string, dependsOn: string[] },
|
|
17
33
|
* components: Array<object>,
|
|
34
|
+
* dependencyGraphComplete: boolean,
|
|
18
35
|
* error?: string
|
|
19
36
|
* }}
|
|
37
|
+
*
|
|
38
|
+
* Every component carries `dependsOn`: the `name@version` keys of the
|
|
39
|
+
* components it directly depends on (TR-03183 §5.2.2 "Dependencies on other
|
|
40
|
+
* components"). `root.dependsOn` lists the project's direct dependencies.
|
|
41
|
+
* `dependencyGraphComplete` is false when a required dependency could not be
|
|
42
|
+
* resolved in the lockfile, so the SBOM can declare the graph as incomplete.
|
|
20
43
|
*/
|
|
21
44
|
function parseLockfile(projectRoot) {
|
|
22
|
-
const candidates = ['package-lock.json', 'npm-shrinkwrap.json'];
|
|
23
45
|
let lockPath = null;
|
|
24
46
|
let lockfileName = null;
|
|
47
|
+
let manager = null;
|
|
25
48
|
|
|
26
|
-
for (const candidate of
|
|
27
|
-
const full = path.join(projectRoot, candidate);
|
|
49
|
+
for (const candidate of LOCKFILES) {
|
|
50
|
+
const full = path.join(projectRoot, candidate.name);
|
|
28
51
|
if (exists(full)) {
|
|
29
52
|
lockPath = full;
|
|
30
|
-
lockfileName = candidate;
|
|
53
|
+
lockfileName = candidate.name;
|
|
54
|
+
manager = candidate.manager;
|
|
31
55
|
break;
|
|
32
56
|
}
|
|
33
57
|
}
|
|
@@ -37,45 +61,115 @@ function parseLockfile(projectRoot) {
|
|
|
37
61
|
const root = {
|
|
38
62
|
name: rootPkg.name || path.basename(projectRoot),
|
|
39
63
|
version: rootPkg.version || '0.0.0',
|
|
64
|
+
dependsOn: [],
|
|
40
65
|
};
|
|
41
66
|
|
|
42
67
|
if (!lockPath) {
|
|
43
68
|
return {
|
|
44
69
|
ok: false,
|
|
70
|
+
manager: null,
|
|
45
71
|
lockfileName: null,
|
|
46
72
|
lockfileVersion: null,
|
|
47
73
|
root,
|
|
48
74
|
components: [],
|
|
75
|
+
dependencyGraphComplete: false,
|
|
49
76
|
error:
|
|
50
|
-
'No
|
|
51
|
-
'
|
|
77
|
+
'No lockfile found (package-lock.json, npm-shrinkwrap.json, ' +
|
|
78
|
+
'yarn.lock or pnpm-lock.yaml). The CRA requires deterministic dependency ' +
|
|
79
|
+
'traceability: run `npm install` (or `yarn` / `pnpm install`) to generate the lockfile.',
|
|
52
80
|
};
|
|
53
81
|
}
|
|
54
82
|
|
|
83
|
+
try {
|
|
84
|
+
let result = null;
|
|
85
|
+
if (manager === 'npm') result = parseNpmLock(lockPath, rootPkg);
|
|
86
|
+
else if (manager === 'yarn') result = parseYarnLock(lockPath, rootPkg);
|
|
87
|
+
else if (manager === 'pnpm') result = parsePnpmLock(lockPath);
|
|
88
|
+
|
|
89
|
+
root.dependsOn = uniqueSorted(result.rootDependsOn);
|
|
90
|
+
return {
|
|
91
|
+
ok: true,
|
|
92
|
+
manager,
|
|
93
|
+
lockfileName,
|
|
94
|
+
lockfileVersion: result.lockfileVersion,
|
|
95
|
+
root,
|
|
96
|
+
components: dedupe(result.components),
|
|
97
|
+
dependencyGraphComplete: result.complete,
|
|
98
|
+
};
|
|
99
|
+
} catch (err) {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
manager,
|
|
103
|
+
lockfileName,
|
|
104
|
+
lockfileVersion: null,
|
|
105
|
+
root,
|
|
106
|
+
components: [],
|
|
107
|
+
dependencyGraphComplete: false,
|
|
108
|
+
error: `Could not parse ${lockfileName}: ${err.message}`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Marker returned by resolvers for dependencies that point to a local
|
|
115
|
+
* workspace/link: they are first-party code, not an unresolved dependency.
|
|
116
|
+
*/
|
|
117
|
+
const LINK = Symbol('link');
|
|
118
|
+
|
|
119
|
+
/** npm: package-lock.json (v1/v2/v3) or npm-shrinkwrap.json. */
|
|
120
|
+
function parseNpmLock(lockPath, rootPkg) {
|
|
55
121
|
const lock = readJson(lockPath);
|
|
56
122
|
const lockfileVersion = lock.lockfileVersion || 1;
|
|
57
123
|
|
|
58
|
-
let components;
|
|
59
124
|
if (lock.packages) {
|
|
60
|
-
|
|
61
|
-
} else if (lock.dependencies) {
|
|
62
|
-
components = parseDependenciesField(lock.dependencies);
|
|
63
|
-
} else {
|
|
64
|
-
components = [];
|
|
125
|
+
return { lockfileVersion, ...parsePackagesField(lock.packages) };
|
|
65
126
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
lockfileVersion,
|
|
71
|
-
root,
|
|
72
|
-
components,
|
|
73
|
-
};
|
|
127
|
+
if (lock.dependencies) {
|
|
128
|
+
return { lockfileVersion, ...parseDependenciesField(lock.dependencies, rootPkg) };
|
|
129
|
+
}
|
|
130
|
+
return { lockfileVersion, components: [], rootDependsOn: [], complete: true };
|
|
74
131
|
}
|
|
75
132
|
|
|
76
133
|
/** Lockfile v2/v3 store everything under the `packages` map keyed by path. */
|
|
77
134
|
function parsePackagesField(packages) {
|
|
78
135
|
const components = [];
|
|
136
|
+
let complete = true;
|
|
137
|
+
|
|
138
|
+
// Node's module resolution: look in `<from>/node_modules/<dep>`, then walk
|
|
139
|
+
// up through every enclosing node_modules folder until the project root.
|
|
140
|
+
const resolve = (fromPath, depName) => {
|
|
141
|
+
let base = fromPath;
|
|
142
|
+
for (;;) {
|
|
143
|
+
const info = packages[`${base ? `${base}/` : ''}node_modules/${depName}`];
|
|
144
|
+
if (info) {
|
|
145
|
+
if (info.link) return LINK;
|
|
146
|
+
return componentKey(info.name || depName, info.version);
|
|
147
|
+
}
|
|
148
|
+
if (!base) return null;
|
|
149
|
+
const idx = base.lastIndexOf('/node_modules/');
|
|
150
|
+
base = idx === -1 ? '' : base.slice(0, idx);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const edges = (fromPath, info, includeDev) => {
|
|
155
|
+
const optional = new Set([
|
|
156
|
+
...Object.keys(info.optionalDependencies || {}),
|
|
157
|
+
...Object.keys(info.peerDependencies || {}),
|
|
158
|
+
]);
|
|
159
|
+
const names = new Set([
|
|
160
|
+
...Object.keys(info.dependencies || {}),
|
|
161
|
+
...optional,
|
|
162
|
+
...(includeDev ? Object.keys(info.devDependencies || {}) : []),
|
|
163
|
+
]);
|
|
164
|
+
const dependsOn = [];
|
|
165
|
+
for (const depName of names) {
|
|
166
|
+
const ref = resolve(fromPath, depName);
|
|
167
|
+
if (ref === LINK) continue;
|
|
168
|
+
if (ref) dependsOn.push(ref);
|
|
169
|
+
else if (!optional.has(depName)) complete = false;
|
|
170
|
+
}
|
|
171
|
+
return dependsOn;
|
|
172
|
+
};
|
|
79
173
|
|
|
80
174
|
for (const [pkgPath, info] of Object.entries(packages)) {
|
|
81
175
|
// The root project is stored under the empty key.
|
|
@@ -94,30 +188,81 @@ function parsePackagesField(packages) {
|
|
|
94
188
|
dev: Boolean(info.dev),
|
|
95
189
|
optional: Boolean(info.optional),
|
|
96
190
|
path: pkgPath,
|
|
191
|
+
dependsOn: edges(pkgPath, info, false),
|
|
97
192
|
}));
|
|
98
193
|
}
|
|
99
194
|
|
|
100
|
-
|
|
195
|
+
const rootDependsOn = packages[''] ? edges('', packages[''], true) : [];
|
|
196
|
+
return { components, rootDependsOn, complete };
|
|
101
197
|
}
|
|
102
198
|
|
|
103
|
-
/**
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
path: `node_modules/${name}`,
|
|
115
|
-
}));
|
|
116
|
-
if (info.dependencies) {
|
|
117
|
-
parseDependenciesField(info.dependencies, acc);
|
|
199
|
+
/**
|
|
200
|
+
* Lockfile v1 nests dependencies recursively under `dependencies`; each entry
|
|
201
|
+
* lists what it needs under `requires`, resolved against the nearest scope.
|
|
202
|
+
*/
|
|
203
|
+
function parseDependenciesField(dependencies, rootPkg) {
|
|
204
|
+
const components = [];
|
|
205
|
+
let complete = true;
|
|
206
|
+
|
|
207
|
+
const lookup = (scopes, name) => {
|
|
208
|
+
for (const scope of scopes) {
|
|
209
|
+
if (scope[name]) return scope[name];
|
|
118
210
|
}
|
|
211
|
+
return null;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const walk = (deps, outerScopes, prefix) => {
|
|
215
|
+
const scopes = [deps, ...outerScopes];
|
|
216
|
+
for (const [name, info] of Object.entries(deps)) {
|
|
217
|
+
const ownScopes = info.dependencies ? [info.dependencies, ...scopes] : scopes;
|
|
218
|
+
const dependsOn = [];
|
|
219
|
+
for (const req of Object.keys(info.requires || {})) {
|
|
220
|
+
const found = lookup(ownScopes, req);
|
|
221
|
+
if (found) dependsOn.push(componentKey(req, found.version));
|
|
222
|
+
else complete = false;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const pkgPath = `${prefix}node_modules/${name}`;
|
|
226
|
+
components.push(normalizeComponent({
|
|
227
|
+
name,
|
|
228
|
+
version: info.version,
|
|
229
|
+
resolved: info.resolved,
|
|
230
|
+
integrity: info.integrity,
|
|
231
|
+
license: info.license,
|
|
232
|
+
dev: Boolean(info.dev),
|
|
233
|
+
optional: Boolean(info.optional),
|
|
234
|
+
path: pkgPath,
|
|
235
|
+
dependsOn,
|
|
236
|
+
}));
|
|
237
|
+
if (info.dependencies) walk(info.dependencies, scopes, `${pkgPath}/`);
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
walk(dependencies, [], '');
|
|
241
|
+
|
|
242
|
+
const rootDependsOn = [];
|
|
243
|
+
for (const { name, optional } of rootDependencies(rootPkg)) {
|
|
244
|
+
const found = dependencies[name];
|
|
245
|
+
if (found) rootDependsOn.push(componentKey(name, found.version));
|
|
246
|
+
else if (!optional) complete = false;
|
|
119
247
|
}
|
|
120
|
-
return
|
|
248
|
+
return { components, rootDependsOn, complete };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Direct dependencies declared in the project's package.json, including
|
|
253
|
+
* dev and optional ones, with the range each was requested with.
|
|
254
|
+
*/
|
|
255
|
+
function rootDependencies(rootPkg) {
|
|
256
|
+
const list = [];
|
|
257
|
+
const add = (map, optional) => {
|
|
258
|
+
for (const [name, range] of Object.entries(map || {})) {
|
|
259
|
+
list.push({ name, range: String(range), optional });
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
add(rootPkg.dependencies, false);
|
|
263
|
+
add(rootPkg.devDependencies, false);
|
|
264
|
+
add(rootPkg.optionalDependencies, true);
|
|
265
|
+
return list;
|
|
121
266
|
}
|
|
122
267
|
|
|
123
268
|
function nameFromPath(pkgPath) {
|
|
@@ -126,6 +271,275 @@ function nameFromPath(pkgPath) {
|
|
|
126
271
|
return pkgPath.slice(idx + 'node_modules/'.length);
|
|
127
272
|
}
|
|
128
273
|
|
|
274
|
+
// --- Yarn ------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
/** Yarn: dispatches between the classic v1 text format and Berry's YAML. */
|
|
277
|
+
function parseYarnLock(lockPath, rootPkg) {
|
|
278
|
+
const content = fs.readFileSync(lockPath, 'utf8');
|
|
279
|
+
const isBerry = /^__metadata:/m.test(content) || /\n {2}resolution:/.test(content);
|
|
280
|
+
const entries = isBerry ? parseYarnBerry(content) : parseYarnClassic(content);
|
|
281
|
+
|
|
282
|
+
// Both formats key entries by the `name@range` descriptors that resolved
|
|
283
|
+
// to them, so edges are resolved by looking descriptors up.
|
|
284
|
+
const byDescriptor = new Map();
|
|
285
|
+
for (const entry of entries) {
|
|
286
|
+
for (const descriptor of entry.descriptors) {
|
|
287
|
+
byDescriptor.set(descriptor, entry.workspace ? LINK : componentKey(entry.name, entry.version));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
let complete = true;
|
|
292
|
+
const resolve = (name, range, optional) => {
|
|
293
|
+
const ref = byDescriptor.get(`${name}@${range}`) ||
|
|
294
|
+
(!range.includes(':') ? byDescriptor.get(`${name}@npm:${range}`) : undefined);
|
|
295
|
+
if (ref === LINK) return null;
|
|
296
|
+
if (ref) return ref;
|
|
297
|
+
// Local protocols never appear as third-party lockfile entries.
|
|
298
|
+
if (!optional && !/^(workspace|link|portal|file):/.test(range)) complete = false;
|
|
299
|
+
return null;
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const components = [];
|
|
303
|
+
for (const entry of entries) {
|
|
304
|
+
if (entry.workspace) continue; // first-party workspace, not a third-party component
|
|
305
|
+
const dependsOn = entry.dependencies
|
|
306
|
+
.map((d) => resolve(d.name, d.range, d.optional))
|
|
307
|
+
.filter(Boolean);
|
|
308
|
+
components.push(normalizeComponent({
|
|
309
|
+
name: entry.name,
|
|
310
|
+
version: entry.version,
|
|
311
|
+
resolved: entry.resolved,
|
|
312
|
+
integrity: entry.integrity,
|
|
313
|
+
path: `node_modules/${entry.name}`,
|
|
314
|
+
dependsOn,
|
|
315
|
+
}));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const rootDependsOn = rootDependencies(rootPkg)
|
|
319
|
+
.map((d) => resolve(d.name, d.range, d.optional))
|
|
320
|
+
.filter(Boolean);
|
|
321
|
+
|
|
322
|
+
return { lockfileVersion: isBerry ? 'berry' : 1, components, rootDependsOn, complete };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Yarn classic (v1) lockfiles use a custom, non-YAML text format: a block per
|
|
327
|
+
* resolved entry whose header lists one or more `name@range` descriptors.
|
|
328
|
+
*/
|
|
329
|
+
function parseYarnClassic(content) {
|
|
330
|
+
const entries = [];
|
|
331
|
+
const blocks = content.split(/\r?\n(?=\S)/);
|
|
332
|
+
|
|
333
|
+
for (const block of blocks) {
|
|
334
|
+
const lines = block.split(/\r?\n/);
|
|
335
|
+
const header = lines[0];
|
|
336
|
+
if (!header || header.startsWith('#') || !header.trim().endsWith(':')) continue;
|
|
337
|
+
|
|
338
|
+
const descriptors = header.replace(/:\s*$/, '').split(',').map((d) => stripQuotes(d.trim()));
|
|
339
|
+
const name = packageNameFromDescriptor(descriptors[0]);
|
|
340
|
+
if (!name) continue;
|
|
341
|
+
|
|
342
|
+
let version = null;
|
|
343
|
+
let resolved = null;
|
|
344
|
+
let integrity = null;
|
|
345
|
+
let section = null;
|
|
346
|
+
const dependencies = [];
|
|
347
|
+
for (const raw of lines.slice(1)) {
|
|
348
|
+
const line = raw.trim();
|
|
349
|
+
const indent = raw.length - raw.trimStart().length;
|
|
350
|
+
if (indent <= 2) {
|
|
351
|
+
section = /^(dependencies|optionalDependencies):$/.test(line) ? line.slice(0, -1) : null;
|
|
352
|
+
if (line.startsWith('version ')) version = stripQuotes(line.slice('version '.length).trim());
|
|
353
|
+
else if (line.startsWith('resolved ')) resolved = stripQuotes(line.slice('resolved '.length).trim());
|
|
354
|
+
else if (line.startsWith('integrity ')) integrity = stripQuotes(line.slice('integrity '.length).trim());
|
|
355
|
+
} else if (section) {
|
|
356
|
+
// `name "range"`, `"@scope/name" "range"` or `name range`.
|
|
357
|
+
const m = line.match(/^(?:"([^"]+)"|(\S+))\s+(?:"([^"]*)"|(\S+))$/);
|
|
358
|
+
if (m) {
|
|
359
|
+
dependencies.push({
|
|
360
|
+
name: m[1] || m[2],
|
|
361
|
+
range: m[3] !== undefined ? m[3] : m[4],
|
|
362
|
+
optional: section === 'optionalDependencies',
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (!version) continue;
|
|
368
|
+
|
|
369
|
+
entries.push({ descriptors, name, version, resolved, integrity, dependencies, workspace: false });
|
|
370
|
+
}
|
|
371
|
+
return entries;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Yarn Berry (v2+) lockfiles are YAML keyed by descriptor lists. */
|
|
375
|
+
function parseYarnBerry(content) {
|
|
376
|
+
const doc = parseYaml(content) || {};
|
|
377
|
+
const entries = [];
|
|
378
|
+
|
|
379
|
+
for (const [key, info] of Object.entries(doc)) {
|
|
380
|
+
if (key === '__metadata' || !info || typeof info !== 'object') continue;
|
|
381
|
+
if (!info.version) continue;
|
|
382
|
+
|
|
383
|
+
const descriptors = String(key).split(',').map((d) => stripQuotes(d.trim())).filter(Boolean);
|
|
384
|
+
const resolution = typeof info.resolution === 'string' ? info.resolution : '';
|
|
385
|
+
const name = packageNameFromDescriptor(resolution || descriptors[0]);
|
|
386
|
+
if (!name) continue;
|
|
387
|
+
|
|
388
|
+
const optionalMeta = info.dependenciesMeta && typeof info.dependenciesMeta === 'object' ? info.dependenciesMeta : {};
|
|
389
|
+
const dependencies = Object.entries(info.dependencies || {}).map(([depName, range]) => ({
|
|
390
|
+
name: depName,
|
|
391
|
+
range: String(range),
|
|
392
|
+
optional: Boolean(optionalMeta[depName] && optionalMeta[depName].optional),
|
|
393
|
+
}));
|
|
394
|
+
for (const [depName, range] of Object.entries(info.optionalDependencies || {})) {
|
|
395
|
+
dependencies.push({ name: depName, range: String(range), optional: true });
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
entries.push({
|
|
399
|
+
descriptors,
|
|
400
|
+
name,
|
|
401
|
+
version: String(info.version),
|
|
402
|
+
resolved: null,
|
|
403
|
+
// Berry's `checksum` is a cache key, not an npm SRI hash, so we omit it.
|
|
404
|
+
integrity: null,
|
|
405
|
+
dependencies,
|
|
406
|
+
workspace: /@workspace:/.test(resolution),
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
return entries;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// --- pnpm ------------------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
/** pnpm: `pnpm-lock.yaml` (lockfileVersion 5.x / 6.x / 9.x). */
|
|
415
|
+
function parsePnpmLock(lockPath) {
|
|
416
|
+
const doc = parseYaml(fs.readFileSync(lockPath, 'utf8')) || {};
|
|
417
|
+
const packages = doc.packages && typeof doc.packages === 'object' ? doc.packages : {};
|
|
418
|
+
// v9 moved the dependency edges from `packages` to `snapshots`.
|
|
419
|
+
const snapshots = doc.snapshots && typeof doc.snapshots === 'object' ? doc.snapshots : packages;
|
|
420
|
+
|
|
421
|
+
let complete = true;
|
|
422
|
+
const edges = (info) => {
|
|
423
|
+
const dependsOn = [];
|
|
424
|
+
if (!info || typeof info !== 'object') return dependsOn;
|
|
425
|
+
for (const [group, optional] of [['dependencies', false], ['devDependencies', false], ['optionalDependencies', true]]) {
|
|
426
|
+
for (const [depName, value] of Object.entries(info[group] || {})) {
|
|
427
|
+
const ref = pnpmDependencyRef(depName, value);
|
|
428
|
+
if (ref === LINK) continue;
|
|
429
|
+
if (ref) dependsOn.push(ref);
|
|
430
|
+
else if (!optional) complete = false;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return dependsOn;
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
const edgesByKey = new Map();
|
|
437
|
+
for (const [key, info] of Object.entries(snapshots)) {
|
|
438
|
+
const parsed = packageKeyToNameVersion(key);
|
|
439
|
+
if (!parsed) continue;
|
|
440
|
+
const ref = componentKey(parsed.name, parsed.version);
|
|
441
|
+
edgesByKey.set(ref, [...(edgesByKey.get(ref) || []), ...edges(info)]);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const components = [];
|
|
445
|
+
for (const [key, info] of Object.entries(packages)) {
|
|
446
|
+
const parsed = packageKeyToNameVersion(key);
|
|
447
|
+
if (!parsed) continue;
|
|
448
|
+
const resolution = info && typeof info === 'object' ? info.resolution : null;
|
|
449
|
+
const integrity = resolution && typeof resolution === 'object' ? resolution.integrity || null : null;
|
|
450
|
+
components.push(normalizeComponent({
|
|
451
|
+
name: parsed.name,
|
|
452
|
+
version: parsed.version,
|
|
453
|
+
integrity,
|
|
454
|
+
path: `node_modules/${parsed.name}`,
|
|
455
|
+
dependsOn: edgesByKey.get(componentKey(parsed.name, parsed.version)) || [],
|
|
456
|
+
}));
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Root edges: `importers['.']` (workspaces, and every project since v9) or
|
|
460
|
+
// the top-level maps of older single-project lockfiles.
|
|
461
|
+
const rootImporter = doc.importers && doc.importers['.'] ? doc.importers['.'] : doc;
|
|
462
|
+
const rootDependsOn = edges(rootImporter);
|
|
463
|
+
|
|
464
|
+
return { lockfileVersion: doc.lockfileVersion || null, components, rootDependsOn, complete };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Resolves one pnpm dependency value to a component key. Values are a version
|
|
469
|
+
* (`1.3.0`), a version with a peer suffix (`1.3.0(react@18.0.0)` or v5's
|
|
470
|
+
* `1.3.0_react@18.0.0`), an alias (`npm:other@1.0.0`, `/other@1.0.0`), an
|
|
471
|
+
* importer object `{ specifier, version }`, or a local `link:`.
|
|
472
|
+
*/
|
|
473
|
+
function pnpmDependencyRef(depName, value) {
|
|
474
|
+
let v = value && typeof value === 'object' ? value.version : value;
|
|
475
|
+
if (v === undefined || v === null) return null;
|
|
476
|
+
v = String(v);
|
|
477
|
+
if (/^(link|file|workspace):/.test(v)) return LINK;
|
|
478
|
+
|
|
479
|
+
v = v.replace(/^npm:/, '').split('(')[0];
|
|
480
|
+
if (/^\d/.test(v)) {
|
|
481
|
+
// Plain version; drop v5's `_peer@x` suffix (semver never contains `_`).
|
|
482
|
+
return componentKey(depName, v.split('_')[0]);
|
|
483
|
+
}
|
|
484
|
+
const aliased = packageKeyToNameVersion(v);
|
|
485
|
+
return aliased ? componentKey(aliased.name, aliased.version) : null;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Extracts a package name from a Yarn/pnpm descriptor or resolution string,
|
|
490
|
+
* e.g. `lodash@^4.17.21`, `@babel/core@npm:7.0.0`, `left-pad@1.3.0`.
|
|
491
|
+
*/
|
|
492
|
+
function packageNameFromDescriptor(descriptor) {
|
|
493
|
+
if (!descriptor) return null;
|
|
494
|
+
let d = stripQuotes(String(descriptor).trim());
|
|
495
|
+
// Drop a protocol/range part: take everything before the version separator.
|
|
496
|
+
const scoped = d.startsWith('@');
|
|
497
|
+
const at = d.indexOf('@', scoped ? 1 : 0);
|
|
498
|
+
return at === -1 ? d : d.slice(0, at);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Splits a pnpm package map key into { name, version }. Handles the historical
|
|
503
|
+
* variants: `/lodash/4.17.21`, `/lodash@4.17.21`, `lodash@4.17.21` and peer
|
|
504
|
+
* suffixes like `@babel/core@7.0.0(react@18.0.0)`.
|
|
505
|
+
*/
|
|
506
|
+
function packageKeyToNameVersion(key) {
|
|
507
|
+
let k = stripQuotes(String(key).trim());
|
|
508
|
+
if (!k) return null;
|
|
509
|
+
// Strip peer-dependency suffix: name@version(peer@x)(...).
|
|
510
|
+
const peerIdx = k.indexOf('(');
|
|
511
|
+
if (peerIdx !== -1) k = k.slice(0, peerIdx);
|
|
512
|
+
|
|
513
|
+
if (k.startsWith('/')) {
|
|
514
|
+
k = k.slice(1);
|
|
515
|
+
// v5 appends peers after an underscore: `/name/1.0.0_react@18.0.0`.
|
|
516
|
+
if (/^(@[^/]+\/)?[^/@]+\/\d/.test(k)) k = k.replace(/_[^/]*$/, '');
|
|
517
|
+
// v5 uses `/` as separator: name/version (scoped: @scope/name/version).
|
|
518
|
+
if (!k.includes('@', k.startsWith('@') ? 1 : 0) && k.includes('/')) {
|
|
519
|
+
const idx = k.lastIndexOf('/');
|
|
520
|
+
return { name: k.slice(0, idx), version: k.slice(idx + 1) };
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const scoped = k.startsWith('@');
|
|
525
|
+
const at = k.lastIndexOf('@');
|
|
526
|
+
if (at <= 0) return null;
|
|
527
|
+
if (scoped && at === 0) return null;
|
|
528
|
+
const name = k.slice(0, at);
|
|
529
|
+
const version = k.slice(at + 1);
|
|
530
|
+
if (!name || !version) return null;
|
|
531
|
+
return { name, version };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function stripQuotes(value) {
|
|
535
|
+
if (typeof value !== 'string') return value;
|
|
536
|
+
const t = value.trim();
|
|
537
|
+
if (t.length >= 2 && ((t[0] === '"' && t[t.length - 1] === '"') || (t[0] === "'" && t[t.length - 1] === "'"))) {
|
|
538
|
+
return t.slice(1, -1);
|
|
539
|
+
}
|
|
540
|
+
return t;
|
|
541
|
+
}
|
|
542
|
+
|
|
129
543
|
function normalizeComponent(raw) {
|
|
130
544
|
const { algorithm, hash } = parseIntegrity(raw.integrity);
|
|
131
545
|
return {
|
|
@@ -138,12 +552,22 @@ function normalizeComponent(raw) {
|
|
|
138
552
|
hashValue: hash,
|
|
139
553
|
license: raw.license || null,
|
|
140
554
|
scope: raw.dev ? 'optional' : 'required',
|
|
141
|
-
dev: raw.dev,
|
|
142
|
-
optional: raw.optional,
|
|
555
|
+
dev: Boolean(raw.dev),
|
|
556
|
+
optional: Boolean(raw.optional),
|
|
143
557
|
path: raw.path,
|
|
558
|
+
dependsOn: uniqueSorted(raw.dependsOn),
|
|
144
559
|
};
|
|
145
560
|
}
|
|
146
561
|
|
|
562
|
+
/** Stable identity of a component across the graph: `name@version`. */
|
|
563
|
+
function componentKey(name, version) {
|
|
564
|
+
return `${name}@${version}`;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function uniqueSorted(list) {
|
|
568
|
+
return [...new Set(list || [])].sort();
|
|
569
|
+
}
|
|
570
|
+
|
|
147
571
|
/**
|
|
148
572
|
* Parses an npm Subresource Integrity string ("sha512-<base64>")
|
|
149
573
|
* into a CycloneDX-friendly { algorithm, hash } pair.
|
|
@@ -165,6 +589,9 @@ function parseIntegrity(integrity) {
|
|
|
165
589
|
} catch {
|
|
166
590
|
hex = null;
|
|
167
591
|
}
|
|
592
|
+
// A digest of the wrong length is corrupt: better no hash than a bogus one.
|
|
593
|
+
const expectedHexLength = { 'SHA-512': 128, 'SHA-384': 96, 'SHA-256': 64, 'SHA-1': 40 }[algorithm];
|
|
594
|
+
if (!hex || hex.length !== expectedHexLength) return { algorithm: null, hash: null };
|
|
168
595
|
return { algorithm, hash: hex };
|
|
169
596
|
}
|
|
170
597
|
|
|
@@ -185,9 +612,13 @@ function buildPurl(name, version) {
|
|
|
185
612
|
function dedupe(components) {
|
|
186
613
|
const seen = new Map();
|
|
187
614
|
for (const comp of components) {
|
|
188
|
-
const key =
|
|
189
|
-
|
|
615
|
+
const key = componentKey(comp.name, comp.version);
|
|
616
|
+
const existing = seen.get(key);
|
|
617
|
+
if (!existing) {
|
|
190
618
|
seen.set(key, comp);
|
|
619
|
+
} else {
|
|
620
|
+
// The same name@version installed at several paths: merge its edges.
|
|
621
|
+
existing.dependsOn = uniqueSorted([...existing.dependsOn, ...comp.dependsOn]);
|
|
191
622
|
}
|
|
192
623
|
}
|
|
193
624
|
return Array.from(seen.values()).sort((a, b) =>
|
|
@@ -195,4 +626,53 @@ function dedupe(components) {
|
|
|
195
626
|
);
|
|
196
627
|
}
|
|
197
628
|
|
|
198
|
-
|
|
629
|
+
/**
|
|
630
|
+
* Builds a synthetic npm `package-lock.json` (v3) and matching `package.json`
|
|
631
|
+
* from a parsed lockfile, so that `npm audit --package-lock-only` can scan a
|
|
632
|
+
* Yarn/pnpm project without an npm lockfile. The exact versions resolved by
|
|
633
|
+
* Yarn/pnpm are preserved; duplicate versions of a package are nested so each
|
|
634
|
+
* is present in the tree the auditor inspects.
|
|
635
|
+
*
|
|
636
|
+
* @param {{ root: {name:string, version:string}, components: Array<object> }} parsed
|
|
637
|
+
* @returns {{ packageJson: object, packageLock: object }}
|
|
638
|
+
*/
|
|
639
|
+
function toNpmLockfile(parsed) {
|
|
640
|
+
const packages = { '': { name: parsed.root.name, version: parsed.root.version } };
|
|
641
|
+
const rootDeps = {};
|
|
642
|
+
const placed = new Map();
|
|
643
|
+
|
|
644
|
+
for (const c of parsed.components) {
|
|
645
|
+
if (!c.name || !c.version) continue;
|
|
646
|
+
const count = placed.get(c.name) || 0;
|
|
647
|
+
placed.set(c.name, count + 1);
|
|
648
|
+
|
|
649
|
+
let key;
|
|
650
|
+
if (count === 0) {
|
|
651
|
+
key = `node_modules/${c.name}`;
|
|
652
|
+
rootDeps[c.name] = c.version;
|
|
653
|
+
} else {
|
|
654
|
+
key = `node_modules/${c.name}` + `/node_modules/${c.name}`.repeat(count);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const entry = { version: c.version };
|
|
658
|
+
if (c.resolved) entry.resolved = c.resolved;
|
|
659
|
+
if (c.integrity) entry.integrity = c.integrity;
|
|
660
|
+
if (c.license) entry.license = c.license;
|
|
661
|
+
packages[key] = entry;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
packages[''].dependencies = rootDeps;
|
|
665
|
+
|
|
666
|
+
return {
|
|
667
|
+
packageJson: { name: parsed.root.name, version: parsed.root.version, dependencies: rootDeps },
|
|
668
|
+
packageLock: {
|
|
669
|
+
name: parsed.root.name,
|
|
670
|
+
version: parsed.root.version,
|
|
671
|
+
lockfileVersion: 3,
|
|
672
|
+
requires: true,
|
|
673
|
+
packages,
|
|
674
|
+
},
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
module.exports = { parseLockfile, buildPurl, parseIntegrity, toNpmLockfile, componentKey };
|
package/src/core/policy.js
CHANGED
|
@@ -15,6 +15,9 @@ const DEFAULT_POLICY = {
|
|
|
15
15
|
// Require an SBOM that satisfies the minimum TR-03183 elements.
|
|
16
16
|
requireSbom: true,
|
|
17
17
|
sbomFormat: 'cyclonedx', // cyclonedx | spdx
|
|
18
|
+
// Email or URL of the SBOM creator (TR-03183-2 §5.2.1). Defaults to the
|
|
19
|
+
// project's package.json author, homepage or repository.
|
|
20
|
+
sbomCreator: null,
|
|
18
21
|
// Audit only production dependencies (recommended for shipped products).
|
|
19
22
|
productionOnly: false,
|
|
20
23
|
vulnerabilities: {
|
|
@@ -44,7 +47,7 @@ function loadPolicy(projectRoot, explicitPath) {
|
|
|
44
47
|
? explicitPath
|
|
45
48
|
: path.join(projectRoot, explicitPath);
|
|
46
49
|
if (!exists(configPath)) {
|
|
47
|
-
throw new Error(`
|
|
50
|
+
throw new Error(`Configuration file not found: ${explicitPath}`);
|
|
48
51
|
}
|
|
49
52
|
} else {
|
|
50
53
|
for (const name of CONFIG_FILENAMES) {
|