cra-audit 2.1.1 → 2.3.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 +24 -0
- package/README.md +64 -3
- package/package.json +1 -1
- package/src/cli.js +5 -1
- package/src/commands/audit.js +85 -69
- package/src/commands/readiness.js +17 -6
- package/src/commands/sbom.js +122 -111
- package/src/commands/vex.js +15 -7
- package/src/core/auditor.js +55 -5
- package/src/core/license-checker.js +10 -3
- package/src/core/manifest-reader.js +374 -0
- package/src/core/osv.js +30 -14
- package/src/core/project-source.js +62 -0
- package/src/core/readiness.js +26 -2
- package/src/core/sbom-reader.js +297 -0
- package/src/core/sbom-validator.js +9 -3
- package/src/core/vex.js +3 -3
- package/src/core/vuln-scanner.js +34 -11
- package/src/reporters/console.js +20 -2
- package/src/reporters/sarif.js +40 -14
|
@@ -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,7 @@ 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');
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Organisational CRA duties that can be checked from the repository itself:
|
|
@@ -102,9 +103,17 @@ function checkReadiness(projectRoot, { now = new Date() } = {}) {
|
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
// --- SBOM ---------------------------------------------------------------------
|
|
106
|
+
// npm projects: a lockfile cra-audit builds the SBOM from. Other ecosystems:
|
|
107
|
+
// an SBOM produced by their own tooling (Syft, cdxgen…) kept in the repo.
|
|
105
108
|
const parsed = parseLockfile(projectRoot);
|
|
106
|
-
|
|
107
|
-
|
|
109
|
+
const sbomFile = parsed.ok ? null : findSbomFile(projectRoot);
|
|
110
|
+
const manifests = parsed.ok || sbomFile ? null : readManifests(projectRoot);
|
|
111
|
+
add('sbom', 'Dependency inventory for the SBOM (lockfile, manifest or SBOM file)', 'required', parsed.ok || sbomFile || manifests,
|
|
112
|
+
parsed.ok ? `${parsed.lockfileName} (${parsed.components.length} components)`
|
|
113
|
+
: sbomFile ? `Found ${sbomFile}`
|
|
114
|
+
: manifests ? `${manifests.files.join(', ')} (${manifests.components.length} components)`
|
|
115
|
+
: 'No lockfile, supported manifest or SBOM file found.',
|
|
116
|
+
'CRA Annex I Part II (1)');
|
|
108
117
|
|
|
109
118
|
add('package-repository', 'package.json links the source repository', 'recommended', Boolean(repositoryUrl(pkg.repository)),
|
|
110
119
|
repositoryUrl(pkg.repository) || 'No "repository" field', 'TR-03183-2 §5.2.4');
|
|
@@ -216,6 +225,21 @@ function securityTxtTemplate({ advisories, repo, expires }) {
|
|
|
216
225
|
return lines.join('\n');
|
|
217
226
|
}
|
|
218
227
|
|
|
228
|
+
/** An SBOM file at the root or in sbom/: *.cdx.json, *.spdx.json, bom.json… */
|
|
229
|
+
function findSbomFile(root) {
|
|
230
|
+
for (const dir of ['', 'sbom']) {
|
|
231
|
+
let names = [];
|
|
232
|
+
try {
|
|
233
|
+
names = fs.readdirSync(path.join(root, dir));
|
|
234
|
+
} catch {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const match = names.find((n) => /(\.cdx\.json|\.spdx\.json|^bom\.json|^sbom\.json)$/i.test(n));
|
|
238
|
+
if (match) return dir ? `${dir}/${match}` : match;
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
219
243
|
function firstExisting(root, candidates) {
|
|
220
244
|
return candidates.find((rel) => fs.existsSync(path.join(root, rel))) || null;
|
|
221
245
|
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reads an existing SBOM (CycloneDX JSON, SPDX 2.x JSON or SPDX 3.0 JSON-LD)
|
|
8
|
+
* into the same shape parseLockfile() returns, so the vulnerability scan, the
|
|
9
|
+
* license check, the VEX and the SARIF work for any ecosystem: Python, Java,
|
|
10
|
+
* Go, Rust, .NET, PHP, Ruby… Each component is identified by its Package URL
|
|
11
|
+
* (purl), which OSV.dev resolves directly.
|
|
12
|
+
*
|
|
13
|
+
* Generate the SBOM with the ecosystem's tooling (Syft, cdxgen, Trivy,
|
|
14
|
+
* cyclonedx-py, the CycloneDX Maven/Gradle plugins…) and pass it with `-i`.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} file
|
|
17
|
+
* @returns {{
|
|
18
|
+
* ok: boolean, error?: string, manager: 'sbom', format: 'cyclonedx'|'spdx',
|
|
19
|
+
* lockfileName: string, sourceFile: string, document: object,
|
|
20
|
+
* root: { name: string, version: string, dependsOn: string[] },
|
|
21
|
+
* components: Array<object>, unidentified: number, dependencyGraphComplete: boolean
|
|
22
|
+
* }}
|
|
23
|
+
*/
|
|
24
|
+
function readSbom(file) {
|
|
25
|
+
let document;
|
|
26
|
+
try {
|
|
27
|
+
document = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
28
|
+
} catch (err) {
|
|
29
|
+
return fail(file, `Could not read the SBOM ${file}: ${err.code === 'ENOENT' ? 'file not found' : err.message}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let result;
|
|
33
|
+
if (document && (document.bomFormat === 'CycloneDX' || Array.isArray(document.components))) {
|
|
34
|
+
result = fromCycloneDx(document);
|
|
35
|
+
} else if (document && document.spdxVersion) {
|
|
36
|
+
result = fromSpdx2(document);
|
|
37
|
+
} else if (document && Array.isArray(document['@graph'])) {
|
|
38
|
+
result = fromSpdx3(document);
|
|
39
|
+
} else {
|
|
40
|
+
return fail(file, `${file} is not a CycloneDX or SPDX JSON document.`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Only components with a purl can be looked up; keep the count visible.
|
|
44
|
+
const identified = result.components.filter((c) => c.purl);
|
|
45
|
+
return {
|
|
46
|
+
ok: true,
|
|
47
|
+
manager: 'sbom',
|
|
48
|
+
purlBased: true,
|
|
49
|
+
format: result.format,
|
|
50
|
+
lockfileName: path.basename(file),
|
|
51
|
+
sourceFile: file,
|
|
52
|
+
document,
|
|
53
|
+
root: result.root,
|
|
54
|
+
components: dedupe(identified),
|
|
55
|
+
unidentified: result.components.length - identified.length,
|
|
56
|
+
dependencyGraphComplete: result.complete,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// --- CycloneDX ---------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
function fromCycloneDx(doc) {
|
|
63
|
+
const byRef = new Map();
|
|
64
|
+
const components = [];
|
|
65
|
+
const visit = (list) => {
|
|
66
|
+
for (const c of list || []) {
|
|
67
|
+
if (!c || typeof c !== 'object') continue;
|
|
68
|
+
if (c.type !== 'file' && c.type !== 'operating-system') {
|
|
69
|
+
const comp = component({
|
|
70
|
+
purl: c.purl,
|
|
71
|
+
name: c.group ? `${c.group}/${c.name}` : c.name,
|
|
72
|
+
version: c.version,
|
|
73
|
+
license: cdxLicense(c.licenses),
|
|
74
|
+
optional: c.scope === 'optional' || c.scope === 'excluded',
|
|
75
|
+
});
|
|
76
|
+
components.push(comp);
|
|
77
|
+
if (c['bom-ref']) byRef.set(c['bom-ref'], comp);
|
|
78
|
+
}
|
|
79
|
+
visit(c.components); // nested components (assemblies)
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
visit(doc.components);
|
|
83
|
+
|
|
84
|
+
const meta = (doc.metadata && doc.metadata.component) || {};
|
|
85
|
+
const root = rootInfo(meta.name, meta.version, meta.purl);
|
|
86
|
+
const rootRef = meta['bom-ref'];
|
|
87
|
+
|
|
88
|
+
let complete = Array.isArray(doc.dependencies) && doc.dependencies.length > 0;
|
|
89
|
+
for (const dep of doc.dependencies || []) {
|
|
90
|
+
const targets = (dep.dependsOn || []).map((ref) => byRef.get(ref)).filter(Boolean).map((c) => c.key);
|
|
91
|
+
if (dep.ref === rootRef) root.dependsOn.push(...targets);
|
|
92
|
+
const from = byRef.get(dep.ref);
|
|
93
|
+
if (from) from.dependsOn.push(...targets);
|
|
94
|
+
}
|
|
95
|
+
if ((doc.compositions || []).some((c) => c && c.aggregate && c.aggregate !== 'complete')) complete = false;
|
|
96
|
+
return { format: 'cyclonedx', components, root, complete };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function cdxLicense(licenses) {
|
|
100
|
+
if (!Array.isArray(licenses) || !licenses.length) return null;
|
|
101
|
+
const ids = licenses.map((l) => l.expression || (l.license && (l.license.id || l.license.name))).filter(Boolean);
|
|
102
|
+
return ids.length ? [...new Set(ids)].join(' AND ') : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- SPDX 2.x ----------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
function fromSpdx2(doc) {
|
|
108
|
+
const byId = new Map();
|
|
109
|
+
const components = [];
|
|
110
|
+
const described = new Set(doc.documentDescribes || []);
|
|
111
|
+
for (const r of doc.relationships || []) {
|
|
112
|
+
if (r.relationshipType === 'DESCRIBES' && r.spdxElementId === 'SPDXRef-DOCUMENT') described.add(r.relatedSpdxElement);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let rootPkg = null;
|
|
116
|
+
for (const pkg of doc.packages || []) {
|
|
117
|
+
const ref = (pkg.externalRefs || []).find((r) => r.referenceType === 'purl');
|
|
118
|
+
if (described.has(pkg.SPDXID) && !rootPkg) {
|
|
119
|
+
rootPkg = pkg;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const comp = component({
|
|
123
|
+
purl: ref && ref.referenceLocator,
|
|
124
|
+
name: pkg.name,
|
|
125
|
+
version: pkg.versionInfo,
|
|
126
|
+
license: spdxLicense(pkg.licenseConcluded) || spdxLicense(pkg.licenseDeclared),
|
|
127
|
+
});
|
|
128
|
+
components.push(comp);
|
|
129
|
+
byId.set(pkg.SPDXID, comp);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const rootRef = rootPkg && (rootPkg.externalRefs || []).find((r) => r.referenceType === 'purl');
|
|
133
|
+
const root = rootInfo((rootPkg && rootPkg.name) || doc.name, rootPkg && rootPkg.versionInfo, rootRef && rootRef.referenceLocator);
|
|
134
|
+
const rootId = rootPkg && rootPkg.SPDXID;
|
|
135
|
+
|
|
136
|
+
let edges = 0;
|
|
137
|
+
for (const r of doc.relationships || []) {
|
|
138
|
+
// A DEPENDS_ON B; B DEPENDENCY_OF A; A CONTAINS B.
|
|
139
|
+
let from = r.spdxElementId;
|
|
140
|
+
let to = r.relatedSpdxElement;
|
|
141
|
+
if (r.relationshipType === 'DEPENDENCY_OF') [from, to] = [to, from];
|
|
142
|
+
else if (!['DEPENDS_ON', 'CONTAINS', 'RUNTIME_DEPENDENCY_OF', 'DEV_DEPENDENCY_OF'].includes(r.relationshipType)) continue;
|
|
143
|
+
if (r.relationshipType.endsWith('_DEPENDENCY_OF')) [from, to] = [to, from];
|
|
144
|
+
const target = byId.get(to);
|
|
145
|
+
if (!target) continue;
|
|
146
|
+
edges++;
|
|
147
|
+
if (from === rootId) root.dependsOn.push(target.key);
|
|
148
|
+
else if (byId.has(from)) byId.get(from).dependsOn.push(target.key);
|
|
149
|
+
if (r.relationshipType === 'DEV_DEPENDENCY_OF') target.optional = true;
|
|
150
|
+
}
|
|
151
|
+
return { format: 'spdx', components, root, complete: edges > 0 };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function spdxLicense(value) {
|
|
155
|
+
return value && value !== 'NOASSERTION' && value !== 'NONE' ? value : null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// --- SPDX 3.0 (JSON-LD) ----------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
function fromSpdx3(doc) {
|
|
161
|
+
const byId = new Map();
|
|
162
|
+
const components = [];
|
|
163
|
+
let rootId = null;
|
|
164
|
+
|
|
165
|
+
for (const el of doc['@graph']) {
|
|
166
|
+
if (el.type === 'SpdxDocument' && Array.isArray(el.rootElement)) rootId = rootId || el.rootElement[0];
|
|
167
|
+
}
|
|
168
|
+
let root = rootInfo(doc.name);
|
|
169
|
+
|
|
170
|
+
for (const el of doc['@graph']) {
|
|
171
|
+
if (el.type !== 'software_Package') continue;
|
|
172
|
+
const id = el.spdxId || el['@id'];
|
|
173
|
+
if (id && id === rootId) {
|
|
174
|
+
root = rootInfo(el.name || root.name, el.software_packageVersion, el.software_packageUrl);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const comp = component({
|
|
178
|
+
purl: el.software_packageUrl,
|
|
179
|
+
name: el.name,
|
|
180
|
+
version: el.software_packageVersion,
|
|
181
|
+
license: null,
|
|
182
|
+
});
|
|
183
|
+
components.push(comp);
|
|
184
|
+
if (id) byId.set(id, comp);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let edges = 0;
|
|
188
|
+
for (const el of doc['@graph']) {
|
|
189
|
+
if (el.type !== 'Relationship' || !['dependsOn', 'contains'].includes(el.relationshipType)) continue;
|
|
190
|
+
for (const to of el.to || []) {
|
|
191
|
+
const target = byId.get(to);
|
|
192
|
+
if (!target) continue;
|
|
193
|
+
edges++;
|
|
194
|
+
if (el.from === rootId) root.dependsOn.push(target.key);
|
|
195
|
+
else if (byId.has(el.from)) byId.get(el.from).dependsOn.push(target.key);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return { format: 'spdx', components, root, complete: edges > 0 };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The product the SBOM describes. Without a purl in the SBOM it gets a
|
|
203
|
+
* `pkg:generic` one, so VEX statements still name the product unambiguously.
|
|
204
|
+
*/
|
|
205
|
+
function rootInfo(name, version, purl) {
|
|
206
|
+
const n = name || 'unknown';
|
|
207
|
+
const v = version || '0.0.0';
|
|
208
|
+
const parsed = parsePurl(purl);
|
|
209
|
+
return { name: n, version: v, purl: parsed ? parsed.canonical : `pkg:generic/${encodeURIComponent(n)}@${encodeURIComponent(v)}`, dependsOn: [] };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// --- Components -------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Normalizes an SBOM component. The name and version come from the purl when
|
|
216
|
+
* there is one (it is what OSV understands), else from the SBOM fields.
|
|
217
|
+
*/
|
|
218
|
+
function component({ purl, name, version, license, optional = false }) {
|
|
219
|
+
const parsed = parsePurl(purl);
|
|
220
|
+
const clean = parsed ? parsed.canonical : null;
|
|
221
|
+
const displayName = parsed ? parsed.displayName : name || 'unknown';
|
|
222
|
+
const ver = (parsed && parsed.version) || version || null;
|
|
223
|
+
return {
|
|
224
|
+
key: clean || `${displayName}@${ver}`,
|
|
225
|
+
name: displayName,
|
|
226
|
+
version: ver,
|
|
227
|
+
purl: clean,
|
|
228
|
+
ecosystem: parsed ? parsed.type : null,
|
|
229
|
+
osvName: parsed ? parsed.osvName : displayName,
|
|
230
|
+
license: license || null,
|
|
231
|
+
scope: optional ? 'optional' : 'required',
|
|
232
|
+
dev: optional,
|
|
233
|
+
optional,
|
|
234
|
+
dependsOn: [],
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Minimal Package URL parser (https://github.com/package-url/purl-spec).
|
|
240
|
+
* Returns the canonical purl without qualifiers/subpath and the name in the
|
|
241
|
+
* form each ecosystem uses (OSV naming): Maven `group:artifact`, npm
|
|
242
|
+
* `@scope/name`, Go/Composer `namespace/name`.
|
|
243
|
+
*/
|
|
244
|
+
function parsePurl(purl) {
|
|
245
|
+
if (typeof purl !== 'string' || !purl.startsWith('pkg:')) return null;
|
|
246
|
+
let rest = purl.slice(4).split('#')[0].split('?')[0];
|
|
247
|
+
// The version separator is the last `@` after the last `/` (an unencoded
|
|
248
|
+
// npm scope, `pkg:npm/@scope/name`, is not a version).
|
|
249
|
+
const at = rest.lastIndexOf('@');
|
|
250
|
+
let version = null;
|
|
251
|
+
let rawVersion = '';
|
|
252
|
+
if (at > rest.lastIndexOf('/')) {
|
|
253
|
+
rawVersion = rest.slice(at + 1);
|
|
254
|
+
version = decodeURIComponent(rawVersion);
|
|
255
|
+
rest = rest.slice(0, at);
|
|
256
|
+
}
|
|
257
|
+
const parts = rest.split('/');
|
|
258
|
+
const type = parts.shift().toLowerCase();
|
|
259
|
+
if (!type || !parts.length) return null;
|
|
260
|
+
const name = decodeURIComponent(parts.pop());
|
|
261
|
+
const namespace = parts.map(decodeURIComponent).join('/');
|
|
262
|
+
|
|
263
|
+
let osvName = name;
|
|
264
|
+
if (type === 'maven') osvName = namespace ? `${namespace}:${name}` : name;
|
|
265
|
+
else if (namespace) osvName = `${namespace}/${name}`;
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
type,
|
|
269
|
+
namespace,
|
|
270
|
+
name,
|
|
271
|
+
version,
|
|
272
|
+
osvName,
|
|
273
|
+
displayName: osvName,
|
|
274
|
+
canonical: `pkg:${rest}${rawVersion ? `@${rawVersion}` : ''}`,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function dedupe(components) {
|
|
279
|
+
const seen = new Map();
|
|
280
|
+
for (const c of components) {
|
|
281
|
+
const existing = seen.get(c.key);
|
|
282
|
+
if (!existing) seen.set(c.key, c);
|
|
283
|
+
else existing.dependsOn.push(...c.dependsOn);
|
|
284
|
+
}
|
|
285
|
+
for (const c of seen.values()) c.dependsOn = [...new Set(c.dependsOn)].sort();
|
|
286
|
+
return [...seen.values()].sort((a, b) => a.key.localeCompare(b.key));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function fail(file, error) {
|
|
290
|
+
return {
|
|
291
|
+
ok: false, error, manager: 'sbom', format: null, lockfileName: path.basename(file), sourceFile: file,
|
|
292
|
+
document: null, root: rootInfo(), components: [], unidentified: 0,
|
|
293
|
+
dependencyGraphComplete: false,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
module.exports = { readSbom, parsePurl };
|
|
@@ -118,9 +118,15 @@ function validateSpdx(doc) {
|
|
|
118
118
|
pushCheck(checks, 'components', 'Contains at least one package', packages.length > 0);
|
|
119
119
|
|
|
120
120
|
const relationships = toArray(doc.relationships);
|
|
121
|
+
// Dependency edges as written by SPDX tools (Syft uses CONTAINS and
|
|
122
|
+
// DEPENDENCY_OF); the described package is the product, not a component.
|
|
123
|
+
const graphTypes = /^(DEPENDS_ON|CONTAINS|DEPENDENCY_OF|[A-Z_]+_DEPENDENCY_OF)$/;
|
|
124
|
+
const described = new Set(toArray(doc.documentDescribes));
|
|
121
125
|
const inGraph = new Set();
|
|
122
126
|
for (const r of relationships) {
|
|
123
|
-
if (r
|
|
127
|
+
if (!r) continue;
|
|
128
|
+
if (r.relationshipType === 'DESCRIBES' && r.spdxElementId === 'SPDXRef-DOCUMENT') described.add(r.relatedSpdxElement);
|
|
129
|
+
if (graphTypes.test(r.relationshipType)) {
|
|
124
130
|
inGraph.add(r.spdxElementId);
|
|
125
131
|
inGraph.add(r.relatedSpdxElement);
|
|
126
132
|
}
|
|
@@ -138,7 +144,7 @@ function validateSpdx(doc) {
|
|
|
138
144
|
};
|
|
139
145
|
for (const pkg of packages) {
|
|
140
146
|
const label = pkg.name || pkg.SPDXID || 'unknown';
|
|
141
|
-
if (pkg.SPDXID === 'SPDXRef-Package-root') continue;
|
|
147
|
+
if (pkg.SPDXID === 'SPDXRef-Package-root' || described.has(pkg.SPDXID)) continue;
|
|
142
148
|
if (!pkg.versionInfo || pkg.versionInfo === 'NOASSERTION') { stats.missingVersion++; issues.push(`Package without a version: ${label}`); }
|
|
143
149
|
if (!isAssertion(pkg.originator) && !isAssertion(pkg.supplier)) { stats.missingCreator++; issues.push(`Package without an originator: ${label}`); }
|
|
144
150
|
if (!pkg.packageFileName) { stats.missingFilename++; issues.push(`Package without a filename: ${label}`); }
|
|
@@ -158,7 +164,7 @@ function validateSpdx(doc) {
|
|
|
158
164
|
pushCheck(checks, 'componentHashes', 'All packages have a SHA-512 checksum', stats.missingHash === 0);
|
|
159
165
|
pushCheck(checks, 'componentLicenses', 'All packages have a license', stats.missingLicense === 0);
|
|
160
166
|
pushCheck(checks, 'componentIdentifiers', 'All packages have a purl', stats.missingPurl === 0);
|
|
161
|
-
pushCheck(checks, 'dependencyGraph', 'Dependency relationships
|
|
167
|
+
pushCheck(checks, 'dependencyGraph', 'Dependency relationships declared for every package', stats.missingDependencies === 0);
|
|
162
168
|
|
|
163
169
|
return summarize('spdx', checks, issues, stats);
|
|
164
170
|
}
|
package/src/core/vex.js
CHANGED
|
@@ -106,18 +106,18 @@ function acceptance(finding, policy) {
|
|
|
106
106
|
/**
|
|
107
107
|
* Builds a VEX document for the vulnerability findings of an audit.
|
|
108
108
|
*
|
|
109
|
-
* @param {{ name: string, version: string }} product
|
|
109
|
+
* @param {{ name: string, version: string, purl?: string }} product
|
|
110
110
|
* @param {object} vulnSection Result of scanVulnerabilities().
|
|
111
111
|
* @param {object} policy
|
|
112
112
|
* @param {{ format?: 'cyclonedx'|'openvex', author?: string|null }} [options]
|
|
113
113
|
*/
|
|
114
114
|
function buildVex(product, vulnSection, policy, { format = 'cyclonedx', author = null } = {}) {
|
|
115
115
|
const entries = normalizeAllowlist(policy);
|
|
116
|
-
const productPurl = buildPurl(product.name, product.version);
|
|
116
|
+
const productPurl = product.purl || buildPurl(product.name, product.version);
|
|
117
117
|
const statements = [];
|
|
118
118
|
|
|
119
119
|
for (const finding of vulnSection.vulnerabilities || []) {
|
|
120
|
-
const purl = buildPurl(finding.name, finding.version);
|
|
120
|
+
const purl = finding.purl || buildPurl(finding.name, finding.version);
|
|
121
121
|
for (const source of finding.sources) {
|
|
122
122
|
const assessment = finding.malicious ? null : assessmentFor(finding, source, entries);
|
|
123
123
|
statements.push({ finding, source, purl, assessment });
|
package/src/core/vuln-scanner.js
CHANGED
|
@@ -5,7 +5,7 @@ const os = require('node:os');
|
|
|
5
5
|
const path = require('node:path');
|
|
6
6
|
const { runJson } = require('../utils/exec');
|
|
7
7
|
const { exists, readJson } = require('../utils/fs');
|
|
8
|
-
const { parseLockfile, toNpmLockfile, componentKey } = require('./lockfile-parser');
|
|
8
|
+
const { parseLockfile, toNpmLockfile, componentKey, buildPurl } = require('./lockfile-parser');
|
|
9
9
|
const {
|
|
10
10
|
queryOsv, osvSeverity, cvssScore, isMalicious, fixedVersion, compareSemver,
|
|
11
11
|
} = require('./osv');
|
|
@@ -25,20 +25,34 @@ const SEVERITY_ORDER = ['info', 'low', 'moderate', 'high', 'critical'];
|
|
|
25
25
|
* CVEs found are cross-checked with the CISA KEV catalogue.
|
|
26
26
|
* - `npm`: `npm audit`. Also the fallback when OSV.dev is unreachable.
|
|
27
27
|
*
|
|
28
|
+
* Other ecosystems are scanned by passing, as `parsed`, the result of
|
|
29
|
+
* readSbom() (an input SBOM) or readManifests() (requirements.txt, go.mod,
|
|
30
|
+
* pom.xml…); their components are looked up in OSV.dev by Package URL.
|
|
31
|
+
*
|
|
28
32
|
* @param {string} projectRoot
|
|
29
|
-
* @param {{ production?: boolean, source?: 'osv'|'npm', kev?: boolean }} [options]
|
|
33
|
+
* @param {{ production?: boolean, source?: 'osv'|'npm', kev?: boolean, parsed?: object }} [options]
|
|
30
34
|
* @returns {Promise<object>}
|
|
31
35
|
*/
|
|
32
|
-
async function scanVulnerabilities(projectRoot, { production = false, source = 'osv', kev = true } = {}) {
|
|
33
|
-
|
|
36
|
+
async function scanVulnerabilities(projectRoot, { production = false, source = 'osv', kev = true, parsed: given } = {}) {
|
|
37
|
+
const fromSbom = Boolean(given && given.purlBased);
|
|
38
|
+
if (source === 'npm') {
|
|
39
|
+
if (fromSbom) {
|
|
40
|
+
return { ok: false, error: '`npm audit` only scans npm projects; use the default OSV source.', counts: emptyCounts(), vulnerabilities: [] };
|
|
41
|
+
}
|
|
42
|
+
return npmAuditSection(projectRoot, { production });
|
|
43
|
+
}
|
|
34
44
|
|
|
35
|
-
const parsed = parseLockfile(projectRoot);
|
|
45
|
+
const parsed = given || parseLockfile(projectRoot);
|
|
36
46
|
if (!parsed.ok) {
|
|
37
47
|
return { ok: false, error: parsed.error, counts: emptyCounts(), vulnerabilities: [] };
|
|
38
48
|
}
|
|
39
49
|
|
|
40
|
-
|
|
41
|
-
|
|
50
|
+
let components = parsed.components;
|
|
51
|
+
if (production) {
|
|
52
|
+
components = fromSbom
|
|
53
|
+
? components.filter((c) => !c.optional)
|
|
54
|
+
: productionComponents(parsed, readJson(path.join(projectRoot, 'package.json')) || {});
|
|
55
|
+
}
|
|
42
56
|
const scannable = components.filter((c) => c.name && c.version);
|
|
43
57
|
|
|
44
58
|
const [osv, catalogue] = await Promise.all([
|
|
@@ -47,6 +61,7 @@ async function scanVulnerabilities(projectRoot, { production = false, source = '
|
|
|
47
61
|
]);
|
|
48
62
|
|
|
49
63
|
if (!osv.ok) {
|
|
64
|
+
if (fromSbom) return { ok: false, error: osv.error, counts: emptyCounts(), vulnerabilities: [] };
|
|
50
65
|
const fallback = npmAuditSection(projectRoot, { production });
|
|
51
66
|
fallback.warnings = [`${osv.error}; fell back to \`npm audit\` (no malicious-package or KEV check).`];
|
|
52
67
|
return fallback;
|
|
@@ -55,7 +70,7 @@ async function scanVulnerabilities(projectRoot, { production = false, source = '
|
|
|
55
70
|
const direct = new Set(parsed.root.dependsOn);
|
|
56
71
|
const vulnerabilities = [];
|
|
57
72
|
for (const c of scannable) {
|
|
58
|
-
const key = componentKey(c.name, c.version);
|
|
73
|
+
const key = c.key || componentKey(c.name, c.version);
|
|
59
74
|
const records = osv.byComponent.get(key);
|
|
60
75
|
if (!records || !records.length) continue;
|
|
61
76
|
vulnerabilities.push(toFinding(c, records, direct.has(key), catalogue));
|
|
@@ -73,6 +88,10 @@ async function scanVulnerabilities(projectRoot, { production = false, source = '
|
|
|
73
88
|
ok: true,
|
|
74
89
|
source: 'osv',
|
|
75
90
|
scanned: scannable.length,
|
|
91
|
+
input: fromSbom
|
|
92
|
+
? { file: parsed.sourceFile || parsed.lockfileName, format: parsed.format, unidentified: parsed.unidentified, notes: parsed.notes || [] }
|
|
93
|
+
: null,
|
|
94
|
+
ecosystems: [...new Set(scannable.map((c) => c.ecosystem || 'npm'))].sort(),
|
|
76
95
|
kev: catalogue
|
|
77
96
|
? (catalogue.ok
|
|
78
97
|
? { checked: true, catalogVersion: catalogue.catalogVersion, entries: catalogue.count }
|
|
@@ -100,7 +119,7 @@ function toFinding(component, records, isDirect, catalogue) {
|
|
|
100
119
|
cvss: cvssScore(record),
|
|
101
120
|
cwe: (record.database_specific && record.database_specific.cwe_ids) || [],
|
|
102
121
|
malicious: isMalicious(record),
|
|
103
|
-
fixed: fixedVersion(record, component.name, component.version),
|
|
122
|
+
fixed: fixedVersion(record, component.osvName || component.name, component.version),
|
|
104
123
|
kev: kevEntry,
|
|
105
124
|
};
|
|
106
125
|
});
|
|
@@ -116,6 +135,9 @@ function toFinding(component, records, isDirect, catalogue) {
|
|
|
116
135
|
return {
|
|
117
136
|
name: component.name,
|
|
118
137
|
version: component.version,
|
|
138
|
+
purl: component.purl || buildPurl(component.name, component.version),
|
|
139
|
+
ecosystem: component.ecosystem || 'npm',
|
|
140
|
+
location: component.location || null,
|
|
119
141
|
severity,
|
|
120
142
|
direct: isDirect,
|
|
121
143
|
range: null,
|
|
@@ -150,7 +172,8 @@ function productionComponents(parsed, rootPkg) {
|
|
|
150
172
|
if (!seen.has(dep)) { seen.add(dep); queue.push(dep); }
|
|
151
173
|
}
|
|
152
174
|
}
|
|
153
|
-
|
|
175
|
+
// Components of other ecosystems (npm+manifest projects) use their own scope.
|
|
176
|
+
return parsed.components.filter((c) => (c.ecosystem ? !c.optional : seen.has(componentKey(c.name, c.version))));
|
|
154
177
|
}
|
|
155
178
|
|
|
156
179
|
function severityRank(severity) {
|
|
@@ -158,7 +181,7 @@ function severityRank(severity) {
|
|
|
158
181
|
}
|
|
159
182
|
|
|
160
183
|
function major(version) {
|
|
161
|
-
return parseInt(String(version).split('.')[0], 10);
|
|
184
|
+
return parseInt(String(version).replace(/^v/i, '').split('.')[0], 10);
|
|
162
185
|
}
|
|
163
186
|
|
|
164
187
|
/** `npm audit` source, used on request or as the OSV fallback. */
|
package/src/reporters/console.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const path = require('node:path');
|
|
3
4
|
const { logger, color } = require('../utils/logger');
|
|
4
5
|
|
|
5
6
|
const SEVERITY_COLORS = {
|
|
@@ -37,6 +38,7 @@ function renderVulnerabilities(section) {
|
|
|
37
38
|
}
|
|
38
39
|
logger.detail(describeSources(section));
|
|
39
40
|
for (const warning of section.warnings || []) logger.warn(warning);
|
|
41
|
+
for (const note of (section.input && section.input.notes) || []) logger.detail(note);
|
|
40
42
|
|
|
41
43
|
const c = section.counts;
|
|
42
44
|
if (c.total === 0) {
|
|
@@ -87,7 +89,11 @@ function describeSources(section) {
|
|
|
87
89
|
const kev = section.kev && section.kev.checked
|
|
88
90
|
? `CISA KEV ${section.kev.catalogVersion || ''} (${section.kev.entries} entries)`.replace(' ', ' ')
|
|
89
91
|
: `CISA KEV not checked (${(section.kev && section.kev.error) || 'unavailable'})`;
|
|
90
|
-
|
|
92
|
+
const scope = section.input
|
|
93
|
+
? `${section.scanned} components from ${section.input.format === 'manifest' ? section.input.file : shortPath(section.input.file)} (${(section.ecosystems || []).join(', ')})` +
|
|
94
|
+
(section.input.unidentified ? ` · ${section.input.unidentified} ${section.input.format === 'manifest' ? 'not pinned, skipped' : 'without purl skipped'}` : '')
|
|
95
|
+
: `${section.scanned} components${(section.ecosystems || []).length > 1 ? ` (${section.ecosystems.join(', ')})` : ''}`;
|
|
96
|
+
return `Sources: OSV.dev (GitHub advisories + OpenSSF malicious packages) · ${kev} · ${scope}`;
|
|
91
97
|
}
|
|
92
98
|
|
|
93
99
|
/** CRA Art. 14 reporting clock, shown when a KEV-listed vulnerability is found. */
|
|
@@ -116,7 +122,13 @@ function renderSbom(section) {
|
|
|
116
122
|
logger.error(section.error);
|
|
117
123
|
return;
|
|
118
124
|
}
|
|
119
|
-
|
|
125
|
+
if (section.manifest) {
|
|
126
|
+
logger.detail(`Components read from ${section.files.join(', ')} (${section.ecosystems.join(', ')}): ${section.componentCount}`);
|
|
127
|
+
logger.warn('cra-audit builds TR-03183 SBOMs from npm lockfiles. For these ecosystems, commit the SBOM your');
|
|
128
|
+
logger.detail('build tooling produces (Syft, cdxgen, cyclonedx-maven-plugin, cyclonedx-py…) and audit it with -i.');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
logger.detail(`${section.input ? `Input SBOM: ${shortPath(section.input)} · ` : ''}Format: ${section.format} · Components: ${section.componentCount}`);
|
|
120
132
|
|
|
121
133
|
if (section.validation) {
|
|
122
134
|
const v = section.validation;
|
|
@@ -176,4 +188,10 @@ function renderSummary(report) {
|
|
|
176
188
|
logger.log('');
|
|
177
189
|
}
|
|
178
190
|
|
|
191
|
+
/** A path relative to the working directory when it is inside it. */
|
|
192
|
+
function shortPath(file) {
|
|
193
|
+
const rel = path.relative(process.cwd(), file);
|
|
194
|
+
return rel && !rel.startsWith('..') && !path.isAbsolute(rel) ? rel : file;
|
|
195
|
+
}
|
|
196
|
+
|
|
179
197
|
module.exports = { reportConsole };
|