cra-audit 2.1.1 → 2.2.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 CHANGED
@@ -8,6 +8,20 @@ Releases are automated: pushing a `vX.Y.Z` tag publishes the package to npm
8
8
  (with provenance) and creates the GitHub Release from the matching section
9
9
  below, so add the section before running `npm version`.
10
10
 
11
+ ## [2.2.0] — Any language through its SBOM
12
+
13
+ ### Highlights
14
+
15
+ - **Input SBOM** (`-i/--input` for `audit`, `vuln`, `licenses` and `vex`; `sbom-input` in the GitHub Action): audit a CycloneDX JSON, SPDX 2.x JSON or SPDX 3.0 JSON-LD SBOM from Syft, cdxgen, Trivy or the CycloneDX build plugins instead of the npm lockfile. Components are looked up in OSV.dev by Package URL, so **Python, Java, Go, Rust, .NET, PHP, Ruby…** projects get known vulnerabilities, malicious packages, CISA KEV with the Art. 14 clock, licenses, VEX and SARIF. No package.json needed.
16
+ - SARIF alerts point at the purl's line in the SBOM; VEX statements use the components' real purls and the product's purl (or `pkg:generic`).
17
+ - The input SBOM's TR-03183-2 gaps are reported as a warning in the audit (`sbom check -i` still fails on them).
18
+ - `readiness` works in any repository (stops at the nearest package.json or .git) and accepts a committed SBOM file for non-npm projects.
19
+
20
+ ### Fixes
21
+
22
+ - The SPDX validator no longer counts the described product as a component and accepts `CONTAINS` / `DEPENDENCY_OF` edges (as written by Syft).
23
+ - Fixed versions resolve with each ecosystem's naming (Maven `group:artifact`, PyPI normalisation) and Go `v`-prefixed versions.
24
+
11
25
  ## [2.1.1] — GitHub Marketplace
12
26
 
13
27
  - The GitHub Action is published in the Marketplace as **CRA Compliance Audit** (the name "CRA Audit" is taken by a GitHub organization). Usage is unchanged: `uses: migohe14/cra-audit@v2`.
package/README.md CHANGED
@@ -2,10 +2,10 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/cra-audit)](https://www.npmjs.com/package/cra-audit) [![CI](https://github.com/migohe14/cra-audit/actions/workflows/ci.yml/badge.svg)](https://github.com/migohe14/cra-audit/actions/workflows/ci.yml) [![license](https://img.shields.io/npm/l/cra-audit)](LICENSE)
4
4
 
5
- > Compliance audit for the **Cyber Resilience Act** (Regulation EU 2024/2847) and the **BSI TR-03183** technical guideline, for npm projects.
5
+ > Compliance audit for the **Cyber Resilience Act** (Regulation EU 2024/2847) and the **BSI TR-03183** technical guideline — native for npm, Yarn and pnpm, and for **any language** through its SBOM.
6
6
 
7
7
  `cra-audit` audits the installed dependencies of an npm project and checks the key requirements that the CRA imposes on "products with digital elements". It runs directly with `npx`, **without installation**, and has **no production dependencies** to minimize its own supply-chain surface.
8
- It reads the project lockfile natively, so it works with **npm** (`package-lock.json` / `npm-shrinkwrap.json`), **Yarn** (classic v1 and Berry v2+ `yarn.lock`) and **pnpm** (`pnpm-lock.yaml`) — auditing the exact versions each package manager pinned.
8
+ It reads the project lockfile natively, so it works with **npm** (`package-lock.json` / `npm-shrinkwrap.json`), **Yarn** (classic v1 and Berry v2+ `yarn.lock`) and **pnpm** (`pnpm-lock.yaml`) — auditing the exact versions each package manager pinned. For **Python, Java, Go, Rust, .NET, PHP, Ruby…** pass the SBOM produced by your usual tooling with `-i` ([details](#8-any-language-input-sbom--i)).
9
9
  ```bash
10
10
  # Run ALL the law's checks (Vulnerabilities + Licenses + SBOM)
11
11
  npx cra-audit
@@ -151,6 +151,37 @@ npx cra-audit readiness --init # creates SECURITY.md and .well-known/security.
151
151
 
152
152
  `--init` prefills the templates from `package.json` (GitHub private vulnerability reporting link, supported major version, a coordinated disclosure process and the Art. 14 24 h / 72 h / 14 days reporting commitments); fill in the `TODO` placeholders and run it again.
153
153
 
154
+ ### 8. Any language: input SBOM (`-i`)
155
+
156
+ `audit`, `vuln`, `licenses` and `vex` accept an existing **CycloneDX JSON**, **SPDX 2.x JSON** or **SPDX 3.0 JSON-LD** SBOM instead of the npm lockfile. Every component is looked up in OSV.dev by its **Package URL**, so you get the same checks — known vulnerabilities, **malicious packages**, **CISA KEV** with the Art. 14 clock, licenses, VEX and SARIF — for any ecosystem OSV covers (Maven, PyPI, Go, crates.io, NuGet, Packagist, RubyGems, Hex, Pub, npm…).
157
+
158
+ ```bash
159
+ # Generate the SBOM with the tool of your ecosystem…
160
+ syft dir:. -o cyclonedx-json=sbom.cdx.json # any language / container images
161
+ cdxgen -o sbom.cdx.json . # multi-language
162
+ mvn org.cyclonedx:cyclonedx-maven-plugin:makeAggregateBom # Maven → target/bom.json
163
+ cyclonedx-py environment -o sbom.cdx.json # Python
164
+
165
+ # …and audit it (no package.json needed)
166
+ npx cra-audit -i sbom.cdx.json
167
+ npx cra-audit vex -i sbom.cdx.json -o vex.cdx.json
168
+ npx cra-audit -i sbom.cdx.json --sarif cra-audit.sarif
169
+ ```
170
+
171
+ ```text
172
+ Sources: OSV.dev … · CISA KEV (1721 entries) · 5 components from sbom.spdx.json (cargo, golang, maven, nuget) · 1 without purl skipped
173
+ CRITICAL [KEV] org.apache.logging.log4j:log4j-core@2.14.1 (fix: org.apache.logging.log4j:log4j-core@2.25.4)
174
+ GHSA-jfh8-c2jp-5v3q / CVE-2021-44228 Remote code injection in Log4j [KEV since 2021-12-10]
175
+ CRITICAL smallvec@1.6.0 (fix: smallvec@1.6.1)
176
+ HIGH Newtonsoft.Json@12.0.1 (fix: Newtonsoft.Json@13.0.1, breaking)
177
+ ```
178
+
179
+ Notes:
180
+ - Components without a purl are skipped and counted in the output. Operating-system packages (deb/rpm/apk) are not looked up.
181
+ - `--production` skips components with CycloneDX scope `optional`/`excluded` (or SPDX `DEV_DEPENDENCY_OF`).
182
+ - The input SBOM is also checked against TR-03183-2; since third-party generators rarely include the manufacturer fields, gaps are reported as a **warning** in the audit (`cra-audit sbom check -i` shows the details and fails on them). For npm projects, `cra-audit sbom generate` produces a conforming SBOM.
183
+ - SARIF alerts point at the line of the component's purl in the SBOM file.
184
+
154
185
  ---
155
186
 
156
187
  ## Usage
@@ -222,7 +253,7 @@ cra-audit --help
222
253
  | `--sarif <path>` | Also write the audit as SARIF 2.1.0 for GitHub code scanning. |
223
254
  | `--init` | With `readiness`: create SECURITY.md and security.txt templates. |
224
255
  | `--output`, `-o <path>` | Write the result / SBOM / HTML to a file. |
225
- | `--input`, `-i <path>` | Existing SBOM to validate (for `sbom check`). |
256
+ | `--input`, `-i <path>` | Existing CycloneDX/SPDX JSON SBOM: validated by `sbom check`, or audited instead of the npm lockfile by `audit`, `vuln`, `licenses` and `vex` (any language). |
226
257
  | `--config`, `-c <path>` | Path to the security policy. |
227
258
  | `--cwd <path>` | Project directory to audit. |
228
259
  | `--no-color` | Disable colors. |
@@ -315,6 +346,17 @@ Suitable for CI/CD: a non-`0` code blocks the pipeline.
315
346
 
316
347
  Available in the GitHub Marketplace as [CRA Compliance Audit](https://github.com/marketplace/actions/cra-compliance-audit).
317
348
 
349
+ For a Python, Java, Go… project, generate the SBOM first and pass it with `sbom-input`:
350
+
351
+ ```yaml
352
+ - uses: anchore/sbom-action@v0 # Syft
353
+ with: { format: cyclonedx-json, output-file: sbom.cdx.json, upload-artifact: false }
354
+ - uses: migohe14/cra-audit@v2
355
+ with:
356
+ sbom-input: sbom.cdx.json
357
+ vex: vex.cdx.json
358
+ ```
359
+
318
360
  ```yaml
319
361
  name: CRA audit
320
362
  on: [push, pull_request]
@@ -357,6 +399,7 @@ Every finding shows up in **Security → Code scanning**, pointing at the exact
357
399
  | `sarif` | `cra-audit.sarif` | SARIF path (empty to skip). |
358
400
  | `upload-sarif` | `true` | Upload to code scanning (needs `security-events: write`; private repos need GitHub Advanced Security). |
359
401
  | `sbom` / `vex` | — | Also write the SBOM / VEX to these paths. |
402
+ | `sbom-input` | — | Audit this CycloneDX/SPDX SBOM instead of the npm lockfile (any language). |
360
403
  | `args` | — | Extra `cra-audit audit` arguments. |
361
404
 
362
405
  Outputs: `exit-code`, `sarif`, `sbom`, `vex`. Without the action, `npx cra-audit --sarif cra-audit.sarif` does the same in any CI.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cra-audit",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
4
4
  "description": "Audits npm, yarn, and pnpm projects for compliance with the European Cyber Resilience Act (CRA, EU Regulation 2024/2847) and BSI TR-03183: SBOM (CycloneDX/SPDX), known vulnerabilities, third-party components, and licenses.",
5
5
  "homepage": "https://github.com/migohe14/cra-audit#readme",
6
6
  "repository": {
package/src/cli.js CHANGED
@@ -211,7 +211,8 @@ ${c.bold('OPTIONS')}
211
211
  ${c.cyan('--json')} Machine-readable JSON output.
212
212
  ${c.cyan('--sarif <path>')} Also write the audit as SARIF 2.1.0 (GitHub code scanning).
213
213
  ${c.cyan('--output, -o <path>')} Write the result/SBOM/HTML to a file.
214
- ${c.cyan('--input, -i <path>')} Existing SBOM to validate (for "sbom check").
214
+ ${c.cyan('--input, -i <path>')} Existing CycloneDX/SPDX JSON SBOM: validate it (sbom check) or audit it
215
+ instead of the npm lockfile — any ecosystem (audit, vuln, licenses, vex).
215
216
  ${c.cyan('--config, -c <path>')} Path to the security policy (.cra-audit.json).
216
217
  ${c.cyan('--cwd <path>')} Project directory to audit.
217
218
  ${c.cyan('--no-color')} Disable colors.
@@ -233,6 +234,9 @@ ${c.bold('EXAMPLES')}
233
234
  ${c.gray('# Generate a CycloneDX SBOM on disk')}
234
235
  npx cra-audit sbom generate -o sbom.cdx.json
235
236
 
237
+ ${c.gray('# Any language: audit an SBOM from Syft, cdxgen, Trivy, CycloneDX plugins…')}
238
+ npx cra-audit -i sbom.cdx.json
239
+
236
240
  ${c.gray('# VEX with the exploitability assessments recorded in .cra-audit.json')}
237
241
  npx cra-audit vex -o vex.cdx.json
238
242
 
@@ -1,69 +1,81 @@
1
- 'use strict';
2
-
3
- const { findProjectRoot } = require('../utils/fs');
4
- const { logger } = require('../utils/logger');
5
- const { loadPolicy } = require('../core/policy');
6
- const { runAudit } = require('../core/auditor');
7
- const { reportConsole } = require('../reporters/console');
8
- const { reportJson } = require('../reporters/json');
9
- const { reportSarif } = require('../reporters/sarif');
10
-
11
- /**
12
- * `cra-audit [audit]` — runs the full compliance audit (vulnerabilities + SBOM
13
- * + licenses) or a single section via `only`.
14
- *
15
- * @param {object} flags Parsed CLI flags.
16
- * @param {'vulnerabilities'|'sbom'|'licenses'} [only]
17
- * @returns {Promise<number>} Process exit code.
18
- */
19
- async function auditCommand(flags, only) {
20
- const projectRoot = resolveRoot(flags);
21
- if (!projectRoot) return 1;
22
-
23
- let policyResult;
24
- try {
25
- policyResult = loadPolicy(projectRoot, flags.config);
26
- } catch (err) {
27
- logger.error(err.message);
28
- return 1;
29
- }
30
-
31
- const policy = applyFlagOverrides(policyResult.policy, flags);
32
- const report = await runAudit(projectRoot, policy, policyResult.source, { only });
33
-
34
- if (flags.json) {
35
- reportJson(report, { outputPath: flags.output });
36
- } else {
37
- reportConsole(report);
38
- }
39
- if (typeof flags.sarif === 'string') {
40
- reportSarif(report, projectRoot, flags.sarif, { quiet: flags.json && !flags.output, policy });
41
- }
42
-
43
- return report.gate.passed ? 0 : 1;
44
- }
45
-
46
- function resolveRoot(flags) {
47
- const start = flags.cwd || process.cwd();
48
- const root = findProjectRoot(start);
49
- if (!root) {
50
- logger.error('No package.json found. Run the command inside an npm project.');
51
- return null;
52
- }
53
- return root;
54
- }
55
-
56
- /** CLI flags take precedence over the file-based policy. */
57
- function applyFlagOverrides(policy, flags) {
58
- const merged = { ...policy };
59
- if (flags.failOn) merged.failOn = flags.failOn;
60
- if (flags.production) merged.productionOnly = true;
61
- if (flags.format) merged.sbomFormat = flags.format;
62
- if (flags.noSbom) merged.requireSbom = false;
63
- if (flags.vulnSource) merged.vulnerabilitySource = flags.vulnSource;
64
- if (flags.failOnKev) merged.failOnKev = true;
65
- if (flags.noFailOnKev) merged.failOnKev = false;
66
- return merged;
67
- }
68
-
69
- module.exports = { auditCommand };
1
+ 'use strict';
2
+
3
+ const path = require('node:path');
4
+ const { findProjectRoot } = require('../utils/fs');
5
+ const { logger } = require('../utils/logger');
6
+ const { loadPolicy } = require('../core/policy');
7
+ const { runAudit } = require('../core/auditor');
8
+ const { reportConsole } = require('../reporters/console');
9
+ const { reportJson } = require('../reporters/json');
10
+ const { reportSarif } = require('../reporters/sarif');
11
+
12
+ /**
13
+ * `cra-audit [audit]` — runs the full compliance audit (vulnerabilities + SBOM
14
+ * + licenses) or a single section via `only`.
15
+ *
16
+ * @param {object} flags Parsed CLI flags.
17
+ * @param {'vulnerabilities'|'sbom'|'licenses'} [only]
18
+ * @returns {Promise<number>} Process exit code.
19
+ */
20
+ async function auditCommand(flags, only) {
21
+ const projectRoot = resolveRoot(flags);
22
+ if (!projectRoot) return 1;
23
+
24
+ let policyResult;
25
+ try {
26
+ policyResult = loadPolicy(projectRoot, flags.config);
27
+ } catch (err) {
28
+ logger.error(err.message);
29
+ return 1;
30
+ }
31
+
32
+ const policy = applyFlagOverrides(policyResult.policy, flags);
33
+ const report = await runAudit(projectRoot, policy, policyResult.source, { only, input: inputPath(flags) });
34
+
35
+ if (flags.json) {
36
+ reportJson(report, { outputPath: flags.output });
37
+ } else {
38
+ reportConsole(report);
39
+ }
40
+ if (typeof flags.sarif === 'string') {
41
+ reportSarif(report, projectRoot, flags.sarif, { quiet: flags.json && !flags.output, policy });
42
+ }
43
+
44
+ return report.gate.passed ? 0 : 1;
45
+ }
46
+
47
+ /**
48
+ * Project root: the nearest package.json, or with `-i <sbom>` the working
49
+ * directory (any ecosystem, no package.json needed).
50
+ */
51
+ function resolveRoot(flags) {
52
+ const start = flags.cwd || process.cwd();
53
+ if (inputPath(flags)) return path.resolve(start);
54
+ const root = findProjectRoot(start);
55
+ if (!root) {
56
+ logger.error('No package.json found. Run the command inside an npm project.');
57
+ return null;
58
+ }
59
+ return root;
60
+ }
61
+
62
+ /** `-i/--input <sbom>` resolved against the working directory. */
63
+ function inputPath(flags) {
64
+ if (typeof flags.input !== 'string') return null;
65
+ return path.resolve(flags.cwd || process.cwd(), flags.input);
66
+ }
67
+
68
+ /** CLI flags take precedence over the file-based policy. */
69
+ function applyFlagOverrides(policy, flags) {
70
+ const merged = { ...policy };
71
+ if (flags.failOn) merged.failOn = flags.failOn;
72
+ if (flags.production) merged.productionOnly = true;
73
+ if (flags.format) merged.sbomFormat = flags.format;
74
+ if (flags.noSbom) merged.requireSbom = false;
75
+ if (flags.vulnSource) merged.vulnerabilitySource = flags.vulnSource;
76
+ if (flags.failOnKev) merged.failOnKev = true;
77
+ if (flags.noFailOnKev) merged.failOnKev = false;
78
+ return merged;
79
+ }
80
+
81
+ module.exports = { auditCommand, inputPath };
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
- const { findProjectRoot } = require('../utils/fs');
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
4
5
  const { logger, color } = require('../utils/logger');
5
6
  const { checkReadiness, writeTemplates } = require('../core/readiness');
6
7
 
@@ -13,11 +14,7 @@ const { checkReadiness, writeTemplates } = require('../core/readiness');
13
14
  * @returns {number} exit code: 1 when a required check fails.
14
15
  */
15
16
  function readinessCommand(flags) {
16
- const projectRoot = findProjectRoot(flags.cwd || process.cwd());
17
- if (!projectRoot) {
18
- logger.error('No package.json found. Run the command inside an npm project.');
19
- return 1;
20
- }
17
+ const projectRoot = findRepositoryRoot(flags.cwd || process.cwd());
21
18
 
22
19
  if (flags.init) {
23
20
  for (const { file, created } of writeTemplates(projectRoot)) {
@@ -56,4 +53,18 @@ function readinessCommand(flags) {
56
53
  return result.passed ? 0 : 1;
57
54
  }
58
55
 
56
+ /**
57
+ * The nearest folder with a package.json or a .git entry: readiness applies to
58
+ * any repository, not only npm projects. Falls back to the start folder.
59
+ */
60
+ function findRepositoryRoot(start) {
61
+ let dir = path.resolve(start);
62
+ for (;;) {
63
+ if (fs.existsSync(path.join(dir, 'package.json')) || fs.existsSync(path.join(dir, '.git'))) return dir;
64
+ const parent = path.dirname(dir);
65
+ if (parent === dir) return path.resolve(start);
66
+ dir = parent;
67
+ }
68
+ }
69
+
59
70
  module.exports = { readinessCommand };
@@ -7,6 +7,8 @@ const { loadPolicy } = require('../core/policy');
7
7
  const { scanVulnerabilities } = require('../core/vuln-scanner');
8
8
  const { buildVex } = require('../core/vex');
9
9
  const { creatorFromManifest } = require('../core/installed-metadata');
10
+ const { readSbom } = require('../core/sbom-reader');
11
+ const { inputPath } = require('./audit');
10
12
 
11
13
  /**
12
14
  * `cra-audit vex` — writes a VEX document (CycloneDX 1.6 or OpenVEX 0.2.0)
@@ -16,7 +18,9 @@ const { creatorFromManifest } = require('../core/installed-metadata');
16
18
  * @returns {Promise<number>} exit code
17
19
  */
18
20
  async function vexCommand(flags) {
19
- const projectRoot = findProjectRoot(flags.cwd || process.cwd());
21
+ const input = inputPath(flags);
22
+ const start = flags.cwd || process.cwd();
23
+ const projectRoot = input ? path.resolve(start) : findProjectRoot(start);
20
24
  if (!projectRoot) {
21
25
  logger.error('No package.json found. Run the command inside an npm project.');
22
26
  return 1;
@@ -36,9 +40,15 @@ async function vexCommand(flags) {
36
40
  return 1;
37
41
  }
38
42
 
43
+ const parsed = input ? readSbom(input) : null;
44
+ if (parsed && !parsed.ok) {
45
+ logger.error(parsed.error);
46
+ return 1;
47
+ }
39
48
  const vulns = await scanVulnerabilities(projectRoot, {
40
49
  production: flags.production || policy.productionOnly,
41
50
  source: flags.vulnSource || policy.vulnerabilitySource,
51
+ parsed,
42
52
  });
43
53
  if (!vulns.ok) {
44
54
  logger.error(vulns.error);
@@ -53,7 +63,9 @@ async function vexCommand(flags) {
53
63
  const pkg = readJson(path.join(projectRoot, 'package.json')) || {};
54
64
  const creator = creatorFromManifest(pkg);
55
65
  const author = policy.sbomCreator || (creator && (creator.email || creator.url)) || null;
56
- const product = { name: pkg.name || path.basename(projectRoot), version: pkg.version || '0.0.0' };
66
+ const product = parsed
67
+ ? { name: parsed.root.name, version: parsed.root.version, purl: parsed.root.purl }
68
+ : { name: pkg.name || path.basename(projectRoot), version: pkg.version || '0.0.0' };
57
69
  const document = buildVex(product, vulns, policy, { format, author });
58
70
 
59
71
  const count = format === 'openvex' ? document.statements.length : document.vulnerabilities.length;
@@ -5,6 +5,7 @@ const { generateSbom } = require('./sbom-generator');
5
5
  const { validateSbom } = require('./sbom-validator');
6
6
  const { checkLicenses } = require('./license-checker');
7
7
  const { acceptance } = require('./vex');
8
+ const { readSbom } = require('./sbom-reader');
8
9
 
9
10
  /**
10
11
  * @typedef {object} AuditResult
@@ -21,19 +22,24 @@ const { acceptance } = require('./vex');
21
22
  * @param {string} projectRoot
22
23
  * @param {object} policy
23
24
  * @param {string|null} policySource
24
- * @param {{ only?: 'vulnerabilities'|'sbom'|'licenses' }} [options]
25
+ * With `input` (path to a CycloneDX/SPDX JSON SBOM) the audit runs on that
26
+ * SBOM instead of the npm lockfile, so it covers any ecosystem.
27
+ *
28
+ * @param {{ only?: 'vulnerabilities'|'sbom'|'licenses', input?: string }} [options]
25
29
  * @returns {Promise<AuditResult>}
26
30
  */
27
31
  async function runAudit(projectRoot, policy, policySource, options = {}) {
28
- const { only } = options;
32
+ const { only, input } = options;
29
33
  const sections = {};
30
34
  const reasons = [];
35
+ const parsed = input ? readSbom(input) : null;
31
36
 
32
37
  // --- 1. Vulnerabilities -------------------------------------------------
33
38
  if (!only || only === 'vulnerabilities') {
34
39
  const vulns = await scanVulnerabilities(projectRoot, {
35
40
  production: policy.productionOnly,
36
41
  source: policy.vulnerabilitySource,
42
+ parsed,
37
43
  });
38
44
  sections.vulnerabilities = vulns;
39
45
 
@@ -56,7 +62,25 @@ async function runAudit(projectRoot, policy, policySource, options = {}) {
56
62
  }
57
63
 
58
64
  // --- 2. SBOM ------------------------------------------------------------
59
- if (!only || only === 'sbom') {
65
+ if ((!only || only === 'sbom') && parsed) {
66
+ // An input SBOM comes from third-party tooling that rarely carries the
67
+ // manufacturer fields TR-03183 requires: report gaps without blocking.
68
+ const sbom = parsed.ok
69
+ ? { ok: true, input: parsed.sourceFile, format: parsed.format, componentCount: parsed.components.length, validation: validateSbom(parsed.document) }
70
+ : { ok: false, input: parsed.sourceFile, error: parsed.error };
71
+ sections.sbom = sbom;
72
+ if (!sbom.ok) {
73
+ reasons.push({ label: `The input SBOM could not be read: ${sbom.error}`, passed: false });
74
+ } else if (policy.requireSbom) {
75
+ reasons.push({
76
+ label: sbom.validation.valid
77
+ ? 'Input SBOM valid against the TR-03183-2 required data fields'
78
+ : `Input SBOM misses ${sbom.validation.failedChecks.length} TR-03183-2 requirement(s) (see \`cra-audit sbom check -i\`)`,
79
+ passed: true,
80
+ warning: !sbom.validation.valid,
81
+ });
82
+ }
83
+ } else if (!only || only === 'sbom') {
60
84
  const sbom = generateSbom(projectRoot, { format: policy.sbomFormat, creator: policy.sbomCreator });
61
85
  if (sbom.ok) {
62
86
  sbom.validation = validateSbom(sbom.document);
@@ -79,7 +103,7 @@ async function runAudit(projectRoot, policy, policySource, options = {}) {
79
103
 
80
104
  // --- 3. Licenses --------------------------------------------------------
81
105
  if (!only || only === 'licenses') {
82
- const licenses = checkLicenses(projectRoot, policy.licenses);
106
+ const licenses = checkLicenses(projectRoot, policy.licenses, { parsed });
83
107
  sections.licenses = licenses;
84
108
 
85
109
  if (!licenses.ok) {
@@ -98,7 +122,9 @@ async function runAudit(projectRoot, policy, policySource, options = {}) {
98
122
  }
99
123
 
100
124
  return {
101
- project: getProject(projectRoot, sections),
125
+ project: parsed && parsed.ok
126
+ ? { name: parsed.root.name, version: parsed.root.version, sbom: parsed.sourceFile }
127
+ : getProject(projectRoot, sections),
102
128
  policySource,
103
129
  generatedAt: new Date().toISOString(),
104
130
  sections,
@@ -7,11 +7,16 @@ const { readInstalledManifest, licenseFromManifest } = require('./installed-meta
7
7
  * Resolves and evaluates the license of every third-party component.
8
8
  * CRA / TR-03183 require every component's license to be documented.
9
9
  *
10
+ * Pass `parsed` (from readSbom()) to check the licenses declared in an input
11
+ * SBOM instead of the lockfile and node_modules.
12
+ *
10
13
  * @param {string} projectRoot
11
14
  * @param {{ allow?: string[], deny?: string[] }} [policy]
15
+ * @param {{ parsed?: object }} [options]
12
16
  */
13
- function checkLicenses(projectRoot, policy = {}) {
14
- const parsed = parseLockfile(projectRoot);
17
+ function checkLicenses(projectRoot, policy = {}, { parsed: given } = {}) {
18
+ const parsed = given || parseLockfile(projectRoot);
19
+ const fromSbom = parsed.manager === 'sbom';
15
20
  if (!parsed.ok) {
16
21
  return { ok: false, error: parsed.error, components: [], summary: emptySummary() };
17
22
  }
@@ -20,12 +25,13 @@ function checkLicenses(projectRoot, policy = {}) {
20
25
  const deny = normalizeList(policy.deny);
21
26
 
22
27
  const components = parsed.components.map((c) => {
23
- const license = c.license || licenseFromManifest(readInstalledManifest(projectRoot, c));
28
+ const license = c.license || (fromSbom ? null : licenseFromManifest(readInstalledManifest(projectRoot, c)));
24
29
  const normalized = license ? String(license) : null;
25
30
  const status = classify(normalized, allow, deny);
26
31
  return {
27
32
  name: c.name,
28
33
  version: c.version,
34
+ purl: c.purl || null,
29
35
  license: normalized,
30
36
  status, // 'ok' | 'missing' | 'denied' | 'not-allowed'
31
37
  };
package/src/core/osv.js CHANGED
@@ -7,25 +7,32 @@ const BATCH_SIZE = 1000; // OSV querybatch limit
7
7
 
8
8
  /**
9
9
  * Queries OSV.dev (https://osv.dev) for the advisories affecting each exact
10
- * `name@version`. OSV aggregates the GitHub Advisory Database (GHSA) and the
11
- * OpenSSF malicious-packages feed (MAL-*), which lists compromised releases
12
- * such as the ones published by the Shai-Hulud worm.
10
+ * component version. OSV aggregates the GitHub Advisory Database (GHSA), the
11
+ * ecosystem databases (PyPA, RustSec, Go…) and the OpenSSF malicious-packages
12
+ * feed (MAL-*), which lists compromised releases such as the ones published by
13
+ * the Shai-Hulud worm.
13
14
  *
14
- * Only package names and versions leave the machine.
15
+ * Components with a Package URL (from an input SBOM, any ecosystem) are
16
+ * queried by purl; lockfile components by npm name and version. Only package
17
+ * identifiers and versions leave the machine.
15
18
  *
16
- * @param {Array<{ name: string, version: string }>} components
19
+ * @param {Array<{ name: string, version: string, purl?: string, key?: string }>} components
17
20
  * @param {{ timeout?: number, concurrency?: number }} [options]
18
21
  * @returns {Promise<{ ok: true, byComponent: Map<string, object[]> } | { ok: false, error: string }>}
19
- * `byComponent` is keyed by `name@version` and holds full OSV records.
22
+ * `byComponent` is keyed by the component key and holds full OSV records.
20
23
  */
21
24
  async function queryOsv(components, { timeout = 30000, concurrency = 8 } = {}) {
22
25
  const idsByKey = new Map();
26
+ const queryFor = (c) => (c.ecosystem && c.purl
27
+ ? { package: { purl: c.purl } } // the version is part of the purl
28
+ : { package: { name: c.name, ecosystem: 'npm' }, version: c.version });
29
+ const keyFor = (c) => c.key || `${c.name}@${c.version}`;
23
30
 
24
31
  for (let i = 0; i < components.length; i += BATCH_SIZE) {
25
32
  const chunk = components.slice(i, i + BATCH_SIZE);
26
33
  const res = await fetchJson(`${OSV_API}/querybatch`, {
27
34
  timeout,
28
- body: { queries: chunk.map((c) => ({ package: { name: c.name, ecosystem: 'npm' }, version: c.version })) },
35
+ body: { queries: chunk.map(queryFor) },
29
36
  });
30
37
  if (!res || !res.ok || !res.json || !Array.isArray(res.json.results)) {
31
38
  return { ok: false, error: `OSV.dev query failed${res ? ` (HTTP ${res.status})` : ' (network unreachable)'}` };
@@ -39,13 +46,13 @@ async function queryOsv(components, { timeout = 30000, concurrency = 8 } = {}) {
39
46
  while (pageToken) {
40
47
  const page = await fetchJson(`${OSV_API}/query`, {
41
48
  timeout,
42
- body: { package: { name: chunk[j].name, ecosystem: 'npm' }, version: chunk[j].version, page_token: pageToken },
49
+ body: { ...queryFor(chunk[j]), page_token: pageToken },
43
50
  });
44
51
  if (!page || !page.ok || !page.json) break;
45
52
  ids.push(...(page.json.vulns || []).map((v) => v.id));
46
53
  pageToken = page.json.next_page_token;
47
54
  }
48
- if (ids.length) idsByKey.set(`${chunk[j].name}@${chunk[j].version}`, ids);
55
+ if (ids.length) idsByKey.set(keyFor(chunk[j]), ids);
49
56
  }
50
57
  }
51
58
 
@@ -123,7 +130,7 @@ function isMalicious(record) {
123
130
  function fixedVersion(record, name, version) {
124
131
  let best = null;
125
132
  for (const affected of record.affected || []) {
126
- if (!affected.package || affected.package.name !== name) continue;
133
+ if (!affected.package || normalizeName(affected.package.name) !== normalizeName(name)) continue;
127
134
  for (const range of affected.ranges || []) {
128
135
  if (range.type !== 'SEMVER' && range.type !== 'ECOSYSTEM') continue;
129
136
  let introduced = null;
@@ -140,17 +147,26 @@ function fixedVersion(record, name, version) {
140
147
  }
141
148
 
142
149
  /**
143
- * Compares two semver strings (major.minor.patch[-prerelease]).
144
- * Build metadata is ignored.
150
+ * OSV package names compare case-insensitively, and PyPI treats `-`, `_` and
151
+ * `.` as equivalent (PEP 503).
152
+ */
153
+ function normalizeName(name) {
154
+ return String(name || '').toLowerCase().replace(/[-_.]+/g, '-');
155
+ }
156
+
157
+ /**
158
+ * Compares two semver strings (major.minor.patch[-prerelease]). Also used,
159
+ * approximately, for other ecosystems: a leading `v` (Go) is ignored and any
160
+ * number of numeric parts is compared. Build metadata is ignored.
145
161
  */
146
162
  function compareSemver(a, b) {
147
163
  const parse = (v) => {
148
- const [core, pre] = String(v).split('+')[0].split(/-(.*)/s);
164
+ const [core, pre] = String(v).replace(/^v/i, '').split('+')[0].split(/-(.*)/s);
149
165
  return { nums: core.split('.').map((n) => parseInt(n, 10) || 0), pre: pre ? pre.split('.') : [] };
150
166
  };
151
167
  const pa = parse(a);
152
168
  const pb = parse(b);
153
- for (let i = 0; i < 3; i++) {
169
+ for (let i = 0; i < Math.max(pa.nums.length, pb.nums.length, 3); i++) {
154
170
  const diff = (pa.nums[i] || 0) - (pb.nums[i] || 0);
155
171
  if (diff) return diff;
156
172
  }
@@ -102,9 +102,14 @@ function checkReadiness(projectRoot, { now = new Date() } = {}) {
102
102
  }
103
103
 
104
104
  // --- SBOM ---------------------------------------------------------------------
105
+ // npm projects: a lockfile cra-audit builds the SBOM from. Other ecosystems:
106
+ // an SBOM produced by their own tooling (Syft, cdxgen…) kept in the repo.
105
107
  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)');
108
+ const sbomFile = parsed.ok ? null : findSbomFile(projectRoot);
109
+ add('sbom', 'SBOM available (npm lockfile or an SBOM file)', 'required', parsed.ok || sbomFile,
110
+ parsed.ok ? `${parsed.lockfileName} (${parsed.components.length} components)`
111
+ : sbomFile ? `Found ${sbomFile}` : `${parsed.error} For other ecosystems, commit an SBOM (e.g. sbom.cdx.json).`,
112
+ 'CRA Annex I Part II (1)');
108
113
 
109
114
  add('package-repository', 'package.json links the source repository', 'recommended', Boolean(repositoryUrl(pkg.repository)),
110
115
  repositoryUrl(pkg.repository) || 'No "repository" field', 'TR-03183-2 §5.2.4');
@@ -216,6 +221,21 @@ function securityTxtTemplate({ advisories, repo, expires }) {
216
221
  return lines.join('\n');
217
222
  }
218
223
 
224
+ /** An SBOM file at the root or in sbom/: *.cdx.json, *.spdx.json, bom.json… */
225
+ function findSbomFile(root) {
226
+ for (const dir of ['', 'sbom']) {
227
+ let names = [];
228
+ try {
229
+ names = fs.readdirSync(path.join(root, dir));
230
+ } catch {
231
+ continue;
232
+ }
233
+ const match = names.find((n) => /(\.cdx\.json|\.spdx\.json|^bom\.json|^sbom\.json)$/i.test(n));
234
+ if (match) return dir ? `${dir}/${match}` : match;
235
+ }
236
+ return null;
237
+ }
238
+
219
239
  function firstExisting(root, candidates) {
220
240
  return candidates.find((rel) => fs.existsSync(path.join(root, rel))) || null;
221
241
  }
@@ -0,0 +1,296 @@
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
+ format: result.format,
49
+ lockfileName: path.basename(file),
50
+ sourceFile: file,
51
+ document,
52
+ root: result.root,
53
+ components: dedupe(identified),
54
+ unidentified: result.components.length - identified.length,
55
+ dependencyGraphComplete: result.complete,
56
+ };
57
+ }
58
+
59
+ // --- CycloneDX ---------------------------------------------------------------
60
+
61
+ function fromCycloneDx(doc) {
62
+ const byRef = new Map();
63
+ const components = [];
64
+ const visit = (list) => {
65
+ for (const c of list || []) {
66
+ if (!c || typeof c !== 'object') continue;
67
+ if (c.type !== 'file' && c.type !== 'operating-system') {
68
+ const comp = component({
69
+ purl: c.purl,
70
+ name: c.group ? `${c.group}/${c.name}` : c.name,
71
+ version: c.version,
72
+ license: cdxLicense(c.licenses),
73
+ optional: c.scope === 'optional' || c.scope === 'excluded',
74
+ });
75
+ components.push(comp);
76
+ if (c['bom-ref']) byRef.set(c['bom-ref'], comp);
77
+ }
78
+ visit(c.components); // nested components (assemblies)
79
+ }
80
+ };
81
+ visit(doc.components);
82
+
83
+ const meta = (doc.metadata && doc.metadata.component) || {};
84
+ const root = rootInfo(meta.name, meta.version, meta.purl);
85
+ const rootRef = meta['bom-ref'];
86
+
87
+ let complete = Array.isArray(doc.dependencies) && doc.dependencies.length > 0;
88
+ for (const dep of doc.dependencies || []) {
89
+ const targets = (dep.dependsOn || []).map((ref) => byRef.get(ref)).filter(Boolean).map((c) => c.key);
90
+ if (dep.ref === rootRef) root.dependsOn.push(...targets);
91
+ const from = byRef.get(dep.ref);
92
+ if (from) from.dependsOn.push(...targets);
93
+ }
94
+ if ((doc.compositions || []).some((c) => c && c.aggregate && c.aggregate !== 'complete')) complete = false;
95
+ return { format: 'cyclonedx', components, root, complete };
96
+ }
97
+
98
+ function cdxLicense(licenses) {
99
+ if (!Array.isArray(licenses) || !licenses.length) return null;
100
+ const ids = licenses.map((l) => l.expression || (l.license && (l.license.id || l.license.name))).filter(Boolean);
101
+ return ids.length ? [...new Set(ids)].join(' AND ') : null;
102
+ }
103
+
104
+ // --- SPDX 2.x ----------------------------------------------------------------
105
+
106
+ function fromSpdx2(doc) {
107
+ const byId = new Map();
108
+ const components = [];
109
+ const described = new Set(doc.documentDescribes || []);
110
+ for (const r of doc.relationships || []) {
111
+ if (r.relationshipType === 'DESCRIBES' && r.spdxElementId === 'SPDXRef-DOCUMENT') described.add(r.relatedSpdxElement);
112
+ }
113
+
114
+ let rootPkg = null;
115
+ for (const pkg of doc.packages || []) {
116
+ const ref = (pkg.externalRefs || []).find((r) => r.referenceType === 'purl');
117
+ if (described.has(pkg.SPDXID) && !rootPkg) {
118
+ rootPkg = pkg;
119
+ continue;
120
+ }
121
+ const comp = component({
122
+ purl: ref && ref.referenceLocator,
123
+ name: pkg.name,
124
+ version: pkg.versionInfo,
125
+ license: spdxLicense(pkg.licenseConcluded) || spdxLicense(pkg.licenseDeclared),
126
+ });
127
+ components.push(comp);
128
+ byId.set(pkg.SPDXID, comp);
129
+ }
130
+
131
+ const rootRef = rootPkg && (rootPkg.externalRefs || []).find((r) => r.referenceType === 'purl');
132
+ const root = rootInfo((rootPkg && rootPkg.name) || doc.name, rootPkg && rootPkg.versionInfo, rootRef && rootRef.referenceLocator);
133
+ const rootId = rootPkg && rootPkg.SPDXID;
134
+
135
+ let edges = 0;
136
+ for (const r of doc.relationships || []) {
137
+ // A DEPENDS_ON B; B DEPENDENCY_OF A; A CONTAINS B.
138
+ let from = r.spdxElementId;
139
+ let to = r.relatedSpdxElement;
140
+ if (r.relationshipType === 'DEPENDENCY_OF') [from, to] = [to, from];
141
+ else if (!['DEPENDS_ON', 'CONTAINS', 'RUNTIME_DEPENDENCY_OF', 'DEV_DEPENDENCY_OF'].includes(r.relationshipType)) continue;
142
+ if (r.relationshipType.endsWith('_DEPENDENCY_OF')) [from, to] = [to, from];
143
+ const target = byId.get(to);
144
+ if (!target) continue;
145
+ edges++;
146
+ if (from === rootId) root.dependsOn.push(target.key);
147
+ else if (byId.has(from)) byId.get(from).dependsOn.push(target.key);
148
+ if (r.relationshipType === 'DEV_DEPENDENCY_OF') target.optional = true;
149
+ }
150
+ return { format: 'spdx', components, root, complete: edges > 0 };
151
+ }
152
+
153
+ function spdxLicense(value) {
154
+ return value && value !== 'NOASSERTION' && value !== 'NONE' ? value : null;
155
+ }
156
+
157
+ // --- SPDX 3.0 (JSON-LD) ----------------------------------------------------------
158
+
159
+ function fromSpdx3(doc) {
160
+ const byId = new Map();
161
+ const components = [];
162
+ let rootId = null;
163
+
164
+ for (const el of doc['@graph']) {
165
+ if (el.type === 'SpdxDocument' && Array.isArray(el.rootElement)) rootId = rootId || el.rootElement[0];
166
+ }
167
+ let root = rootInfo(doc.name);
168
+
169
+ for (const el of doc['@graph']) {
170
+ if (el.type !== 'software_Package') continue;
171
+ const id = el.spdxId || el['@id'];
172
+ if (id && id === rootId) {
173
+ root = rootInfo(el.name || root.name, el.software_packageVersion, el.software_packageUrl);
174
+ continue;
175
+ }
176
+ const comp = component({
177
+ purl: el.software_packageUrl,
178
+ name: el.name,
179
+ version: el.software_packageVersion,
180
+ license: null,
181
+ });
182
+ components.push(comp);
183
+ if (id) byId.set(id, comp);
184
+ }
185
+
186
+ let edges = 0;
187
+ for (const el of doc['@graph']) {
188
+ if (el.type !== 'Relationship' || !['dependsOn', 'contains'].includes(el.relationshipType)) continue;
189
+ for (const to of el.to || []) {
190
+ const target = byId.get(to);
191
+ if (!target) continue;
192
+ edges++;
193
+ if (el.from === rootId) root.dependsOn.push(target.key);
194
+ else if (byId.has(el.from)) byId.get(el.from).dependsOn.push(target.key);
195
+ }
196
+ }
197
+ return { format: 'spdx', components, root, complete: edges > 0 };
198
+ }
199
+
200
+ /**
201
+ * The product the SBOM describes. Without a purl in the SBOM it gets a
202
+ * `pkg:generic` one, so VEX statements still name the product unambiguously.
203
+ */
204
+ function rootInfo(name, version, purl) {
205
+ const n = name || 'unknown';
206
+ const v = version || '0.0.0';
207
+ const parsed = parsePurl(purl);
208
+ return { name: n, version: v, purl: parsed ? parsed.canonical : `pkg:generic/${encodeURIComponent(n)}@${encodeURIComponent(v)}`, dependsOn: [] };
209
+ }
210
+
211
+ // --- Components -------------------------------------------------------------------
212
+
213
+ /**
214
+ * Normalizes an SBOM component. The name and version come from the purl when
215
+ * there is one (it is what OSV understands), else from the SBOM fields.
216
+ */
217
+ function component({ purl, name, version, license, optional = false }) {
218
+ const parsed = parsePurl(purl);
219
+ const clean = parsed ? parsed.canonical : null;
220
+ const displayName = parsed ? parsed.displayName : name || 'unknown';
221
+ const ver = (parsed && parsed.version) || version || null;
222
+ return {
223
+ key: clean || `${displayName}@${ver}`,
224
+ name: displayName,
225
+ version: ver,
226
+ purl: clean,
227
+ ecosystem: parsed ? parsed.type : null,
228
+ osvName: parsed ? parsed.osvName : displayName,
229
+ license: license || null,
230
+ scope: optional ? 'optional' : 'required',
231
+ dev: optional,
232
+ optional,
233
+ dependsOn: [],
234
+ };
235
+ }
236
+
237
+ /**
238
+ * Minimal Package URL parser (https://github.com/package-url/purl-spec).
239
+ * Returns the canonical purl without qualifiers/subpath and the name in the
240
+ * form each ecosystem uses (OSV naming): Maven `group:artifact`, npm
241
+ * `@scope/name`, Go/Composer `namespace/name`.
242
+ */
243
+ function parsePurl(purl) {
244
+ if (typeof purl !== 'string' || !purl.startsWith('pkg:')) return null;
245
+ let rest = purl.slice(4).split('#')[0].split('?')[0];
246
+ // The version separator is the last `@` after the last `/` (an unencoded
247
+ // npm scope, `pkg:npm/@scope/name`, is not a version).
248
+ const at = rest.lastIndexOf('@');
249
+ let version = null;
250
+ let rawVersion = '';
251
+ if (at > rest.lastIndexOf('/')) {
252
+ rawVersion = rest.slice(at + 1);
253
+ version = decodeURIComponent(rawVersion);
254
+ rest = rest.slice(0, at);
255
+ }
256
+ const parts = rest.split('/');
257
+ const type = parts.shift().toLowerCase();
258
+ if (!type || !parts.length) return null;
259
+ const name = decodeURIComponent(parts.pop());
260
+ const namespace = parts.map(decodeURIComponent).join('/');
261
+
262
+ let osvName = name;
263
+ if (type === 'maven') osvName = namespace ? `${namespace}:${name}` : name;
264
+ else if (namespace) osvName = `${namespace}/${name}`;
265
+
266
+ return {
267
+ type,
268
+ namespace,
269
+ name,
270
+ version,
271
+ osvName,
272
+ displayName: osvName,
273
+ canonical: `pkg:${rest}${rawVersion ? `@${rawVersion}` : ''}`,
274
+ };
275
+ }
276
+
277
+ function dedupe(components) {
278
+ const seen = new Map();
279
+ for (const c of components) {
280
+ const existing = seen.get(c.key);
281
+ if (!existing) seen.set(c.key, c);
282
+ else existing.dependsOn.push(...c.dependsOn);
283
+ }
284
+ for (const c of seen.values()) c.dependsOn = [...new Set(c.dependsOn)].sort();
285
+ return [...seen.values()].sort((a, b) => a.key.localeCompare(b.key));
286
+ }
287
+
288
+ function fail(file, error) {
289
+ return {
290
+ ok: false, error, manager: 'sbom', format: null, lockfileName: path.basename(file), sourceFile: file,
291
+ document: null, root: rootInfo(), components: [], unidentified: 0,
292
+ dependencyGraphComplete: false,
293
+ };
294
+ }
295
+
296
+ 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
+ * An existing SBOM of any ecosystem can be scanned instead of the lockfile by
29
+ * passing the result of readSbom() as `parsed`; its components are looked up
30
+ * 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.manager === 'sbom');
38
+ if (source === 'npm') {
39
+ if (fromSbom) {
40
+ return { ok: false, error: '`npm audit` cannot scan an input SBOM; 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,8 @@ async function scanVulnerabilities(projectRoot, { production = false, source = '
73
88
  ok: true,
74
89
  source: 'osv',
75
90
  scanned: scannable.length,
91
+ input: fromSbom ? { file: parsed.sourceFile, format: parsed.format, unidentified: parsed.unidentified } : null,
92
+ ecosystems: [...new Set(scannable.map((c) => c.ecosystem || 'npm'))].sort(),
76
93
  kev: catalogue
77
94
  ? (catalogue.ok
78
95
  ? { checked: true, catalogVersion: catalogue.catalogVersion, entries: catalogue.count }
@@ -100,7 +117,7 @@ function toFinding(component, records, isDirect, catalogue) {
100
117
  cvss: cvssScore(record),
101
118
  cwe: (record.database_specific && record.database_specific.cwe_ids) || [],
102
119
  malicious: isMalicious(record),
103
- fixed: fixedVersion(record, component.name, component.version),
120
+ fixed: fixedVersion(record, component.osvName || component.name, component.version),
104
121
  kev: kevEntry,
105
122
  };
106
123
  });
@@ -116,6 +133,8 @@ function toFinding(component, records, isDirect, catalogue) {
116
133
  return {
117
134
  name: component.name,
118
135
  version: component.version,
136
+ purl: component.purl || buildPurl(component.name, component.version),
137
+ ecosystem: component.ecosystem || 'npm',
119
138
  severity,
120
139
  direct: isDirect,
121
140
  range: null,
@@ -158,7 +177,7 @@ function severityRank(severity) {
158
177
  }
159
178
 
160
179
  function major(version) {
161
- return parseInt(String(version).split('.')[0], 10);
180
+ return parseInt(String(version).replace(/^v/i, '').split('.')[0], 10);
162
181
  }
163
182
 
164
183
  /** `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 = {
@@ -87,7 +88,11 @@ function describeSources(section) {
87
88
  const kev = section.kev && section.kev.checked
88
89
  ? `CISA KEV ${section.kev.catalogVersion || ''} (${section.kev.entries} entries)`.replace(' ', ' ')
89
90
  : `CISA KEV not checked (${(section.kev && section.kev.error) || 'unavailable'})`;
90
- return `Sources: OSV.dev (GitHub advisories + OpenSSF malicious packages) · ${kev} · ${section.scanned} components`;
91
+ const scope = section.input
92
+ ? `${section.scanned} components from ${shortPath(section.input.file)} (${(section.ecosystems || []).join(', ')})` +
93
+ (section.input.unidentified ? ` · ${section.input.unidentified} without purl skipped` : '')
94
+ : `${section.scanned} components`;
95
+ return `Sources: OSV.dev (GitHub advisories + OpenSSF malicious packages) · ${kev} · ${scope}`;
91
96
  }
92
97
 
93
98
  /** CRA Art. 14 reporting clock, shown when a KEV-listed vulnerability is found. */
@@ -116,7 +121,7 @@ function renderSbom(section) {
116
121
  logger.error(section.error);
117
122
  return;
118
123
  }
119
- logger.detail(`Format: ${section.format} · Components: ${section.componentCount}`);
124
+ logger.detail(`${section.input ? `Input SBOM: ${shortPath(section.input)} · ` : ''}Format: ${section.format} · Components: ${section.componentCount}`);
120
125
 
121
126
  if (section.validation) {
122
127
  const v = section.validation;
@@ -176,4 +181,10 @@ function renderSummary(report) {
176
181
  logger.log('');
177
182
  }
178
183
 
184
+ /** A path relative to the working directory when it is inside it. */
185
+ function shortPath(file) {
186
+ const rel = path.relative(process.cwd(), file);
187
+ return rel && !rel.startsWith('..') && !path.isAbsolute(rel) ? rel : file;
188
+ }
189
+
179
190
  module.exports = { reportConsole };
@@ -29,10 +29,11 @@ const SECURITY_SEVERITY = { critical: 9.5, high: 8.0, moderate: 5.5, low: 2.0, i
29
29
  function buildSarif(report, projectRoot, policy = {}) {
30
30
  const rules = new Map();
31
31
  const results = [];
32
- const lock = lockfileLocator(projectRoot);
32
+ const vulns = report.sections.vulnerabilities;
33
+ const inputFile = vulns && vulns.input ? vulns.input.file : null;
34
+ const lock = lockfileLocator(projectRoot, inputFile);
33
35
  const assessments = normalizeAllowlist(policy);
34
36
 
35
- const vulns = report.sections.vulnerabilities;
36
37
  if (vulns && vulns.ok) {
37
38
  for (const finding of vulns.vulnerabilities) {
38
39
  for (const source of finding.sources) {
@@ -46,7 +47,7 @@ function buildSarif(report, projectRoot, policy = {}) {
46
47
  : finding.malicious || source.kev || ['critical', 'high'].includes(source.severity || finding.severity) ? 'error'
47
48
  : (source.severity || finding.severity) === 'moderate' ? 'warning' : 'note',
48
49
  message: { text: vulnerabilityMessage(finding, source) },
49
- locations: [lock.locate(finding.name, finding.version)],
50
+ locations: [lock.locate(finding.name, finding.version, finding.purl)],
50
51
  partialFingerprints: { 'craAudit/v1': `${ruleId}:${finding.name}@${finding.version || ''}` },
51
52
  };
52
53
  if (accepted) {
@@ -77,7 +78,7 @@ function buildSarif(report, projectRoot, policy = {}) {
77
78
  ruleId,
78
79
  level,
79
80
  message: { text: text(c) },
80
- locations: [lock.locate(c.name, c.version)],
81
+ locations: [lock.locate(c.name, c.version, c.purl)],
81
82
  partialFingerprints: { 'craAudit/v1': `${ruleId}:${c.name}@${c.version}` },
82
83
  });
83
84
  }
@@ -163,13 +164,18 @@ function licenseRule(ruleId) {
163
164
  }
164
165
 
165
166
  /**
166
- * Finds the line of `name@version` in the project lockfile so each alert
167
- * points at the exact entry. Falls back to package.json line 1.
167
+ * Finds the line of `name@version` in the project lockfile (or of the purl in
168
+ * an input SBOM) so each alert points at the exact entry. Falls back to line 1.
168
169
  */
169
- function lockfileLocator(projectRoot) {
170
+ function lockfileLocator(projectRoot, inputFile = null) {
170
171
  const repoRoot = findRepoRoot(projectRoot);
171
- const parsed = parseLockfile(projectRoot);
172
- const file = parsed.lockfileName ? path.join(projectRoot, parsed.lockfileName) : path.join(projectRoot, 'package.json');
172
+ let file;
173
+ if (inputFile) {
174
+ file = path.resolve(inputFile);
175
+ } else {
176
+ const parsed = parseLockfile(projectRoot);
177
+ file = parsed.lockfileName ? path.join(projectRoot, parsed.lockfileName) : path.join(projectRoot, 'package.json');
178
+ }
173
179
  let lines = [];
174
180
  try {
175
181
  lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
@@ -178,8 +184,8 @@ function lockfileLocator(projectRoot) {
178
184
  }
179
185
  const uri = path.relative(repoRoot, file).split(path.sep).join('/');
180
186
 
181
- const locate = (name, version) => {
182
- const line = findEntryLine(lines, name, version);
187
+ const locate = (name, version, purl) => {
188
+ const line = inputFile ? findPurlLine(lines, purl) : findEntryLine(lines, name, version);
183
189
  return {
184
190
  physicalLocation: {
185
191
  artifactLocation: { uri, uriBaseId: '%SRCROOT%' },
@@ -209,6 +215,15 @@ function findEntryLine(lines, name, version) {
209
215
  return 1;
210
216
  }
211
217
 
218
+ /** Line of an SBOM that holds the purl (qualifiers may follow it). */
219
+ function findPurlLine(lines, purl) {
220
+ if (!purl) return 1;
221
+ const escaped = purl.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
222
+ const exact = new RegExp(`${escaped}(?![\\w.+-])`);
223
+ const index = lines.findIndex((line) => exact.test(line));
224
+ return index === -1 ? 1 : index + 1;
225
+ }
226
+
212
227
  /** Nearest folder with a .git entry: SARIF paths must be repository-relative. */
213
228
  function findRepoRoot(start) {
214
229
  let dir = path.resolve(start);