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.
@@ -0,0 +1,62 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { parseLockfile } = require('./lockfile-parser');
6
+ const { readSbom } = require('./sbom-reader');
7
+ const { readManifests, hasManifests } = require('./manifest-reader');
8
+
9
+ /**
10
+ * Decides where the components of a project come from:
11
+ *
12
+ * - `sbom`: an input SBOM (`-i`), any ecosystem
13
+ * - `npm`: the npm/Yarn/pnpm lockfile (native, richest data)
14
+ * - `manifest`: requirements.txt / poetry.lock / go.mod / pom.xml… when
15
+ * there is no npm lockfile
16
+ * - `npm+manifest`: both in the same folder (e.g. a JS front end next to a
17
+ * Python service); vulnerabilities cover both
18
+ *
19
+ * `parsed` is what the scanner, license check, VEX and SARIF consume; it is
20
+ * null for plain npm projects, which keep their existing code paths.
21
+ *
22
+ * @param {string} projectRoot
23
+ * @param {{ input?: string|null }} [options]
24
+ */
25
+ function loadProject(projectRoot, { input = null } = {}) {
26
+ if (input) {
27
+ const parsed = readSbom(input);
28
+ return { mode: 'sbom', parsed, manifests: null };
29
+ }
30
+
31
+ const lock = parseLockfile(projectRoot);
32
+ const manifests = hasManifests(projectRoot) ? readManifests(projectRoot) : null;
33
+
34
+ if (lock.ok && manifests) {
35
+ // npm components keep their name-based lookups; manifest ones use purls.
36
+ const parsed = {
37
+ ...lock,
38
+ purlBased: false,
39
+ components: [...lock.components, ...manifests.components],
40
+ root: { ...lock.root, dependsOn: [...lock.root.dependsOn, ...manifests.root.dependsOn] },
41
+ };
42
+ return { mode: 'npm+manifest', parsed, manifests };
43
+ }
44
+ if (!lock.ok && manifests) return { mode: 'manifest', parsed: manifests, manifests };
45
+ return { mode: 'npm', parsed: null, manifests: null };
46
+ }
47
+
48
+ /**
49
+ * The nearest folder, walking up from `start`, with a package.json or a
50
+ * supported manifest of another ecosystem. Null when there is none.
51
+ */
52
+ function findAnyProjectRoot(start) {
53
+ let dir = path.resolve(start);
54
+ for (;;) {
55
+ if (fs.existsSync(path.join(dir, 'package.json')) || hasManifests(dir)) return dir;
56
+ const parent = path.dirname(dir);
57
+ if (parent === dir) return null;
58
+ dir = parent;
59
+ }
60
+ }
61
+
62
+ module.exports = { loadProject, findAnyProjectRoot };
@@ -5,6 +5,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
- add('sbom', 'A lockfile to generate the SBOM from', 'required', parsed.ok,
107
- parsed.ok ? `${parsed.lockfileName} (${parsed.components.length} components)` : parsed.error, 'CRA Annex I Part II (1)');
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 && r.relationshipType === 'DEPENDS_ON') {
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 (DEPENDS_ON) declared', stats.missingDependencies === 0);
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 });
@@ -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
- if (source === 'npm') return npmAuditSection(projectRoot, { production });
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
- const rootPkg = readJson(path.join(projectRoot, 'package.json')) || {};
41
- const components = production ? productionComponents(parsed, rootPkg) : parsed.components;
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
- return parsed.components.filter((c) => seen.has(componentKey(c.name, c.version)));
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. */
@@ -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
- return `Sources: OSV.dev (GitHub advisories + OpenSSF malicious packages) · ${kev} · ${section.scanned} components`;
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
- logger.detail(`Format: ${section.format} · Components: ${section.componentCount}`);
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 };