cra-audit 2.2.0 → 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 CHANGED
@@ -8,6 +8,16 @@ 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.3.0] — Python, Go and Java without an SBOM tool
12
+
13
+ ### Highlights
14
+
15
+ - **Native manifests**: `npx cra-audit` in a Python, Go or Java project reads `poetry.lock`, `uv.lock`, `Pipfile.lock` or `requirements*.txt`, `go.mod`, `gradle.lockfile` and `pom.xml` directly — no Syft/cdxgen step and no package.json needed. Same checks as npm: OSV vulnerabilities, malicious packages, CISA KEV with the Art. 14 clock, VEX and SARIF.
16
+ - Findings and SARIF alerts point at the exact line of the manifest.
17
+ - Only exact versions are audited: unpinned requirements, parent/BOM-managed Maven versions and version ranges are skipped and reported, never guessed. `pom.xml` covers declared dependencies (transitive ones need a Maven-generated SBOM).
18
+ - Folders with an npm lockfile *and* one of these manifests are audited as a whole.
19
+ - For these ecosystems the audit warns (instead of failing) about the missing TR-03183 SBOM and undeclared licenses; `sbom generate` explains how to produce one.
20
+
11
21
  ## [2.2.0] — Any language through its SBOM
12
22
 
13
23
  ### Highlights
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
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. 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)).
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. **Python, Go and Java** projects are read directly from `requirements.txt` / `poetry.lock` / `uv.lock` / `Pipfile.lock`, `go.mod`, `pom.xml` and `gradle.lockfile`, and **any other language** through the SBOM your tooling produces (`-i`) — [details](#8-other-languages-manifests-and-input-sbom).
9
9
  ```bash
10
10
  # Run ALL the law's checks (Vulnerabilities + Licenses + SBOM)
11
11
  npx cra-audit
@@ -151,7 +151,25 @@ 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`)
154
+ ### 8. Other languages: manifests and input SBOM
155
+
156
+ **Read directly — just run `npx cra-audit` in the project folder (no package.json needed):**
157
+
158
+ | Ecosystem | Files | Notes |
159
+ | --- | --- | --- |
160
+ | Python | `poetry.lock`, `uv.lock`, `Pipfile.lock` (exact); otherwise `requirements*.txt` | Only `==` pins are audited; ranges are skipped and reported, never guessed. `-r` includes, extras, markers and `--hash` lines are handled. |
161
+ | Go | `go.mod` | Every required module (direct and `// indirect`), with `replace` directives applied. |
162
+ | Java | `gradle.lockfile` (exact), `pom.xml` | `pom.xml`: declared dependencies with `${property}` and `<dependencyManagement>` resolution; transitive ones need a Maven-generated SBOM. |
163
+
164
+ Findings point at the exact line of the manifest (in the console and in SARIF). A folder with both an npm lockfile and one of these manifests (e.g. a front end next to a Go API) is audited as a whole. Manifests do not carry licenses or hashes, so for these ecosystems the audit **warns** instead of generating a TR-03183 SBOM: commit the one your build produces and audit it with `-i`.
165
+
166
+ ```text
167
+ Sources: OSV.dev … · 4 components from requirements.txt, go.mod, pom.xml (golang, maven, pypi)
168
+ pom.xml: declared dependencies only; commit a Maven-generated SBOM (cyclonedx-maven-plugin) to audit transitive ones
169
+ CRITICAL [KEV] org.apache.logging.log4j:log4j-core@2.14.1 (fix: org.apache.logging.log4j:log4j-core@2.25.4)
170
+ ```
171
+
172
+ **Input SBOM (`-i`), any language:**
155
173
 
156
174
  `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
175
 
@@ -346,7 +364,7 @@ Suitable for CI/CD: a non-`0` code blocks the pipeline.
346
364
 
347
365
  Available in the GitHub Marketplace as [CRA Compliance Audit](https://github.com/marketplace/actions/cra-compliance-audit).
348
366
 
349
- For a Python, Java, Go… project, generate the SBOM first and pass it with `sbom-input`:
367
+ Python, Go and Java projects work as they are (the action reads `requirements.txt`, `go.mod`, `pom.xml`…). For other languages, or to include Maven's transitive dependencies, generate the SBOM first and pass it with `sbom-input`:
350
368
 
351
369
  ```yaml
352
370
  - uses: anchore/sbom-action@v0 # Syft
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cra-audit",
3
- "version": "2.2.0",
3
+ "version": "2.3.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": {
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const path = require('node:path');
4
- const { findProjectRoot } = require('../utils/fs');
4
+ const { findAnyProjectRoot } = require('../core/project-source');
5
5
  const { logger } = require('../utils/logger');
6
6
  const { loadPolicy } = require('../core/policy');
7
7
  const { runAudit } = require('../core/auditor');
@@ -45,20 +45,24 @@ async function auditCommand(flags, only) {
45
45
  }
46
46
 
47
47
  /**
48
- * Project root: the nearest package.json, or with `-i <sbom>` the working
49
- * directory (any ecosystem, no package.json needed).
48
+ * Project root: with `-i <sbom>` the working directory; otherwise the nearest
49
+ * folder with a package.json or a supported manifest (requirements.txt,
50
+ * poetry.lock, uv.lock, Pipfile.lock, go.mod, pom.xml, gradle.lockfile).
50
51
  */
51
52
  function resolveRoot(flags) {
52
53
  const start = flags.cwd || process.cwd();
53
54
  if (inputPath(flags)) return path.resolve(start);
54
- const root = findProjectRoot(start);
55
+ const root = findAnyProjectRoot(start);
55
56
  if (!root) {
56
- logger.error('No package.json found. Run the command inside an npm project.');
57
+ logger.error(NO_PROJECT);
57
58
  return null;
58
59
  }
59
60
  return root;
60
61
  }
61
62
 
63
+ const NO_PROJECT = 'No project found: expected a package.json (npm/Yarn/pnpm), requirements.txt, ' +
64
+ 'poetry.lock, uv.lock, Pipfile.lock, go.mod, pom.xml or gradle.lockfile — or pass an SBOM with -i.';
65
+
62
66
  /** `-i/--input <sbom>` resolved against the working directory. */
63
67
  function inputPath(flags) {
64
68
  if (typeof flags.input !== 'string') return null;
@@ -78,4 +82,4 @@ function applyFlagOverrides(policy, flags) {
78
82
  return merged;
79
83
  }
80
84
 
81
- module.exports = { auditCommand, inputPath };
85
+ module.exports = { auditCommand, inputPath, resolveRoot };
@@ -1,111 +1,122 @@
1
- 'use strict';
2
-
3
- const path = require('node:path');
4
- const { findProjectRoot, readJson, writeJson, exists } = require('../utils/fs');
5
- const { logger, color } = require('../utils/logger');
6
- const { generateSbom } = require('../core/sbom-generator');
7
- const { validateSbom } = require('../core/sbom-validator');
8
- const { loadPolicy } = require('../core/policy');
9
-
10
- /**
11
- * `cra-audit sbom <generate|check>` and the `--sbom` shortcut.
12
- *
13
- * - generate: builds a CycloneDX/SPDX SBOM and prints it or writes it to disk.
14
- * - check: generates (or reads) an SBOM and validates the data fields
15
- * required by BSI TR-03183-2 v2.1.0 §5.2.
16
- */
17
- function sbomCommand(subcommand, flags) {
18
- const projectRoot = findProjectRoot(flags.cwd || process.cwd());
19
- if (!projectRoot) {
20
- logger.error('No package.json found. Run the command inside an npm project.');
21
- return 1;
22
- }
23
-
24
- const action = subcommand || 'generate';
25
- const format = flags.format || 'cyclonedx';
26
-
27
- if (action === 'check') {
28
- return sbomCheck(projectRoot, format, flags);
29
- }
30
- if (action === 'generate') {
31
- return sbomGenerate(projectRoot, format, flags);
32
- }
33
-
34
- logger.error(`Unknown SBOM subcommand: "${action}". Use "generate" or "check".`);
35
- return 1;
36
- }
37
-
38
- function sbomGenerate(projectRoot, format, flags) {
39
- const result = generateSbom(projectRoot, { format, creator: sbomCreator(projectRoot, flags) });
40
- if (!result.ok) {
41
- logger.error(result.error);
42
- return 1;
43
- }
44
-
45
- if (flags.output) {
46
- const outPath = path.isAbsolute(flags.output) ? flags.output : path.join(projectRoot, flags.output);
47
- writeJson(outPath, result.document);
48
- logger.success(`SBOM (${result.format}) with ${result.componentCount} components written to: ${outPath}`);
49
- } else {
50
- process.stdout.write(JSON.stringify(result.document, null, 2) + '\n');
51
- }
52
- return 0;
53
- }
54
-
55
- function sbomCheck(projectRoot, format, flags) {
56
- let document;
57
-
58
- // If an SBOM file is provided, validate it directly; otherwise generate one.
59
- if (flags.input) {
60
- const inPath = path.isAbsolute(flags.input) ? flags.input : path.join(projectRoot, flags.input);
61
- if (!exists(inPath)) {
62
- logger.error(`The specified SBOM was not found: ${flags.input}`);
63
- return 1;
64
- }
65
- document = readJson(inPath);
66
- } else {
67
- const result = generateSbom(projectRoot, { format, creator: sbomCreator(projectRoot, flags) });
68
- if (!result.ok) {
69
- logger.error(result.error);
70
- return 1;
71
- }
72
- document = result.document;
73
- }
74
-
75
- const validation = validateSbom(document);
76
- if (!validation.ok) {
77
- logger.error(validation.error);
78
- return 1;
79
- }
80
-
81
- if (flags.json) {
82
- process.stdout.write(JSON.stringify(validation, null, 2) + '\n');
83
- return validation.valid ? 0 : 1;
84
- }
85
-
86
- logger.heading('SBOM validation · BSI TR-03183-2 v2.1.0 (required data fields)');
87
- logger.detail(`Detected format: ${validation.format} · Components: ${validation.stats.total}`);
88
- for (const check of validation.checks) {
89
- const mark = check.passed ? color.green('✔') : color.red('✖');
90
- logger.log(` ${mark} ${check.label}`);
91
- }
92
- logger.log('');
93
- if (validation.valid) {
94
- logger.success(color.bold('SBOM VALID — meets the TR-03183-2 data fields required by the CRA.'));
95
- return 0;
96
- }
97
- logger.error(color.bold(`SBOM INVALID — ${validation.failedChecks.length} requirement(s) not met.`));
98
- return 1;
99
- }
100
-
101
- /** SBOM creator from --creator, else from the project policy (`sbomCreator`). */
102
- function sbomCreator(projectRoot, flags) {
103
- if (typeof flags.creator === 'string') return flags.creator;
104
- try {
105
- return loadPolicy(projectRoot, flags.config).policy.sbomCreator;
106
- } catch {
107
- return undefined; // A broken policy file must not block SBOM generation.
108
- }
109
- }
110
-
111
- module.exports = { sbomCommand };
1
+ 'use strict';
2
+
3
+ const path = require('node:path');
4
+ const { readJson, writeJson, exists } = require('../utils/fs');
5
+ const { logger, color } = require('../utils/logger');
6
+ const { generateSbom } = require('../core/sbom-generator');
7
+ const { validateSbom } = require('../core/sbom-validator');
8
+ const { loadPolicy } = require('../core/policy');
9
+ const { hasManifests } = require('../core/manifest-reader');
10
+ const { findAnyProjectRoot } = require('../core/project-source');
11
+
12
+ /**
13
+ * `cra-audit sbom <generate|check>` and the `--sbom` shortcut.
14
+ *
15
+ * - generate: builds a CycloneDX/SPDX SBOM and prints it or writes it to disk.
16
+ * - check: generates (or reads) an SBOM and validates the data fields
17
+ * required by BSI TR-03183-2 v2.1.0 §5.2.
18
+ */
19
+ function sbomCommand(subcommand, flags) {
20
+ const start = flags.cwd || process.cwd();
21
+ // The nearest project decides: a Python/Go/Java folder inside an npm repo is not npm.
22
+ const nearest = findAnyProjectRoot(start);
23
+ const npmProject = nearest && exists(path.join(nearest, 'package.json'));
24
+ const projectRoot = npmProject ? nearest : (flags.input ? path.resolve(start) : null);
25
+ if (!projectRoot) {
26
+ const other = nearest && hasManifests(nearest);
27
+ logger.error(other
28
+ ? 'cra-audit generates TR-03183 SBOMs for npm/Yarn/pnpm projects. For this ecosystem, generate the SBOM with ' +
29
+ 'your build tooling (Syft, cdxgen, cyclonedx-maven-plugin, cyclonedx-py…) and check it with `cra-audit sbom check -i <file>`; ' +
30
+ '`cra-audit` audits this project directly.'
31
+ : 'No package.json found. Run the command inside an npm project, or pass an SBOM with `sbom check -i <file>`.');
32
+ return 1;
33
+ }
34
+
35
+ const action = subcommand || 'generate';
36
+ const format = flags.format || 'cyclonedx';
37
+
38
+ if (action === 'check') {
39
+ return sbomCheck(projectRoot, format, flags);
40
+ }
41
+ if (action === 'generate') {
42
+ return sbomGenerate(projectRoot, format, flags);
43
+ }
44
+
45
+ logger.error(`Unknown SBOM subcommand: "${action}". Use "generate" or "check".`);
46
+ return 1;
47
+ }
48
+
49
+ function sbomGenerate(projectRoot, format, flags) {
50
+ const result = generateSbom(projectRoot, { format, creator: sbomCreator(projectRoot, flags) });
51
+ if (!result.ok) {
52
+ logger.error(result.error);
53
+ return 1;
54
+ }
55
+
56
+ if (flags.output) {
57
+ const outPath = path.isAbsolute(flags.output) ? flags.output : path.join(projectRoot, flags.output);
58
+ writeJson(outPath, result.document);
59
+ logger.success(`SBOM (${result.format}) with ${result.componentCount} components written to: ${outPath}`);
60
+ } else {
61
+ process.stdout.write(JSON.stringify(result.document, null, 2) + '\n');
62
+ }
63
+ return 0;
64
+ }
65
+
66
+ function sbomCheck(projectRoot, format, flags) {
67
+ let document;
68
+
69
+ // If an SBOM file is provided, validate it directly; otherwise generate one.
70
+ if (flags.input) {
71
+ const inPath = path.isAbsolute(flags.input) ? flags.input : path.join(projectRoot, flags.input);
72
+ if (!exists(inPath)) {
73
+ logger.error(`The specified SBOM was not found: ${flags.input}`);
74
+ return 1;
75
+ }
76
+ document = readJson(inPath);
77
+ } else {
78
+ const result = generateSbom(projectRoot, { format, creator: sbomCreator(projectRoot, flags) });
79
+ if (!result.ok) {
80
+ logger.error(result.error);
81
+ return 1;
82
+ }
83
+ document = result.document;
84
+ }
85
+
86
+ const validation = validateSbom(document);
87
+ if (!validation.ok) {
88
+ logger.error(validation.error);
89
+ return 1;
90
+ }
91
+
92
+ if (flags.json) {
93
+ process.stdout.write(JSON.stringify(validation, null, 2) + '\n');
94
+ return validation.valid ? 0 : 1;
95
+ }
96
+
97
+ logger.heading('SBOM validation · BSI TR-03183-2 v2.1.0 (required data fields)');
98
+ logger.detail(`Detected format: ${validation.format} · Components: ${validation.stats.total}`);
99
+ for (const check of validation.checks) {
100
+ const mark = check.passed ? color.green('✔') : color.red('✖');
101
+ logger.log(` ${mark} ${check.label}`);
102
+ }
103
+ logger.log('');
104
+ if (validation.valid) {
105
+ logger.success(color.bold('SBOM VALID — meets the TR-03183-2 data fields required by the CRA.'));
106
+ return 0;
107
+ }
108
+ logger.error(color.bold(`SBOM INVALID — ${validation.failedChecks.length} requirement(s) not met.`));
109
+ return 1;
110
+ }
111
+
112
+ /** SBOM creator from --creator, else from the project policy (`sbomCreator`). */
113
+ function sbomCreator(projectRoot, flags) {
114
+ if (typeof flags.creator === 'string') return flags.creator;
115
+ try {
116
+ return loadPolicy(projectRoot, flags.config).policy.sbomCreator;
117
+ } catch {
118
+ return undefined; // A broken policy file must not block SBOM generation.
119
+ }
120
+ }
121
+
122
+ module.exports = { sbomCommand };
@@ -1,14 +1,14 @@
1
1
  'use strict';
2
2
 
3
3
  const path = require('node:path');
4
- const { findProjectRoot, readJson, writeJson } = require('../utils/fs');
4
+ const { readJson, writeJson } = require('../utils/fs');
5
5
  const { logger } = require('../utils/logger');
6
6
  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
+ const { loadProject } = require('../core/project-source');
11
+ const { inputPath, resolveRoot } = require('./audit');
12
12
 
13
13
  /**
14
14
  * `cra-audit vex` — writes a VEX document (CycloneDX 1.6 or OpenVEX 0.2.0)
@@ -19,12 +19,8 @@ const { inputPath } = require('./audit');
19
19
  */
20
20
  async function vexCommand(flags) {
21
21
  const input = inputPath(flags);
22
- const start = flags.cwd || process.cwd();
23
- const projectRoot = input ? path.resolve(start) : findProjectRoot(start);
24
- if (!projectRoot) {
25
- logger.error('No package.json found. Run the command inside an npm project.');
26
- return 1;
27
- }
22
+ const projectRoot = resolveRoot(flags);
23
+ if (!projectRoot) return 1;
28
24
 
29
25
  let policy;
30
26
  try {
@@ -40,7 +36,7 @@ async function vexCommand(flags) {
40
36
  return 1;
41
37
  }
42
38
 
43
- const parsed = input ? readSbom(input) : null;
39
+ const { mode, parsed } = loadProject(projectRoot, { input });
44
40
  if (parsed && !parsed.ok) {
45
41
  logger.error(parsed.error);
46
42
  return 1;
@@ -63,7 +59,7 @@ async function vexCommand(flags) {
63
59
  const pkg = readJson(path.join(projectRoot, 'package.json')) || {};
64
60
  const creator = creatorFromManifest(pkg);
65
61
  const author = policy.sbomCreator || (creator && (creator.email || creator.url)) || null;
66
- const product = parsed
62
+ const product = parsed && mode !== 'npm+manifest'
67
63
  ? { name: parsed.root.name, version: parsed.root.version, purl: parsed.root.purl }
68
64
  : { name: pkg.name || path.basename(projectRoot), version: pkg.version || '0.0.0' };
69
65
  const document = buildVex(product, vulns, policy, { format, author });
@@ -5,7 +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
+ const { loadProject } = require('./project-source');
9
9
 
10
10
  /**
11
11
  * @typedef {object} AuditResult
@@ -19,12 +19,13 @@ const { readSbom } = require('./sbom-reader');
19
19
  * Runs the full CRA audit: vulnerabilities + SBOM + licenses, then evaluates
20
20
  * the result against the configured security policy.
21
21
  *
22
+ * The components come from the npm lockfile, an input SBOM (`input`, any
23
+ * ecosystem) or, without an npm lockfile, the manifests of other ecosystems
24
+ * (requirements.txt, poetry.lock, go.mod, pom.xml…). See ./project-source.js.
25
+ *
22
26
  * @param {string} projectRoot
23
27
  * @param {object} policy
24
28
  * @param {string|null} policySource
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
29
  * @param {{ only?: 'vulnerabilities'|'sbom'|'licenses', input?: string }} [options]
29
30
  * @returns {Promise<AuditResult>}
30
31
  */
@@ -32,7 +33,9 @@ async function runAudit(projectRoot, policy, policySource, options = {}) {
32
33
  const { only, input } = options;
33
34
  const sections = {};
34
35
  const reasons = [];
35
- const parsed = input ? readSbom(input) : null;
36
+ const project = loadProject(projectRoot, { input });
37
+ const parsed = project.parsed;
38
+ const fromManifests = project.mode === 'manifest';
36
39
 
37
40
  // --- 1. Vulnerabilities -------------------------------------------------
38
41
  if (!only || only === 'vulnerabilities') {
@@ -62,7 +65,21 @@ async function runAudit(projectRoot, policy, policySource, options = {}) {
62
65
  }
63
66
 
64
67
  // --- 2. SBOM ------------------------------------------------------------
65
- if ((!only || only === 'sbom') && parsed) {
68
+ if ((!only || only === 'sbom') && fromManifests) {
69
+ // TR-03183 SBOMs (hashes, creators, graph) are built from npm lockfiles;
70
+ // other ecosystems should commit the one their build tooling produces.
71
+ sections.sbom = {
72
+ ok: true, manifest: true, format: 'manifest', componentCount: parsed.components.length,
73
+ files: parsed.files, ecosystems: parsed.ecosystems,
74
+ };
75
+ if (policy.requireSbom) {
76
+ reasons.push({
77
+ label: `No SBOM generated for ${parsed.ecosystems.join(', ')}: commit one from your build tooling (Syft, cdxgen, CycloneDX plugins) and audit it with \`-i\``,
78
+ passed: true,
79
+ warning: true,
80
+ });
81
+ }
82
+ } else if ((!only || only === 'sbom') && project.mode === 'sbom') {
66
83
  // An input SBOM comes from third-party tooling that rarely carries the
67
84
  // manufacturer fields TR-03183 requires: report gaps without blocking.
68
85
  const sbom = parsed.ok
@@ -103,11 +120,18 @@ async function runAudit(projectRoot, policy, policySource, options = {}) {
103
120
 
104
121
  // --- 3. Licenses --------------------------------------------------------
105
122
  if (!only || only === 'licenses') {
106
- const licenses = checkLicenses(projectRoot, policy.licenses, { parsed });
123
+ const licenses = checkLicenses(projectRoot, policy.licenses, { parsed: project.mode === 'npm+manifest' ? null : parsed });
107
124
  sections.licenses = licenses;
108
125
 
109
126
  if (!licenses.ok) {
110
127
  reasons.push({ label: `Licenses could not be analyzed: ${licenses.error}`, passed: false });
128
+ } else if (fromManifests) {
129
+ // Manifests do not declare licenses: report the gap instead of failing.
130
+ reasons.push({
131
+ label: `Licenses are not declared in ${parsed.files.join(', ')}: ${licenses.summary.missing.length} component(s) to review in your SBOM`,
132
+ passed: true,
133
+ warning: licenses.summary.missing.length > 0,
134
+ });
111
135
  } else {
112
136
  const s = licenses.summary;
113
137
  const missingFails = policy.licenses.failOnMissing && s.missing.length > 0;
@@ -122,8 +146,8 @@ async function runAudit(projectRoot, policy, policySource, options = {}) {
122
146
  }
123
147
 
124
148
  return {
125
- project: parsed && parsed.ok
126
- ? { name: parsed.root.name, version: parsed.root.version, sbom: parsed.sourceFile }
149
+ project: parsed && parsed.ok && project.mode !== 'npm+manifest'
150
+ ? { name: parsed.root.name, version: parsed.root.version, ...(project.mode === 'sbom' ? { sbom: parsed.sourceFile } : { manifests: parsed.files }) }
127
151
  : getProject(projectRoot, sections),
128
152
  policySource,
129
153
  generatedAt: new Date().toISOString(),
@@ -32,6 +32,7 @@ function checkLicenses(projectRoot, policy = {}, { parsed: given } = {}) {
32
32
  name: c.name,
33
33
  version: c.version,
34
34
  purl: c.purl || null,
35
+ location: c.location || null,
35
36
  license: normalized,
36
37
  status, // 'ok' | 'missing' | 'denied' | 'not-allowed'
37
38
  };
@@ -0,0 +1,374 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ /**
7
+ * Reads the dependency manifests of non-npm ecosystems straight from the
8
+ * repository, without an SBOM generator:
9
+ *
10
+ * - Python: poetry.lock, uv.lock, Pipfile.lock (exact, preferred) or
11
+ * requirements*.txt (only `==` pins; `-r` includes are followed)
12
+ * - Go: go.mod (every required module, direct and `// indirect`)
13
+ * - Java: gradle.lockfile (exact), pom.xml (declared dependencies with
14
+ * `${property}` and dependencyManagement resolution)
15
+ *
16
+ * Returns the same shape as readSbom(), so the OSV/KEV scan, VEX and SARIF
17
+ * work unchanged. Every component records the file and line it came from.
18
+ * Versions that are not pinned cannot be audited reliably: they are skipped
19
+ * and reported, never guessed.
20
+ *
21
+ * @param {string} projectRoot
22
+ * @returns {null | {
23
+ * ok: true, manager: 'manifest', purlBased: true, format: 'manifest',
24
+ * lockfileName: string, sourceFile: null, files: string[],
25
+ * root: { name: string, version: string, purl: string, dependsOn: string[] },
26
+ * components: object[], unidentified: number, notes: string[],
27
+ * dependencyGraphComplete: false, ecosystems: string[]
28
+ * }} null when no supported manifest exists.
29
+ */
30
+ function readManifests(projectRoot) {
31
+ const found = [];
32
+ const notes = [];
33
+ let skipped = 0;
34
+
35
+ const collect = (result) => {
36
+ if (!result) return;
37
+ found.push(result);
38
+ skipped += result.skipped || 0;
39
+ if (result.note) notes.push(result.note);
40
+ };
41
+
42
+ // Python: an exact lockfile wins over requirements files.
43
+ const pyLock = firstExisting(projectRoot, ['poetry.lock', 'uv.lock', 'Pipfile.lock']);
44
+ if (pyLock) {
45
+ collect(pyLock === 'Pipfile.lock' ? readPipfileLock(projectRoot, pyLock) : readTomlLock(projectRoot, pyLock));
46
+ } else {
47
+ for (const file of requirementsFiles(projectRoot)) collect(readRequirements(projectRoot, file));
48
+ }
49
+
50
+ if (exists(projectRoot, 'go.mod')) collect(readGoMod(projectRoot, 'go.mod'));
51
+ if (exists(projectRoot, 'gradle.lockfile')) collect(readGradleLock(projectRoot, 'gradle.lockfile'));
52
+ if (exists(projectRoot, 'pom.xml')) collect(readPom(projectRoot, 'pom.xml'));
53
+
54
+ if (!found.length) return null;
55
+
56
+ const components = dedupe(found.flatMap((r) => r.components));
57
+ const files = found.map((r) => r.file);
58
+ const name = path.basename(path.resolve(projectRoot));
59
+ const rootName = found.map((r) => r.rootName).find(Boolean) || name;
60
+ const rootVersion = found.map((r) => r.rootVersion).find(Boolean) || '0.0.0';
61
+ return {
62
+ ok: true,
63
+ manager: 'manifest',
64
+ purlBased: true,
65
+ format: 'manifest',
66
+ lockfileName: files.join(', '),
67
+ sourceFile: null,
68
+ files,
69
+ root: {
70
+ name: rootName,
71
+ version: rootVersion,
72
+ purl: `pkg:generic/${encodeURIComponent(rootName)}@${encodeURIComponent(rootVersion)}`,
73
+ dependsOn: components.filter((c) => c.direct).map((c) => c.key),
74
+ },
75
+ components,
76
+ unidentified: skipped,
77
+ notes,
78
+ dependencyGraphComplete: false,
79
+ ecosystems: [...new Set(components.map((c) => c.ecosystem))].sort(),
80
+ };
81
+ }
82
+
83
+ /** True when the folder holds a manifest this reader understands. */
84
+ function hasManifests(projectRoot) {
85
+ return ['poetry.lock', 'uv.lock', 'Pipfile.lock', 'go.mod', 'gradle.lockfile', 'pom.xml']
86
+ .some((f) => exists(projectRoot, f)) || requirementsFiles(projectRoot).length > 0;
87
+ }
88
+
89
+ // --- Python ------------------------------------------------------------------------
90
+
91
+ function requirementsFiles(root) {
92
+ let names = [];
93
+ try {
94
+ names = fs.readdirSync(root);
95
+ } catch {
96
+ return [];
97
+ }
98
+ return names.filter((n) => /^requirements([-_.][\w.-]+)?\.txt$/i.test(n)).sort();
99
+ }
100
+
101
+ /**
102
+ * pip requirements: `name==1.2.3`, extras, environment markers, `--hash`
103
+ * continuation lines and `-r other.txt` includes. Anything that is not an
104
+ * exact pin (ranges, URLs, editable installs) is skipped and counted.
105
+ */
106
+ function readRequirements(root, file, seen = new Set()) {
107
+ const full = path.join(root, file);
108
+ if (seen.has(full)) return { file, components: [], skipped: 0 };
109
+ seen.add(full);
110
+ const components = [];
111
+ let skipped = 0;
112
+ const dev = /dev|test|lint|doc/i.test(path.basename(file));
113
+
114
+ lines(full).forEach((raw, index) => {
115
+ const line = raw.replace(/\s+#.*$/, '').replace(/\\$/, '').trim();
116
+ if (!line || line.startsWith('#') || line.startsWith('--hash')) return;
117
+ const include = line.match(/^(?:-r|--requirement)\s+(.+)$/);
118
+ if (include) {
119
+ const nested = readRequirements(root, path.join(path.dirname(file), include[1].trim()), seen);
120
+ components.push(...nested.components);
121
+ skipped += nested.skipped;
122
+ return;
123
+ }
124
+ if (line.startsWith('-')) return; // pip options: -c, -i, --index-url, -e…
125
+ const spec = line.split(';')[0].trim();
126
+ const m = spec.match(/^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?\s*===?\s*([^\s,]+)$/);
127
+ if (m) components.push(pypi(m[1], m[2], file, index + 1, { dev, direct: true }));
128
+ else skipped++;
129
+ });
130
+ const note = skipped ? `${file}: ${skipped} requirement(s) without an exact == pin were not audited` : null;
131
+ return { file, components, skipped, note };
132
+ }
133
+
134
+ /** poetry.lock / uv.lock: `[[package]]` tables with `name` and `version`. */
135
+ function readTomlLock(root, file) {
136
+ const components = [];
137
+ let current = null;
138
+ let rootName = null;
139
+ let rootVersion = null;
140
+ const flush = () => {
141
+ if (!current || !current.name || !current.version) return;
142
+ if (current.local) {
143
+ rootName = rootName || current.name;
144
+ rootVersion = rootVersion || current.version;
145
+ } else {
146
+ components.push(pypi(current.name, current.version, file, current.line, { direct: false }));
147
+ }
148
+ };
149
+ lines(path.join(root, file)).forEach((raw, index) => {
150
+ const line = raw.trim();
151
+ if (line === '[[package]]') {
152
+ flush();
153
+ current = { line: index + 1 };
154
+ return;
155
+ }
156
+ if (!current) return;
157
+ if (/^\[/.test(line)) { // a sub-table: [package.dependencies], [package.source]…
158
+ if (!line.startsWith('[package.')) { flush(); current = null; }
159
+ return;
160
+ }
161
+ const kv = line.match(/^(name|version)\s*=\s*"([^"]+)"/);
162
+ if (kv && !current[kv[1]]) current[kv[1]] = kv[2];
163
+ // uv marks the project itself with an editable/virtual source.
164
+ if (/^source\s*=\s*\{\s*(editable|virtual)\s*=/.test(line)) current.local = true;
165
+ });
166
+ flush();
167
+ return { file, components, rootName, rootVersion };
168
+ }
169
+
170
+ /** Pipfile.lock (JSON): `default` and `develop` maps of `"==version"`. */
171
+ function readPipfileLock(root, file) {
172
+ let doc;
173
+ try {
174
+ doc = JSON.parse(fs.readFileSync(path.join(root, file), 'utf8'));
175
+ } catch {
176
+ return { file, components: [], skipped: 0, note: `${file} could not be parsed` };
177
+ }
178
+ const text = lines(path.join(root, file));
179
+ const components = [];
180
+ for (const [group, dev] of [['default', false], ['develop', true]]) {
181
+ for (const [name, info] of Object.entries(doc[group] || {})) {
182
+ const version = info && typeof info.version === 'string' ? info.version.replace(/^===?/, '') : null;
183
+ if (!version) continue;
184
+ const line = text.findIndex((l) => l.includes(`"${name}": {`)) + 1 || 1;
185
+ components.push(pypi(name, version, file, line, { dev, direct: true }));
186
+ }
187
+ }
188
+ return { file, components };
189
+ }
190
+
191
+ function pypi(name, version, file, line, { dev = false, direct = false } = {}) {
192
+ // PEP 503 normalisation, as purl and OSV use it.
193
+ const normalized = name.toLowerCase().replace(/[-_.]+/g, '-');
194
+ return component({
195
+ ecosystem: 'pypi', name: normalized, osvName: normalized, version,
196
+ purl: `pkg:pypi/${normalized}@${version}`, file, line, dev, direct,
197
+ });
198
+ }
199
+
200
+ // --- Go ------------------------------------------------------------------------------
201
+
202
+ /** go.mod `require` directives (single and block form); `replace` is applied. */
203
+ function readGoMod(root, file) {
204
+ const text = lines(path.join(root, file));
205
+ const requires = [];
206
+ const replaces = new Map();
207
+ let block = null;
208
+ let moduleName = null;
209
+
210
+ text.forEach((raw, index) => {
211
+ const line = raw.replace(/\/\/(?!\s*indirect).*$/, '').trim();
212
+ const mod = line.match(/^module\s+(\S+)/);
213
+ if (mod) moduleName = mod[1];
214
+ if (/^(require|replace|exclude|retract)\s*\($/.test(line)) { block = line.split(/\s/)[0]; return; }
215
+ if (line === ')') { block = null; return; }
216
+ const single = line.match(/^(require|replace)\s+(.+)$/);
217
+ const kind = single ? single[1] : block;
218
+ const body = single ? single[2] : line;
219
+ if (kind === 'require') {
220
+ const m = body.match(/^(\S+)\s+(v\S+)(\s+\/\/\s*indirect)?/);
221
+ if (m) requires.push({ module: m[1], version: m[2], indirect: Boolean(m[3]), line: index + 1 });
222
+ } else if (kind === 'replace') {
223
+ const m = body.match(/^(\S+)(?:\s+v\S+)?\s+=>\s+(\S+)\s+(v\S+)/);
224
+ if (m) replaces.set(m[1], { module: m[2], version: m[3] });
225
+ }
226
+ });
227
+
228
+ const components = requires.map((r) => {
229
+ const target = replaces.get(r.module) || { module: r.module, version: r.version };
230
+ return component({
231
+ ecosystem: 'golang', name: target.module, osvName: target.module, version: target.version,
232
+ purl: `pkg:golang/${target.module}@${target.version}`, file, line: r.line, direct: !r.indirect,
233
+ });
234
+ });
235
+ return { file, components, rootName: moduleName ? moduleName.split('/').pop() : null };
236
+ }
237
+
238
+ // --- Java --------------------------------------------------------------------------------
239
+
240
+ /** gradle.lockfile: `group:artifact:version=configurations`. */
241
+ function readGradleLock(root, file) {
242
+ const components = [];
243
+ lines(path.join(root, file)).forEach((raw, index) => {
244
+ const m = raw.trim().match(/^([^:#\s]+):([^:\s]+):([^=\s]+)=(.*)$/);
245
+ if (!m) return;
246
+ const configs = m[4];
247
+ const dev = configs && !/(runtimeClasspath|compileClasspath)/.test(configs);
248
+ components.push(maven(m[1], m[2], m[3], file, index + 1, { dev, direct: false }));
249
+ });
250
+ return { file, components };
251
+ }
252
+
253
+ /**
254
+ * pom.xml declared dependencies. Versions come from the dependency itself,
255
+ * `${property}` values in <properties> (and project.version) or the
256
+ * <dependencyManagement> section. Transitive dependencies need Maven to be
257
+ * resolved, so only the declared ones are audited.
258
+ */
259
+ function readPom(root, file) {
260
+ const xml = fs.readFileSync(path.join(root, file), 'utf8');
261
+ const stripped = xml.replace(/<!--[\s\S]*?-->/g, (c) => c.replace(/[^\n]/g, ' '));
262
+
263
+ const tag = (block, name) => {
264
+ const m = block.match(new RegExp(`<${name}>\\s*([^<]*?)\\s*</${name}>`));
265
+ return m ? m[1] : null;
266
+ };
267
+ // Top-level project fields, ignoring <parent>, <dependencies> and the like.
268
+ const topLevel = stripped
269
+ .replace(/<parent>[\s\S]*?<\/parent>/, '')
270
+ .replace(/<(dependencies|dependencyManagement|build|profiles|plugins|reporting)>[\s\S]*?<\/\1>/g, '');
271
+ const parentBlock = (stripped.match(/<parent>([\s\S]*?)<\/parent>/) || [])[1] || '';
272
+ const props = {
273
+ 'project.version': tag(topLevel, 'version') || tag(parentBlock, 'version'),
274
+ 'project.groupId': tag(topLevel, 'groupId') || tag(parentBlock, 'groupId'),
275
+ };
276
+ const propsBlock = (stripped.match(/<properties>([\s\S]*?)<\/properties>/) || [])[1] || '';
277
+ for (const m of propsBlock.matchAll(/<([\w.-]+)>\s*([^<]*?)\s*<\/\1>/g)) props[m[1]] = m[2];
278
+ const resolve = (value) => {
279
+ let v = value;
280
+ for (let i = 0; i < 5 && v && /\$\{([^}]+)\}/.test(v); i++) {
281
+ v = v.replace(/\$\{([^}]+)\}/g, (_, key) => (props[key] !== undefined && props[key] !== null ? props[key] : `\${${key}}`));
282
+ }
283
+ return v;
284
+ };
285
+
286
+ const managedBlock = (stripped.match(/<dependencyManagement>([\s\S]*?)<\/dependencyManagement>/) || [])[1] || '';
287
+ const managed = new Map();
288
+ for (const m of managedBlock.matchAll(/<dependency>([\s\S]*?)<\/dependency>/g)) {
289
+ const g = resolve(tag(m[1], 'groupId'));
290
+ const a = resolve(tag(m[1], 'artifactId'));
291
+ const v = resolve(tag(m[1], 'version'));
292
+ if (g && a && v) managed.set(`${g}:${a}`, v);
293
+ }
294
+
295
+ const components = [];
296
+ let skipped = 0;
297
+ const outside = stripped.replace(/<dependencyManagement>[\s\S]*?<\/dependencyManagement>/, (c) => c.replace(/[^\n]/g, ' '));
298
+ for (const m of outside.matchAll(/<dependency>([\s\S]*?)<\/dependency>/g)) {
299
+ const block = m[1];
300
+ const g = resolve(tag(block, 'groupId'));
301
+ const a = resolve(tag(block, 'artifactId'));
302
+ let v = resolve(tag(block, 'version')) || managed.get(`${g}:${a}`) || null;
303
+ if (!g || !a || !v || /\$\{|[[\]()]/.test(v)) { skipped++; continue; } // unresolved or a version range
304
+ const scope = tag(block, 'scope');
305
+ const line = outside.slice(0, m.index).split('\n').length + block.slice(0, block.indexOf('<artifactId>')).split('\n').length - 1;
306
+ components.push(maven(g, a, v, file, line, { dev: scope === 'test' || scope === 'provided', direct: true }));
307
+ }
308
+ const notes = ['pom.xml: declared dependencies only; commit a Maven-generated SBOM (cyclonedx-maven-plugin) to audit transitive ones'];
309
+ if (skipped) notes.push(`pom.xml: ${skipped} dependency(ies) with a version inherited from a parent/BOM or a range were not audited`);
310
+ return {
311
+ file, components, skipped, note: notes.join('; '),
312
+ rootName: tag(topLevel, 'artifactId'), rootVersion: props['project.version'],
313
+ };
314
+ }
315
+
316
+ function maven(group, artifact, version, file, line, { dev = false, direct = false } = {}) {
317
+ return component({
318
+ ecosystem: 'maven', name: `${group}:${artifact}`, osvName: `${group}:${artifact}`, version,
319
+ purl: `pkg:maven/${group}/${artifact}@${version}`, file, line, dev, direct,
320
+ });
321
+ }
322
+
323
+ // --- Shared ------------------------------------------------------------------------------
324
+
325
+ function component({ ecosystem, name, osvName, version, purl, file, line, dev = false, direct = false }) {
326
+ return {
327
+ key: purl,
328
+ name,
329
+ version,
330
+ purl,
331
+ ecosystem,
332
+ osvName,
333
+ license: null,
334
+ scope: dev ? 'optional' : 'required',
335
+ dev,
336
+ optional: dev,
337
+ direct,
338
+ dependsOn: [],
339
+ location: { file, line },
340
+ };
341
+ }
342
+
343
+ function dedupe(components) {
344
+ const seen = new Map();
345
+ for (const c of components) {
346
+ const existing = seen.get(c.key);
347
+ if (!existing) seen.set(c.key, c);
348
+ else {
349
+ existing.direct = existing.direct || c.direct;
350
+ existing.optional = existing.optional && c.optional;
351
+ existing.dev = existing.optional;
352
+ existing.scope = existing.optional ? 'optional' : 'required';
353
+ }
354
+ }
355
+ return [...seen.values()].sort((a, b) => a.key.localeCompare(b.key));
356
+ }
357
+
358
+ function lines(file) {
359
+ try {
360
+ return fs.readFileSync(file, 'utf8').split(/\r?\n/);
361
+ } catch {
362
+ return [];
363
+ }
364
+ }
365
+
366
+ function exists(root, file) {
367
+ return fs.existsSync(path.join(root, file));
368
+ }
369
+
370
+ function firstExisting(root, files) {
371
+ return files.find((f) => exists(root, f)) || null;
372
+ }
373
+
374
+ module.exports = { readManifests, hasManifests };
@@ -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:
@@ -106,9 +107,12 @@ function checkReadiness(projectRoot, { now = new Date() } = {}) {
106
107
  // an SBOM produced by their own tooling (Syft, cdxgen…) kept in the repo.
107
108
  const parsed = parseLockfile(projectRoot);
108
109
  const sbomFile = parsed.ok ? null : findSbomFile(projectRoot);
109
- add('sbom', 'SBOM available (npm lockfile or an SBOM file)', 'required', parsed.ok || sbomFile,
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,
110
112
  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).`,
113
+ : sbomFile ? `Found ${sbomFile}`
114
+ : manifests ? `${manifests.files.join(', ')} (${manifests.components.length} components)`
115
+ : 'No lockfile, supported manifest or SBOM file found.',
112
116
  'CRA Annex I Part II (1)');
113
117
 
114
118
  add('package-repository', 'package.json links the source repository', 'recommended', Boolean(repositoryUrl(pkg.repository)),
@@ -45,6 +45,7 @@ function readSbom(file) {
45
45
  return {
46
46
  ok: true,
47
47
  manager: 'sbom',
48
+ purlBased: true,
48
49
  format: result.format,
49
50
  lockfileName: path.basename(file),
50
51
  sourceFile: file,
@@ -25,19 +25,19 @@ 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.
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
31
  *
32
32
  * @param {string} projectRoot
33
33
  * @param {{ production?: boolean, source?: 'osv'|'npm', kev?: boolean, parsed?: object }} [options]
34
34
  * @returns {Promise<object>}
35
35
  */
36
36
  async function scanVulnerabilities(projectRoot, { production = false, source = 'osv', kev = true, parsed: given } = {}) {
37
- const fromSbom = Boolean(given && given.manager === 'sbom');
37
+ const fromSbom = Boolean(given && given.purlBased);
38
38
  if (source === 'npm') {
39
39
  if (fromSbom) {
40
- return { ok: false, error: '`npm audit` cannot scan an input SBOM; use the default OSV source.', counts: emptyCounts(), vulnerabilities: [] };
40
+ return { ok: false, error: '`npm audit` only scans npm projects; use the default OSV source.', counts: emptyCounts(), vulnerabilities: [] };
41
41
  }
42
42
  return npmAuditSection(projectRoot, { production });
43
43
  }
@@ -88,7 +88,9 @@ async function scanVulnerabilities(projectRoot, { production = false, source = '
88
88
  ok: true,
89
89
  source: 'osv',
90
90
  scanned: scannable.length,
91
- input: fromSbom ? { file: parsed.sourceFile, format: parsed.format, unidentified: parsed.unidentified } : null,
91
+ input: fromSbom
92
+ ? { file: parsed.sourceFile || parsed.lockfileName, format: parsed.format, unidentified: parsed.unidentified, notes: parsed.notes || [] }
93
+ : null,
92
94
  ecosystems: [...new Set(scannable.map((c) => c.ecosystem || 'npm'))].sort(),
93
95
  kev: catalogue
94
96
  ? (catalogue.ok
@@ -135,6 +137,7 @@ function toFinding(component, records, isDirect, catalogue) {
135
137
  version: component.version,
136
138
  purl: component.purl || buildPurl(component.name, component.version),
137
139
  ecosystem: component.ecosystem || 'npm',
140
+ location: component.location || null,
138
141
  severity,
139
142
  direct: isDirect,
140
143
  range: null,
@@ -169,7 +172,8 @@ function productionComponents(parsed, rootPkg) {
169
172
  if (!seen.has(dep)) { seen.add(dep); queue.push(dep); }
170
173
  }
171
174
  }
172
- 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))));
173
177
  }
174
178
 
175
179
  function severityRank(severity) {
@@ -38,6 +38,7 @@ function renderVulnerabilities(section) {
38
38
  }
39
39
  logger.detail(describeSources(section));
40
40
  for (const warning of section.warnings || []) logger.warn(warning);
41
+ for (const note of (section.input && section.input.notes) || []) logger.detail(note);
41
42
 
42
43
  const c = section.counts;
43
44
  if (c.total === 0) {
@@ -89,9 +90,9 @@ function describeSources(section) {
89
90
  ? `CISA KEV ${section.kev.catalogVersion || ''} (${section.kev.entries} entries)`.replace(' ', ' ')
90
91
  : `CISA KEV not checked (${(section.kev && section.kev.error) || 'unavailable'})`;
91
92
  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`;
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(', ')})` : ''}`;
95
96
  return `Sources: OSV.dev (GitHub advisories + OpenSSF malicious packages) · ${kev} · ${scope}`;
96
97
  }
97
98
 
@@ -121,6 +122,12 @@ function renderSbom(section) {
121
122
  logger.error(section.error);
122
123
  return;
123
124
  }
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
+ }
124
131
  logger.detail(`${section.input ? `Input SBOM: ${shortPath(section.input)} · ` : ''}Format: ${section.format} · Components: ${section.componentCount}`);
125
132
 
126
133
  if (section.validation) {
@@ -30,7 +30,8 @@ function buildSarif(report, projectRoot, policy = {}) {
30
30
  const rules = new Map();
31
31
  const results = [];
32
32
  const vulns = report.sections.vulnerabilities;
33
- const inputFile = vulns && vulns.input ? vulns.input.file : null;
33
+ // An input SBOM is searched for purls; manifest findings carry their own location.
34
+ const inputFile = vulns && vulns.input && vulns.input.format !== 'manifest' ? vulns.input.file : null;
34
35
  const lock = lockfileLocator(projectRoot, inputFile);
35
36
  const assessments = normalizeAllowlist(policy);
36
37
 
@@ -47,7 +48,7 @@ function buildSarif(report, projectRoot, policy = {}) {
47
48
  : finding.malicious || source.kev || ['critical', 'high'].includes(source.severity || finding.severity) ? 'error'
48
49
  : (source.severity || finding.severity) === 'moderate' ? 'warning' : 'note',
49
50
  message: { text: vulnerabilityMessage(finding, source) },
50
- locations: [lock.locate(finding.name, finding.version, finding.purl)],
51
+ locations: [finding.location ? lock.at(finding.location) : lock.locate(finding.name, finding.version, finding.purl)],
51
52
  partialFingerprints: { 'craAudit/v1': `${ruleId}:${finding.name}@${finding.version || ''}` },
52
53
  };
53
54
  if (accepted) {
@@ -71,14 +72,17 @@ function buildSarif(report, projectRoot, policy = {}) {
71
72
  ['cra-audit/license-missing', s.missing, 'warning', (c) => `${c.name}@${c.version} has no documented license (TR-03183-2 §5.2.2).`],
72
73
  ];
73
74
  for (const [ruleId, list, level, text] of groups) {
74
- if (!list.length) continue;
75
+ // Manifests (requirements.txt, go.mod…) never declare licenses: that gap is
76
+ // reported once by the audit, not as one alert per dependency.
77
+ const relevant = ruleId === 'cra-audit/license-missing' ? list.filter((c) => !c.location) : list;
78
+ if (!relevant.length) continue;
75
79
  rules.set(ruleId, licenseRule(ruleId));
76
- for (const c of list) {
80
+ for (const c of relevant) {
77
81
  results.push({
78
82
  ruleId,
79
83
  level,
80
84
  message: { text: text(c) },
81
- locations: [lock.locate(c.name, c.version, c.purl)],
85
+ locations: [c.location ? lock.at(c.location) : lock.locate(c.name, c.version, c.purl)],
82
86
  partialFingerprints: { 'craAudit/v1': `${ruleId}:${c.name}@${c.version}` },
83
87
  });
84
88
  }
@@ -193,7 +197,14 @@ function lockfileLocator(projectRoot, inputFile = null) {
193
197
  },
194
198
  };
195
199
  };
196
- return { repoRoot, locate };
200
+ // A known file + line (components read from manifests).
201
+ const at = ({ file: rel, line }) => ({
202
+ physicalLocation: {
203
+ artifactLocation: { uri: path.relative(repoRoot, path.resolve(projectRoot, rel)).split(path.sep).join('/'), uriBaseId: '%SRCROOT%' },
204
+ region: { startLine: line || 1 },
205
+ },
206
+ });
207
+ return { repoRoot, locate, at };
197
208
  }
198
209
 
199
210
  function findEntryLine(lines, name, version) {