cra-audit 2.1.0 → 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 +18 -0
- package/README.md +48 -3
- package/package.json +1 -1
- package/src/cli.js +5 -1
- package/src/commands/audit.js +81 -69
- package/src/commands/readiness.js +17 -6
- package/src/commands/vex.js +14 -2
- package/src/core/auditor.js +31 -5
- package/src/core/license-checker.js +9 -3
- package/src/core/osv.js +30 -14
- package/src/core/readiness.js +22 -2
- package/src/core/sbom-reader.js +296 -0
- package/src/core/sbom-validator.js +9 -3
- package/src/core/vex.js +3 -3
- package/src/core/vuln-scanner.js +29 -10
- package/src/reporters/console.js +13 -2
- package/src/reporters/sarif.js +26 -11
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,24 @@ 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
|
+
|
|
25
|
+
## [2.1.1] — GitHub Marketplace
|
|
26
|
+
|
|
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`.
|
|
28
|
+
|
|
11
29
|
## [2.1.0] — VEX, SARIF & GitHub Action, CRA readiness
|
|
12
30
|
|
|
13
31
|
### Highlights
|
package/README.md
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/cra-audit) [](https://github.com/migohe14/cra-audit/actions/workflows/ci.yml) [](LICENSE)
|
|
4
4
|
|
|
5
|
-
> Compliance audit for the **Cyber Resilience Act** (Regulation EU 2024/2847) and the **BSI TR-03183** technical guideline
|
|
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
|
|
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. |
|
|
@@ -313,6 +344,19 @@ Suitable for CI/CD: a non-`0` code blocks the pipeline.
|
|
|
313
344
|
|
|
314
345
|
## GitHub Action
|
|
315
346
|
|
|
347
|
+
Available in the GitHub Marketplace as [CRA Compliance Audit](https://github.com/marketplace/actions/cra-compliance-audit).
|
|
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
|
+
|
|
316
360
|
```yaml
|
|
317
361
|
name: CRA audit
|
|
318
362
|
on: [push, pull_request]
|
|
@@ -355,6 +399,7 @@ Every finding shows up in **Security → Code scanning**, pointing at the exact
|
|
|
355
399
|
| `sarif` | `cra-audit.sarif` | SARIF path (empty to skip). |
|
|
356
400
|
| `upload-sarif` | `true` | Upload to code scanning (needs `security-events: write`; private repos need GitHub Advanced Security). |
|
|
357
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). |
|
|
358
403
|
| `args` | — | Extra `cra-audit audit` arguments. |
|
|
359
404
|
|
|
360
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.
|
|
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
|
|
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
|
|
package/src/commands/audit.js
CHANGED
|
@@ -1,69 +1,81 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const
|
|
4
|
-
const {
|
|
5
|
-
const {
|
|
6
|
-
const {
|
|
7
|
-
const {
|
|
8
|
-
const {
|
|
9
|
-
const {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* @param {
|
|
17
|
-
* @
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
return
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (flags.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
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 =
|
|
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 };
|
package/src/commands/vex.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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;
|
package/src/core/auditor.js
CHANGED
|
@@ -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
|
-
*
|
|
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:
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* such as the ones published by
|
|
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
|
-
*
|
|
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
|
|
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(
|
|
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: {
|
|
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(
|
|
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
|
-
*
|
|
144
|
-
*
|
|
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
|
}
|
package/src/core/readiness.js
CHANGED
|
@@ -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
|
-
|
|
107
|
-
|
|
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
|
|
127
|
+
if (!r) continue;
|
|
128
|
+
if (r.relationshipType === 'DESCRIBES' && r.spdxElementId === 'SPDXRef-DOCUMENT') described.add(r.relatedSpdxElement);
|
|
129
|
+
if (graphTypes.test(r.relationshipType)) {
|
|
124
130
|
inGraph.add(r.spdxElementId);
|
|
125
131
|
inGraph.add(r.relatedSpdxElement);
|
|
126
132
|
}
|
|
@@ -138,7 +144,7 @@ function validateSpdx(doc) {
|
|
|
138
144
|
};
|
|
139
145
|
for (const pkg of packages) {
|
|
140
146
|
const label = pkg.name || pkg.SPDXID || 'unknown';
|
|
141
|
-
if (pkg.SPDXID === 'SPDXRef-Package-root') continue;
|
|
147
|
+
if (pkg.SPDXID === 'SPDXRef-Package-root' || described.has(pkg.SPDXID)) continue;
|
|
142
148
|
if (!pkg.versionInfo || pkg.versionInfo === 'NOASSERTION') { stats.missingVersion++; issues.push(`Package without a version: ${label}`); }
|
|
143
149
|
if (!isAssertion(pkg.originator) && !isAssertion(pkg.supplier)) { stats.missingCreator++; issues.push(`Package without an originator: ${label}`); }
|
|
144
150
|
if (!pkg.packageFileName) { stats.missingFilename++; issues.push(`Package without a filename: ${label}`); }
|
|
@@ -158,7 +164,7 @@ function validateSpdx(doc) {
|
|
|
158
164
|
pushCheck(checks, 'componentHashes', 'All packages have a SHA-512 checksum', stats.missingHash === 0);
|
|
159
165
|
pushCheck(checks, 'componentLicenses', 'All packages have a license', stats.missingLicense === 0);
|
|
160
166
|
pushCheck(checks, 'componentIdentifiers', 'All packages have a purl', stats.missingPurl === 0);
|
|
161
|
-
pushCheck(checks, 'dependencyGraph', 'Dependency relationships
|
|
167
|
+
pushCheck(checks, 'dependencyGraph', 'Dependency relationships declared for every package', stats.missingDependencies === 0);
|
|
162
168
|
|
|
163
169
|
return summarize('spdx', checks, issues, stats);
|
|
164
170
|
}
|
package/src/core/vex.js
CHANGED
|
@@ -106,18 +106,18 @@ function acceptance(finding, policy) {
|
|
|
106
106
|
/**
|
|
107
107
|
* Builds a VEX document for the vulnerability findings of an audit.
|
|
108
108
|
*
|
|
109
|
-
* @param {{ name: string, version: string }} product
|
|
109
|
+
* @param {{ name: string, version: string, purl?: string }} product
|
|
110
110
|
* @param {object} vulnSection Result of scanVulnerabilities().
|
|
111
111
|
* @param {object} policy
|
|
112
112
|
* @param {{ format?: 'cyclonedx'|'openvex', author?: string|null }} [options]
|
|
113
113
|
*/
|
|
114
114
|
function buildVex(product, vulnSection, policy, { format = 'cyclonedx', author = null } = {}) {
|
|
115
115
|
const entries = normalizeAllowlist(policy);
|
|
116
|
-
const productPurl = buildPurl(product.name, product.version);
|
|
116
|
+
const productPurl = product.purl || buildPurl(product.name, product.version);
|
|
117
117
|
const statements = [];
|
|
118
118
|
|
|
119
119
|
for (const finding of vulnSection.vulnerabilities || []) {
|
|
120
|
-
const purl = buildPurl(finding.name, finding.version);
|
|
120
|
+
const purl = finding.purl || buildPurl(finding.name, finding.version);
|
|
121
121
|
for (const source of finding.sources) {
|
|
122
122
|
const assessment = finding.malicious ? null : assessmentFor(finding, source, entries);
|
|
123
123
|
statements.push({ finding, source, purl, assessment });
|
package/src/core/vuln-scanner.js
CHANGED
|
@@ -5,7 +5,7 @@ const os = require('node:os');
|
|
|
5
5
|
const path = require('node:path');
|
|
6
6
|
const { runJson } = require('../utils/exec');
|
|
7
7
|
const { exists, readJson } = require('../utils/fs');
|
|
8
|
-
const { parseLockfile, toNpmLockfile, componentKey } = require('./lockfile-parser');
|
|
8
|
+
const { parseLockfile, toNpmLockfile, componentKey, buildPurl } = require('./lockfile-parser');
|
|
9
9
|
const {
|
|
10
10
|
queryOsv, osvSeverity, cvssScore, isMalicious, fixedVersion, compareSemver,
|
|
11
11
|
} = require('./osv');
|
|
@@ -25,20 +25,34 @@ const SEVERITY_ORDER = ['info', 'low', 'moderate', 'high', 'critical'];
|
|
|
25
25
|
* CVEs found are cross-checked with the CISA KEV catalogue.
|
|
26
26
|
* - `npm`: `npm audit`. Also the fallback when OSV.dev is unreachable.
|
|
27
27
|
*
|
|
28
|
+
* 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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
50
|
+
let components = parsed.components;
|
|
51
|
+
if (production) {
|
|
52
|
+
components = fromSbom
|
|
53
|
+
? components.filter((c) => !c.optional)
|
|
54
|
+
: productionComponents(parsed, readJson(path.join(projectRoot, 'package.json')) || {});
|
|
55
|
+
}
|
|
42
56
|
const scannable = components.filter((c) => c.name && c.version);
|
|
43
57
|
|
|
44
58
|
const [osv, catalogue] = await Promise.all([
|
|
@@ -47,6 +61,7 @@ async function scanVulnerabilities(projectRoot, { production = false, source = '
|
|
|
47
61
|
]);
|
|
48
62
|
|
|
49
63
|
if (!osv.ok) {
|
|
64
|
+
if (fromSbom) return { ok: false, error: osv.error, counts: emptyCounts(), vulnerabilities: [] };
|
|
50
65
|
const fallback = npmAuditSection(projectRoot, { production });
|
|
51
66
|
fallback.warnings = [`${osv.error}; fell back to \`npm audit\` (no malicious-package or KEV check).`];
|
|
52
67
|
return fallback;
|
|
@@ -55,7 +70,7 @@ async function scanVulnerabilities(projectRoot, { production = false, source = '
|
|
|
55
70
|
const direct = new Set(parsed.root.dependsOn);
|
|
56
71
|
const vulnerabilities = [];
|
|
57
72
|
for (const c of scannable) {
|
|
58
|
-
const key = componentKey(c.name, c.version);
|
|
73
|
+
const key = c.key || componentKey(c.name, c.version);
|
|
59
74
|
const records = osv.byComponent.get(key);
|
|
60
75
|
if (!records || !records.length) continue;
|
|
61
76
|
vulnerabilities.push(toFinding(c, records, direct.has(key), catalogue));
|
|
@@ -73,6 +88,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. */
|
package/src/reporters/console.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const path = require('node:path');
|
|
3
4
|
const { logger, color } = require('../utils/logger');
|
|
4
5
|
|
|
5
6
|
const SEVERITY_COLORS = {
|
|
@@ -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
|
-
|
|
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 };
|
package/src/reporters/sarif.js
CHANGED
|
@@ -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
|
|
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
|
|
167
|
-
* points at the exact entry. Falls back to
|
|
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
|
-
|
|
172
|
-
|
|
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);
|