cra-audit 2.0.0 → 2.1.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 +13 -0
- package/README.md +106 -4
- package/package.json +1 -1
- package/src/cli.js +23 -1
- package/src/commands/audit.js +4 -0
- package/src/commands/readiness.js +59 -0
- package/src/commands/vex.js +70 -0
- package/src/core/auditor.js +19 -7
- package/src/core/readiness.js +231 -0
- package/src/core/vex.js +264 -0
- package/src/index.js +6 -0
- package/src/reporters/sarif.js +238 -0
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,19 @@ 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.1.0] — VEX, SARIF & GitHub Action, CRA readiness
|
|
12
|
+
|
|
13
|
+
### Highlights
|
|
14
|
+
|
|
15
|
+
- **VEX** (`cra-audit vex`): writes a CycloneDX 1.6 or OpenVEX 0.2.0 document with the exploitability of every known vulnerability. Assessments live in `.cra-audit.json` (`status`, `justification`, `detail`) and are also honoured by the audit gate; accepting a vulnerability without a justification now raises a warning.
|
|
16
|
+
- **SARIF 2.1.0** (`--sarif <path>`): findings appear in GitHub code scanning at the exact lockfile line, with GitHub severities; malicious and CISA KEV findings are errors and `not_affected` assessments are shown as suppressed with their justification.
|
|
17
|
+
- **GitHub Action** (`uses: migohe14/cra-audit@v2`): runs the audit, uploads the SARIF to code scanning and can write the SBOM and VEX as build evidence.
|
|
18
|
+
- **CRA readiness** (`cra-audit readiness`): checks SECURITY.md, the vulnerability contact, the support period, security.txt (RFC 9116) and the Art. 14 reporting process. `--init` creates prefilled SECURITY.md and security.txt templates.
|
|
19
|
+
|
|
20
|
+
### Compatibility
|
|
21
|
+
|
|
22
|
+
- Plain-string allowlist entries keep working as before.
|
|
23
|
+
|
|
11
24
|
## [2.0.0] — OSV.dev, CISA KEV & malicious package detection
|
|
12
25
|
|
|
13
26
|
### Highlights
|
package/README.md
CHANGED
|
@@ -95,6 +95,62 @@ Generates an **interactive, self-contained HTML report** (no external CDNs, work
|
|
|
95
95
|
|
|
96
96
|
The report includes summary cards, search, filters (vulnerable, outdated, license issues, at risk) and column sorting.
|
|
97
97
|
|
|
98
|
+
### 6. VEX — documenting exploitability (`vex`)
|
|
99
|
+
|
|
100
|
+
A known vulnerability in a dependency is not always exploitable in your product. Record your assessment in `.cra-audit.json` and `cra-audit` both **accepts** it in the audit and writes it to a **VEX** document (CycloneDX 1.6 or OpenVEX 0.2.0) — the place TR-03183-2 reserves for vulnerability data, outside the SBOM:
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"vulnerabilities": {
|
|
105
|
+
"allowlist": [
|
|
106
|
+
{
|
|
107
|
+
"id": "CVE-2020-11023",
|
|
108
|
+
"package": "jquery",
|
|
109
|
+
"status": "not_affected",
|
|
110
|
+
"justification": "code_not_reachable",
|
|
111
|
+
"detail": "We never pass untrusted HTML to jQuery DOM methods; content is rendered via textContent."
|
|
112
|
+
},
|
|
113
|
+
{ "id": "GHSA-35jh-r3h4-6jhm", "status": "affected", "detail": "_.template used in the exporter; upgrade planned for 3.2.1." }
|
|
114
|
+
]
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
| `status` | Accepted by the audit | CycloneDX `analysis.state` | OpenVEX `status` |
|
|
120
|
+
| --- | --- | --- | --- |
|
|
121
|
+
| `not_affected` (default) | ✔ | `not_affected` | `not_affected` |
|
|
122
|
+
| `false_positive` | ✔ | `false_positive` | `not_affected` |
|
|
123
|
+
| `affected` | ✖ | `exploitable` | `affected` |
|
|
124
|
+
| `under_investigation` | ✖ | `in_triage` | `under_investigation` |
|
|
125
|
+
|
|
126
|
+
`justification` accepts the CycloneDX values (`code_not_present`, `code_not_reachable`, `requires_configuration`, `requires_dependency`, `requires_environment`, `protected_by_compiler`, `protected_at_runtime`, `protected_at_perimeter`, `protected_by_mitigating_control`) or the OpenVEX ones, and is translated for each format. Findings without an assessment are written as *in triage* / *under investigation*; malicious packages are always *exploitable* / *affected*. Accepting a vulnerability without `justification` or `detail` works, but the audit warns about it.
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
npx cra-audit vex -o vex.cdx.json # CycloneDX 1.6 VEX
|
|
130
|
+
npx cra-audit vex --format openvex -o vex.openvex.json
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### 7. CRA readiness (`readiness`)
|
|
134
|
+
|
|
135
|
+
Checks the vulnerability-handling duties that live in the repository itself:
|
|
136
|
+
|
|
137
|
+
| Check | Level | Reference |
|
|
138
|
+
| --- | --- | --- |
|
|
139
|
+
| `SECURITY.md` (root, `.github/` or `docs/`) | required | CRA Annex I Part II (5) |
|
|
140
|
+
| Email address or reporting URL for vulnerabilities | required | Annex I Part II (6) · Annex II (2) |
|
|
141
|
+
| Support period with an end date or duration | required | Art. 13(8) · Annex II (7) |
|
|
142
|
+
| No unfilled `TODO` placeholders | required | Annex II |
|
|
143
|
+
| `security.txt` `Contact` and a future `Expires` (when present) | required | RFC 9116 |
|
|
144
|
+
| A lockfile to build the SBOM from | required | Annex I Part II (1) |
|
|
145
|
+
| Supported versions, response times, Art. 14 process (CSIRT/ENISA), `security.txt`, `repository` link | recommended | Annex II · Art. 14 |
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
npx cra-audit readiness # exit code 1 when a required check fails
|
|
149
|
+
npx cra-audit readiness --init # creates SECURITY.md and .well-known/security.txt templates (never overwrites)
|
|
150
|
+
```
|
|
151
|
+
|
|
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
|
+
|
|
98
154
|
---
|
|
99
155
|
|
|
100
156
|
## Usage
|
|
@@ -142,6 +198,8 @@ cra-audit --help
|
|
|
142
198
|
| `cra-audit sbom check` | Validate the SBOM against the TR-03183-2 v2.1 data fields. |
|
|
143
199
|
| `cra-audit vulnerabilities` (alias `vuln`) | Vulnerability analysis only. |
|
|
144
200
|
| `cra-audit licenses` | License analysis only. |
|
|
201
|
+
| `cra-audit vex` | Write a VEX document (CycloneDX, or `--format openvex`) from the policy assessments. |
|
|
202
|
+
| `cra-audit readiness` | Check SECURITY.md, vulnerability contact, support period and security.txt (`--init` for templates). |
|
|
145
203
|
| `cra-audit help` | Show help. |
|
|
146
204
|
|
|
147
205
|
## Options
|
|
@@ -161,6 +219,8 @@ cra-audit --help
|
|
|
161
219
|
| `--production`, `--prod` | Audit production dependencies only. |
|
|
162
220
|
| `--no-sbom` | Do not require an SBOM in the full audit. |
|
|
163
221
|
| `--json` | Machine-readable JSON output. |
|
|
222
|
+
| `--sarif <path>` | Also write the audit as SARIF 2.1.0 for GitHub code scanning. |
|
|
223
|
+
| `--init` | With `readiness`: create SECURITY.md and security.txt templates. |
|
|
164
224
|
| `--output`, `-o <path>` | Write the result / SBOM / HTML to a file. |
|
|
165
225
|
| `--input`, `-i <path>` | Existing SBOM to validate (for `sbom check`). |
|
|
166
226
|
| `--config`, `-c <path>` | Path to the security policy. |
|
|
@@ -234,7 +294,7 @@ Create a `.cra-audit.json` file at the project root to customize the rules (ther
|
|
|
234
294
|
- `sbomFormat`: `cyclonedx` or `spdx`.
|
|
235
295
|
- `sbomCreator`: email or URL of the entity that creates the SBOM (usually the manufacturer).
|
|
236
296
|
- `productionOnly`: audit production dependencies only.
|
|
237
|
-
- `vulnerabilities.allowlist`:
|
|
297
|
+
- `vulnerabilities.allowlist`: exploitability assessments (see [VEX](#6-vex--documenting-exploitability-vex)). Plain strings (a package name or a GHSA/CVE id) are still accepted. Malicious packages (`MAL-*`) cannot be allowlisted.
|
|
238
298
|
- `licenses.allow` / `licenses.deny`: allowed / denied lists (SPDX id).
|
|
239
299
|
- `licenses.failOnMissing`: treat undocumented licenses as a failure.
|
|
240
300
|
|
|
@@ -251,12 +311,54 @@ Command-line options take precedence over the policy file.
|
|
|
251
311
|
|
|
252
312
|
Suitable for CI/CD: a non-`0` code blocks the pipeline.
|
|
253
313
|
|
|
314
|
+
## GitHub Action
|
|
315
|
+
|
|
254
316
|
```yaml
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
317
|
+
name: CRA audit
|
|
318
|
+
on: [push, pull_request]
|
|
319
|
+
|
|
320
|
+
permissions:
|
|
321
|
+
contents: read
|
|
322
|
+
security-events: write # upload the SARIF report to code scanning
|
|
323
|
+
|
|
324
|
+
jobs:
|
|
325
|
+
cra:
|
|
326
|
+
runs-on: ubuntu-latest
|
|
327
|
+
steps:
|
|
328
|
+
- uses: actions/checkout@v4
|
|
329
|
+
- uses: actions/setup-node@v4
|
|
330
|
+
with: { node-version: 22 }
|
|
331
|
+
- run: npm ci # installed packages give the SBOM its creators/licenses
|
|
332
|
+
- uses: migohe14/cra-audit@v2
|
|
333
|
+
with:
|
|
334
|
+
fail-on: high
|
|
335
|
+
sbom: sbom.cdx.json
|
|
336
|
+
vex: vex.cdx.json
|
|
337
|
+
- uses: actions/upload-artifact@v4
|
|
338
|
+
if: always()
|
|
339
|
+
with:
|
|
340
|
+
name: cra-evidence
|
|
341
|
+
path: |
|
|
342
|
+
sbom.cdx.json
|
|
343
|
+
vex.cdx.json
|
|
344
|
+
cra-audit.sarif
|
|
258
345
|
```
|
|
259
346
|
|
|
347
|
+
Every finding shows up in **Security → Code scanning**, pointing at the exact line of the lockfile; malicious packages and CISA KEV findings are errors, and advisories assessed as `not_affected` in the policy are shown as suppressed with their justification.
|
|
348
|
+
|
|
349
|
+
| Input | Default | Description |
|
|
350
|
+
| --- | --- | --- |
|
|
351
|
+
| `working-directory` | `.` | Project to audit. |
|
|
352
|
+
| `fail-on` | `high` | Minimum severity that fails the job. |
|
|
353
|
+
| `fail-on-kev` | `true` | Fail on actively exploited (CISA KEV) vulnerabilities. |
|
|
354
|
+
| `production` | `false` | Production dependencies only. |
|
|
355
|
+
| `sarif` | `cra-audit.sarif` | SARIF path (empty to skip). |
|
|
356
|
+
| `upload-sarif` | `true` | Upload to code scanning (needs `security-events: write`; private repos need GitHub Advanced Security). |
|
|
357
|
+
| `sbom` / `vex` | — | Also write the SBOM / VEX to these paths. |
|
|
358
|
+
| `args` | — | Extra `cra-audit audit` arguments. |
|
|
359
|
+
|
|
360
|
+
Outputs: `exit-code`, `sarif`, `sbom`, `vex`. Without the action, `npx cra-audit --sarif cra-audit.sarif` does the same in any CI.
|
|
361
|
+
|
|
260
362
|
---
|
|
261
363
|
|
|
262
364
|
## Programmatic API
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cra-audit",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.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
|
@@ -4,6 +4,8 @@ const { logger, color } = require('./utils/logger');
|
|
|
4
4
|
const { auditCommand } = require('./commands/audit');
|
|
5
5
|
const { sbomCommand } = require('./commands/sbom');
|
|
6
6
|
const { visualizeCommand } = require('./commands/visualize');
|
|
7
|
+
const { vexCommand } = require('./commands/vex');
|
|
8
|
+
const { readinessCommand } = require('./commands/readiness');
|
|
7
9
|
|
|
8
10
|
const VERSION = require('../package.json').version;
|
|
9
11
|
|
|
@@ -20,7 +22,7 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
20
22
|
'--help', '--version', '--json', '--sbom', '--no-sbom',
|
|
21
23
|
'--production', '--prod', '--no-color',
|
|
22
24
|
'--visualize', '--offline', '--github', '--no-open',
|
|
23
|
-
'--fail-on-kev', '--no-fail-on-kev',
|
|
25
|
+
'--fail-on-kev', '--no-fail-on-kev', '--init', '--verbose',
|
|
24
26
|
]);
|
|
25
27
|
|
|
26
28
|
/**
|
|
@@ -75,6 +77,13 @@ async function main(argv) {
|
|
|
75
77
|
case 'license':
|
|
76
78
|
return auditCommand(flags, 'licenses');
|
|
77
79
|
|
|
80
|
+
case 'vex':
|
|
81
|
+
return vexCommand(flags);
|
|
82
|
+
|
|
83
|
+
case 'readiness':
|
|
84
|
+
case 'ready':
|
|
85
|
+
return readinessCommand(flags);
|
|
86
|
+
|
|
78
87
|
case 'help':
|
|
79
88
|
printHelp();
|
|
80
89
|
return 0;
|
|
@@ -156,6 +165,9 @@ function setFlag(flags, name, value) {
|
|
|
156
165
|
case '--vuln-source': flags.vulnSource = String(value).toLowerCase(); break;
|
|
157
166
|
case '--fail-on-kev': flags.failOnKev = value; break;
|
|
158
167
|
case '--no-fail-on-kev': flags.noFailOnKev = value; break;
|
|
168
|
+
case '--sarif': flags.sarif = value; break;
|
|
169
|
+
case '--init': flags.init = value; break;
|
|
170
|
+
case '--verbose': flags.verbose = value; break;
|
|
159
171
|
default:
|
|
160
172
|
// Unknown flag stored under its raw name for forward compatibility.
|
|
161
173
|
flags[name.replace(/^--/, '')] = value;
|
|
@@ -178,6 +190,9 @@ ${c.bold('COMMANDS')}
|
|
|
178
190
|
${c.cyan('sbom check')} Validate the SBOM against the TR-03183-2 v2.1 data fields.
|
|
179
191
|
${c.cyan('vulnerabilities')} Known, actively exploited (KEV) and malicious packages only (alias: vuln).
|
|
180
192
|
${c.cyan('licenses')} Analyze dependency licenses only.
|
|
193
|
+
${c.cyan('vex')} Write a VEX document (CycloneDX or --format openvex) from the policy assessments.
|
|
194
|
+
${c.cyan('readiness')} Check SECURITY.md, vulnerability contact, support period and security.txt.
|
|
195
|
+
--init creates SECURITY.md and security.txt templates.
|
|
181
196
|
${c.cyan('help')} Show this help.
|
|
182
197
|
|
|
183
198
|
${c.bold('OPTIONS')}
|
|
@@ -194,6 +209,7 @@ ${c.bold('OPTIONS')}
|
|
|
194
209
|
${c.cyan('--production, --prod')} Audit production dependencies only (skips devDependencies).
|
|
195
210
|
${c.cyan('--no-sbom')} Do not require an SBOM in the full audit.
|
|
196
211
|
${c.cyan('--json')} Machine-readable JSON output.
|
|
212
|
+
${c.cyan('--sarif <path>')} Also write the audit as SARIF 2.1.0 (GitHub code scanning).
|
|
197
213
|
${c.cyan('--output, -o <path>')} Write the result/SBOM/HTML to a file.
|
|
198
214
|
${c.cyan('--input, -i <path>')} Existing SBOM to validate (for "sbom check").
|
|
199
215
|
${c.cyan('--config, -c <path>')} Path to the security policy (.cra-audit.json).
|
|
@@ -217,6 +233,12 @@ ${c.bold('EXAMPLES')}
|
|
|
217
233
|
${c.gray('# Generate a CycloneDX SBOM on disk')}
|
|
218
234
|
npx cra-audit sbom generate -o sbom.cdx.json
|
|
219
235
|
|
|
236
|
+
${c.gray('# VEX with the exploitability assessments recorded in .cra-audit.json')}
|
|
237
|
+
npx cra-audit vex -o vex.cdx.json
|
|
238
|
+
|
|
239
|
+
${c.gray('# Security policy, contact and support period checks')}
|
|
240
|
+
npx cra-audit readiness --init
|
|
241
|
+
|
|
220
242
|
${c.gray('# Fail only on critical vulnerabilities, in CI')}
|
|
221
243
|
npx cra-audit --fail-on critical --production --json -o cra-report.json
|
|
222
244
|
`);
|
package/src/commands/audit.js
CHANGED
|
@@ -6,6 +6,7 @@ const { loadPolicy } = require('../core/policy');
|
|
|
6
6
|
const { runAudit } = require('../core/auditor');
|
|
7
7
|
const { reportConsole } = require('../reporters/console');
|
|
8
8
|
const { reportJson } = require('../reporters/json');
|
|
9
|
+
const { reportSarif } = require('../reporters/sarif');
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* `cra-audit [audit]` — runs the full compliance audit (vulnerabilities + SBOM
|
|
@@ -35,6 +36,9 @@ async function auditCommand(flags, only) {
|
|
|
35
36
|
} else {
|
|
36
37
|
reportConsole(report);
|
|
37
38
|
}
|
|
39
|
+
if (typeof flags.sarif === 'string') {
|
|
40
|
+
reportSarif(report, projectRoot, flags.sarif, { quiet: flags.json && !flags.output, policy });
|
|
41
|
+
}
|
|
38
42
|
|
|
39
43
|
return report.gate.passed ? 0 : 1;
|
|
40
44
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { findProjectRoot } = require('../utils/fs');
|
|
4
|
+
const { logger, color } = require('../utils/logger');
|
|
5
|
+
const { checkReadiness, writeTemplates } = require('../core/readiness');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `cra-audit readiness [--init]` — checks the organisational CRA duties that
|
|
9
|
+
* live in the repository (disclosure policy, vulnerability contact, support
|
|
10
|
+
* period, security.txt, SBOM). `--init` writes SECURITY.md and security.txt
|
|
11
|
+
* templates first; existing files are never overwritten.
|
|
12
|
+
*
|
|
13
|
+
* @returns {number} exit code: 1 when a required check fails.
|
|
14
|
+
*/
|
|
15
|
+
function readinessCommand(flags) {
|
|
16
|
+
const projectRoot = findProjectRoot(flags.cwd || process.cwd());
|
|
17
|
+
if (!projectRoot) {
|
|
18
|
+
logger.error('No package.json found. Run the command inside an npm project.');
|
|
19
|
+
return 1;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (flags.init) {
|
|
23
|
+
for (const { file, created } of writeTemplates(projectRoot)) {
|
|
24
|
+
if (created) logger.success(`Created ${file} — fill in the TODO placeholders.`);
|
|
25
|
+
else logger.info(`${file} already exists; left untouched.`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const result = checkReadiness(projectRoot);
|
|
30
|
+
|
|
31
|
+
if (flags.json) {
|
|
32
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
33
|
+
return result.passed ? 0 : 1;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
logger.heading('CRA readiness · vulnerability handling and user information');
|
|
37
|
+
for (const check of result.checks) {
|
|
38
|
+
const mark = check.passed ? color.green('✔') : check.level === 'required' ? color.red('✖') : color.yellow('!');
|
|
39
|
+
const level = check.level === 'required' ? '' : color.gray(' (recommended)');
|
|
40
|
+
logger.log(` ${mark} ${check.label}${level} ${color.gray(`— ${check.reference}`)}`);
|
|
41
|
+
if (!check.passed || flags.verbose) logger.detail(` ${check.detail}`);
|
|
42
|
+
}
|
|
43
|
+
logger.log('');
|
|
44
|
+
|
|
45
|
+
const failed = result.checks.filter((c) => c.level === 'required' && !c.passed).length;
|
|
46
|
+
const warned = result.checks.filter((c) => c.level === 'recommended' && !c.passed).length;
|
|
47
|
+
if (result.passed) {
|
|
48
|
+
logger.success(color.bold(`READY — all required checks pass${warned ? ` (${warned} recommendation(s))` : ''}.`));
|
|
49
|
+
} else {
|
|
50
|
+
logger.error(color.bold(`NOT READY — ${failed} required check(s) failed.`));
|
|
51
|
+
if (!flags.init && !result.files.securityMd) {
|
|
52
|
+
logger.detail(`Run ${color.cyan('cra-audit readiness --init')} to create SECURITY.md and security.txt templates.`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
logger.detail('This checks what the repository documents; it is not legal advice.');
|
|
56
|
+
return result.passed ? 0 : 1;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { readinessCommand };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { findProjectRoot, readJson, writeJson } = require('../utils/fs');
|
|
5
|
+
const { logger } = require('../utils/logger');
|
|
6
|
+
const { loadPolicy } = require('../core/policy');
|
|
7
|
+
const { scanVulnerabilities } = require('../core/vuln-scanner');
|
|
8
|
+
const { buildVex } = require('../core/vex');
|
|
9
|
+
const { creatorFromManifest } = require('../core/installed-metadata');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `cra-audit vex` — writes a VEX document (CycloneDX 1.6 or OpenVEX 0.2.0)
|
|
13
|
+
* stating the exploitability of every known vulnerability in the dependency
|
|
14
|
+
* tree, from the assessments recorded in the policy allowlist.
|
|
15
|
+
*
|
|
16
|
+
* @returns {Promise<number>} exit code
|
|
17
|
+
*/
|
|
18
|
+
async function vexCommand(flags) {
|
|
19
|
+
const projectRoot = findProjectRoot(flags.cwd || process.cwd());
|
|
20
|
+
if (!projectRoot) {
|
|
21
|
+
logger.error('No package.json found. Run the command inside an npm project.');
|
|
22
|
+
return 1;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let policy;
|
|
26
|
+
try {
|
|
27
|
+
policy = loadPolicy(projectRoot, flags.config).policy;
|
|
28
|
+
} catch (err) {
|
|
29
|
+
logger.error(err.message);
|
|
30
|
+
return 1;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const format = flags.format === 'openvex' ? 'openvex' : 'cyclonedx';
|
|
34
|
+
if (flags.format && !['openvex', 'cyclonedx', 'cdx'].includes(flags.format)) {
|
|
35
|
+
logger.error(`Unsupported VEX format: "${flags.format}". Use "cyclonedx" or "openvex".`);
|
|
36
|
+
return 1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const vulns = await scanVulnerabilities(projectRoot, {
|
|
40
|
+
production: flags.production || policy.productionOnly,
|
|
41
|
+
source: flags.vulnSource || policy.vulnerabilitySource,
|
|
42
|
+
});
|
|
43
|
+
if (!vulns.ok) {
|
|
44
|
+
logger.error(vulns.error);
|
|
45
|
+
return 1;
|
|
46
|
+
}
|
|
47
|
+
// Without -o the document goes to stdout: keep warnings on stderr.
|
|
48
|
+
for (const warning of vulns.warnings || []) {
|
|
49
|
+
if (flags.output) logger.warn(warning);
|
|
50
|
+
else process.stderr.write(`${warning}\n`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const pkg = readJson(path.join(projectRoot, 'package.json')) || {};
|
|
54
|
+
const creator = creatorFromManifest(pkg);
|
|
55
|
+
const author = policy.sbomCreator || (creator && (creator.email || creator.url)) || null;
|
|
56
|
+
const product = { name: pkg.name || path.basename(projectRoot), version: pkg.version || '0.0.0' };
|
|
57
|
+
const document = buildVex(product, vulns, policy, { format, author });
|
|
58
|
+
|
|
59
|
+
const count = format === 'openvex' ? document.statements.length : document.vulnerabilities.length;
|
|
60
|
+
if (flags.output) {
|
|
61
|
+
const outPath = path.isAbsolute(flags.output) ? flags.output : path.join(projectRoot, flags.output);
|
|
62
|
+
writeJson(outPath, document);
|
|
63
|
+
logger.success(`VEX (${format}) with ${count} statement(s) written to: ${outPath}`);
|
|
64
|
+
} else {
|
|
65
|
+
process.stdout.write(JSON.stringify(document, null, 2) + '\n');
|
|
66
|
+
}
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { vexCommand };
|
package/src/core/auditor.js
CHANGED
|
@@ -4,6 +4,7 @@ const { scanVulnerabilities, SEVERITY_ORDER } = require('./vuln-scanner');
|
|
|
4
4
|
const { generateSbom } = require('./sbom-generator');
|
|
5
5
|
const { validateSbom } = require('./sbom-validator');
|
|
6
6
|
const { checkLicenses } = require('./license-checker');
|
|
7
|
+
const { acceptance } = require('./vex');
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* @typedef {object} AuditResult
|
|
@@ -43,6 +44,7 @@ async function runAudit(projectRoot, policy, policySource, options = {}) {
|
|
|
43
44
|
reasons.push({ label: warning, passed: true, warning: true });
|
|
44
45
|
}
|
|
45
46
|
reasons.push(...exploitationReasons(vulns, policy));
|
|
47
|
+
reasons.push(...justificationReasons(vulns, policy));
|
|
46
48
|
const blocking = countBlocking(vulns, policy);
|
|
47
49
|
reasons.push({
|
|
48
50
|
label: blocking === 0
|
|
@@ -155,15 +157,25 @@ function countBlocking(vulns, policy) {
|
|
|
155
157
|
}
|
|
156
158
|
|
|
157
159
|
/**
|
|
158
|
-
* A finding is accepted when
|
|
159
|
-
*
|
|
160
|
+
* A finding is accepted when every advisory on it is assessed as not_affected
|
|
161
|
+
* (or false_positive) in the policy allowlist. See ./vex.js.
|
|
160
162
|
*/
|
|
161
163
|
function isAllowlisted(vuln, policy) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
164
|
+
return acceptance(vuln, policy).accepted;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Accepted vulnerabilities must be documented (CRA Annex I Part II): warn
|
|
169
|
+
* when an allowlist entry carries no justification or detail for the VEX.
|
|
170
|
+
*/
|
|
171
|
+
function justificationReasons(vulns, policy) {
|
|
172
|
+
const unjustified = vulns.vulnerabilities.reduce((n, v) => n + acceptance(v, policy).unjustified, 0);
|
|
173
|
+
if (!unjustified) return [];
|
|
174
|
+
return [{
|
|
175
|
+
label: `${unjustified} accepted vulnerability(ies) without a justification — add "justification"/"detail" to the allowlist entry for the VEX`,
|
|
176
|
+
passed: true,
|
|
177
|
+
warning: true,
|
|
178
|
+
}];
|
|
167
179
|
}
|
|
168
180
|
|
|
169
181
|
function getProject(projectRoot, sections) {
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { readJson } = require('../utils/fs');
|
|
6
|
+
const { parseLockfile } = require('./lockfile-parser');
|
|
7
|
+
const { repositoryUrl } = require('./installed-metadata');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Organisational CRA duties that can be checked from the repository itself:
|
|
11
|
+
* the vulnerability disclosure policy, a contact for vulnerability reports,
|
|
12
|
+
* the support period, a security.txt and the ability to produce an SBOM.
|
|
13
|
+
*
|
|
14
|
+
* `required` checks fail the command; `recommended` ones only warn.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const SECURITY_MD_PATHS = ['SECURITY.md', '.github/SECURITY.md', 'docs/SECURITY.md'];
|
|
18
|
+
const SECURITY_TXT_PATHS = [
|
|
19
|
+
'.well-known/security.txt',
|
|
20
|
+
'public/.well-known/security.txt',
|
|
21
|
+
'static/.well-known/security.txt',
|
|
22
|
+
'src/.well-known/security.txt',
|
|
23
|
+
'security.txt',
|
|
24
|
+
];
|
|
25
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
const EMAIL = /[\w.+-]+@[\w-]+\.[\w.-]+/;
|
|
28
|
+
const URL = /https?:\/\/\S+/;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {string} projectRoot
|
|
32
|
+
* @param {{ now?: Date }} [options]
|
|
33
|
+
* @returns {{ checks: Array<object>, passed: boolean, files: object }}
|
|
34
|
+
*/
|
|
35
|
+
function checkReadiness(projectRoot, { now = new Date() } = {}) {
|
|
36
|
+
const checks = [];
|
|
37
|
+
const add = (id, label, level, passed, detail, reference) => checks.push({ id, label, level, passed: Boolean(passed), detail, reference });
|
|
38
|
+
|
|
39
|
+
const securityMdPath = firstExisting(projectRoot, SECURITY_MD_PATHS);
|
|
40
|
+
const securityMd = securityMdPath ? read(path.join(projectRoot, securityMdPath)) : '';
|
|
41
|
+
const readme = read(path.join(projectRoot, 'README.md'));
|
|
42
|
+
const pkg = readJson(path.join(projectRoot, 'package.json')) || {};
|
|
43
|
+
|
|
44
|
+
// --- Coordinated vulnerability disclosure policy --------------------------
|
|
45
|
+
add('security-policy', 'Vulnerability disclosure policy (SECURITY.md)', 'required', securityMdPath,
|
|
46
|
+
securityMdPath ? `Found ${securityMdPath}` : `None of ${SECURITY_MD_PATHS.join(', ')} exists`,
|
|
47
|
+
'CRA Annex I Part II (5)');
|
|
48
|
+
|
|
49
|
+
const reportingChannel = EMAIL.test(securityMd) || /security\/advisories|report[^\n]*vulnerabilit[^\n]*https?:\/\//i.test(securityMd) || URL.test(securityMd);
|
|
50
|
+
add('security-contact', 'Contact address for vulnerability reports', 'required', securityMdPath && reportingChannel,
|
|
51
|
+
!securityMdPath ? 'No SECURITY.md' : reportingChannel ? 'Email address or reporting URL found' : 'SECURITY.md has no email address or reporting URL',
|
|
52
|
+
'CRA Annex I Part II (6) · Annex II (2)');
|
|
53
|
+
|
|
54
|
+
// --- Support period ---------------------------------------------------------
|
|
55
|
+
const supportText = `${securityMd}\n${readme}`;
|
|
56
|
+
const supportMention = /support(ed)?\s+period|supported\s+until|end\s+of\s+(security\s+)?support|end[-\s]of[-\s]life|\bEOL\b/i.test(supportText);
|
|
57
|
+
const supportDate = supportMention && /\b(19|20)\d{2}-\d{2}(-\d{2})?\b|\b\d+\s+years?\b/i.test(supportText);
|
|
58
|
+
add('support-period', 'Support period and its end date stated', 'required', supportMention && supportDate,
|
|
59
|
+
!supportMention ? 'No mention of the support period in SECURITY.md or README.md'
|
|
60
|
+
: supportDate ? 'Support period with an end date or duration found' : 'Support period mentioned without an end date',
|
|
61
|
+
'CRA Art. 13(8) · Annex II (7)');
|
|
62
|
+
|
|
63
|
+
// --- Recommended content ------------------------------------------------------
|
|
64
|
+
add('supported-versions', 'Supported versions listed', 'recommended',
|
|
65
|
+
/supported\s+versions/i.test(securityMd), 'A "Supported Versions" section in SECURITY.md', 'CRA Annex II (7)');
|
|
66
|
+
add('response-timeline', 'Response timeline for reporters', 'recommended',
|
|
67
|
+
/\b\d+\s*(business\s+)?(hours?|days?|weeks?)\b/i.test(securityMd), 'Acknowledgement / fix time frames in SECURITY.md',
|
|
68
|
+
'CRA Annex I Part II (5)');
|
|
69
|
+
add('art14-process', 'Art. 14 reporting process (CSIRT / ENISA, 24 h / 72 h)', 'recommended',
|
|
70
|
+
/ENISA|CSIRT|single\s+reporting\s+platform|24\s*h(ours)?/i.test(securityMd),
|
|
71
|
+
'How actively exploited vulnerabilities and severe incidents are reported', 'CRA Art. 14');
|
|
72
|
+
|
|
73
|
+
// --- security.txt (RFC 9116) --------------------------------------------------
|
|
74
|
+
const securityTxtPath = firstExisting(projectRoot, SECURITY_TXT_PATHS);
|
|
75
|
+
const txt = parseSecurityTxt(securityTxtPath ? read(path.join(projectRoot, securityTxtPath)) : '');
|
|
76
|
+
if (!securityTxtPath) {
|
|
77
|
+
add('security-txt', 'security.txt published (RFC 9116)', 'recommended', false,
|
|
78
|
+
'No .well-known/security.txt found (recommended for products with a web presence)', 'RFC 9116 · CRA Annex II (2)');
|
|
79
|
+
} else {
|
|
80
|
+
const expires = txt.expires ? new Date(txt.expires) : null;
|
|
81
|
+
const validExpiry = expires && !Number.isNaN(expires.getTime());
|
|
82
|
+
add('security-txt', 'security.txt published (RFC 9116)', 'recommended', true, `Found ${securityTxtPath}`, 'RFC 9116');
|
|
83
|
+
add('security-txt-contact', 'security.txt has a Contact field', 'required', txt.contact.length > 0,
|
|
84
|
+
txt.contact.length ? txt.contact.join(', ') : 'Missing Contact:', 'RFC 9116 §2.5.3');
|
|
85
|
+
add('security-txt-expires', 'security.txt Expires is set and in the future', 'required', validExpiry && expires > now,
|
|
86
|
+
!txt.expires ? 'Missing Expires:' : !validExpiry ? `Invalid date: ${txt.expires}` : expires > now ? `Expires ${txt.expires}` : `Expired on ${txt.expires}`,
|
|
87
|
+
'RFC 9116 §2.5.5');
|
|
88
|
+
if (validExpiry && expires > now) {
|
|
89
|
+
add('security-txt-expiry-window', 'security.txt Expires is less than a year away', 'recommended',
|
|
90
|
+
expires - now <= 366 * DAY, `Expires in ${Math.round((expires - now) / DAY)} days`, 'RFC 9116 §2.5.5');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// --- Unfilled templates (`readiness --init`) ------------------------------------
|
|
95
|
+
const securityTxtText = securityTxtPath ? read(path.join(projectRoot, securityTxtPath)) : '';
|
|
96
|
+
const todo = [[securityMdPath, securityMd], [securityTxtPath, securityTxtText]]
|
|
97
|
+
.filter(([file, text]) => file && /\bTODO\b/.test(text))
|
|
98
|
+
.map(([file, text]) => `${file} (${text.match(/\bTODO\b/g).length})`);
|
|
99
|
+
if (securityMdPath || securityTxtPath) {
|
|
100
|
+
add('placeholders', 'No unfilled TODO placeholders', 'required', todo.length === 0,
|
|
101
|
+
todo.length ? `TODO placeholders left in ${todo.join(', ')}` : 'No placeholders', 'CRA Annex II');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- SBOM ---------------------------------------------------------------------
|
|
105
|
+
const parsed = parseLockfile(projectRoot);
|
|
106
|
+
add('sbom', 'A lockfile to generate the SBOM from', 'required', parsed.ok,
|
|
107
|
+
parsed.ok ? `${parsed.lockfileName} (${parsed.components.length} components)` : parsed.error, 'CRA Annex I Part II (1)');
|
|
108
|
+
|
|
109
|
+
add('package-repository', 'package.json links the source repository', 'recommended', Boolean(repositoryUrl(pkg.repository)),
|
|
110
|
+
repositoryUrl(pkg.repository) || 'No "repository" field', 'TR-03183-2 §5.2.4');
|
|
111
|
+
|
|
112
|
+
const failed = checks.filter((c) => c.level === 'required' && !c.passed);
|
|
113
|
+
return { checks, passed: failed.length === 0, files: { securityMd: securityMdPath, securityTxt: securityTxtPath } };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Parses the fields of a security.txt that the checks need. */
|
|
117
|
+
function parseSecurityTxt(text) {
|
|
118
|
+
const out = { contact: [], expires: null, policy: [] };
|
|
119
|
+
for (const raw of String(text).split(/\r?\n/)) {
|
|
120
|
+
const m = raw.match(/^\s*([A-Za-z-]+)\s*:\s*(.+?)\s*$/);
|
|
121
|
+
if (!m) continue;
|
|
122
|
+
const field = m[1].toLowerCase();
|
|
123
|
+
if (field === 'contact') out.contact.push(m[2]);
|
|
124
|
+
else if (field === 'expires') out.expires = m[2];
|
|
125
|
+
else if (field === 'policy') out.policy.push(m[2]);
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --- Templates (`readiness --init`) --------------------------------------------
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Writes SECURITY.md and .well-known/security.txt templates prefilled from
|
|
134
|
+
* package.json. Existing files are never overwritten.
|
|
135
|
+
*
|
|
136
|
+
* @returns {Array<{ file: string, created: boolean }>}
|
|
137
|
+
*/
|
|
138
|
+
function writeTemplates(projectRoot, { now = new Date() } = {}) {
|
|
139
|
+
const pkg = readJson(path.join(projectRoot, 'package.json')) || {};
|
|
140
|
+
const repo = repositoryUrl(pkg.repository);
|
|
141
|
+
const advisories = repo && /github\.com/.test(repo) ? `${repo}/security/advisories/new` : null;
|
|
142
|
+
const name = pkg.name || path.basename(projectRoot);
|
|
143
|
+
const major = String(pkg.version || '1.0.0').split('.')[0];
|
|
144
|
+
const results = [];
|
|
145
|
+
|
|
146
|
+
const existingMd = firstExisting(projectRoot, SECURITY_MD_PATHS);
|
|
147
|
+
if (existingMd) {
|
|
148
|
+
results.push({ file: existingMd, created: false });
|
|
149
|
+
} else {
|
|
150
|
+
fs.writeFileSync(path.join(projectRoot, 'SECURITY.md'), securityMdTemplate({ name, major, advisories }));
|
|
151
|
+
results.push({ file: 'SECURITY.md', created: true });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const existingTxt = firstExisting(projectRoot, SECURITY_TXT_PATHS);
|
|
155
|
+
if (existingTxt) {
|
|
156
|
+
results.push({ file: existingTxt, created: false });
|
|
157
|
+
} else {
|
|
158
|
+
const base = fs.existsSync(path.join(projectRoot, 'public')) ? 'public/.well-known' : '.well-known';
|
|
159
|
+
fs.mkdirSync(path.join(projectRoot, base), { recursive: true });
|
|
160
|
+
const expires = new Date(now.getTime() + 364 * DAY).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
161
|
+
fs.writeFileSync(path.join(projectRoot, base, 'security.txt'), securityTxtTemplate({ advisories, repo, expires }));
|
|
162
|
+
results.push({ file: `${base}/security.txt`, created: true });
|
|
163
|
+
}
|
|
164
|
+
return results;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function securityMdTemplate({ name, major, advisories }) {
|
|
168
|
+
const channel = advisories
|
|
169
|
+
? `Report it privately through [GitHub Security Advisories](${advisories}), or by email to TODO: security@example.com.`
|
|
170
|
+
: 'Report it privately by email to TODO: security@example.com.';
|
|
171
|
+
return `# Security Policy
|
|
172
|
+
|
|
173
|
+
## Supported Versions
|
|
174
|
+
|
|
175
|
+
| Version | Supported | End of security support |
|
|
176
|
+
| ------- | --------- | ----------------------- |
|
|
177
|
+
| ${major}.x | ✅ | TODO: YYYY-MM-DD |
|
|
178
|
+
| < ${major}.0 | ❌ | — |
|
|
179
|
+
|
|
180
|
+
**Support period:** security updates for ${name} ${major}.x are provided until TODO: YYYY-MM-DD
|
|
181
|
+
(the EU Cyber Resilience Act expects at least 5 years, or the expected time of use of the product).
|
|
182
|
+
|
|
183
|
+
## Reporting a Vulnerability
|
|
184
|
+
|
|
185
|
+
Please do **not** open a public issue for security problems.
|
|
186
|
+
|
|
187
|
+
${channel}
|
|
188
|
+
|
|
189
|
+
Include the affected version, a description of the issue and, if possible, steps to reproduce it.
|
|
190
|
+
|
|
191
|
+
## Our Process (Coordinated Vulnerability Disclosure)
|
|
192
|
+
|
|
193
|
+
- We acknowledge reports within **3 business days** and send a first assessment within **10 business days**.
|
|
194
|
+
- We agree a disclosure date with the reporter, normally within **90 days**, and credit reporters who wish to be named.
|
|
195
|
+
- Fixes are released as security updates, separate from feature updates where possible, and announced in the release notes and a GitHub Security Advisory.
|
|
196
|
+
|
|
197
|
+
## EU Cyber Resilience Act — Reporting (Art. 14)
|
|
198
|
+
|
|
199
|
+
When we become aware of an actively exploited vulnerability in ${name}, or a severe incident affecting its security,
|
|
200
|
+
we notify the coordinating CSIRT and ENISA through the Single Reporting Platform:
|
|
201
|
+
|
|
202
|
+
- an **early warning within 24 hours**,
|
|
203
|
+
- a **notification within 72 hours**,
|
|
204
|
+
- a **final report within 14 days** after a corrective measure is available (one month for severe incidents).
|
|
205
|
+
|
|
206
|
+
Affected users are informed of the issue and of the corrective measures to take.
|
|
207
|
+
`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function securityTxtTemplate({ advisories, repo, expires }) {
|
|
211
|
+
const lines = ['# RFC 9116 — https://securitytxt.org', 'Contact: mailto:TODO-security@example.com'];
|
|
212
|
+
if (advisories) lines.push(`Contact: ${advisories}`);
|
|
213
|
+
lines.push(`Expires: ${expires}`);
|
|
214
|
+
if (repo) lines.push(`Policy: ${repo}/blob/HEAD/SECURITY.md`);
|
|
215
|
+
lines.push('Preferred-Languages: en', '');
|
|
216
|
+
return lines.join('\n');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function firstExisting(root, candidates) {
|
|
220
|
+
return candidates.find((rel) => fs.existsSync(path.join(root, rel))) || null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function read(file) {
|
|
224
|
+
try {
|
|
225
|
+
return fs.readFileSync(file, 'utf8');
|
|
226
|
+
} catch {
|
|
227
|
+
return '';
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
module.exports = { checkReadiness, writeTemplates, parseSecurityTxt };
|
package/src/core/vex.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const { buildPurl } = require('./lockfile-parser');
|
|
5
|
+
|
|
6
|
+
const TOOL_NAME = 'cra-audit';
|
|
7
|
+
const TOOL_VERSION = require('../../package.json').version;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* VEX (Vulnerability Exploitability eXchange) states the exploitability of
|
|
11
|
+
* each known vulnerability in the product. TR-03183-2 keeps vulnerability data
|
|
12
|
+
* out of the SBOM and points to VEX for it, and the CRA asks manufacturers to
|
|
13
|
+
* document how each vulnerability was assessed.
|
|
14
|
+
*
|
|
15
|
+
* The assessments come from the policy allowlist
|
|
16
|
+
* (`vulnerabilities.allowlist` in .cra-audit.json). Entries can be plain ids
|
|
17
|
+
* or package names (legacy), or objects:
|
|
18
|
+
*
|
|
19
|
+
* { "id": "CVE-2021-23337", "package": "lodash",
|
|
20
|
+
* "status": "not_affected", "justification": "code_not_reachable",
|
|
21
|
+
* "detail": "We never call _.template with user input." }
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Statuses, as written in the policy, and whether they accept the finding. */
|
|
25
|
+
const STATUSES = {
|
|
26
|
+
not_affected: { accepts: true, cdx: 'not_affected', openvex: 'not_affected' },
|
|
27
|
+
false_positive: { accepts: true, cdx: 'false_positive', openvex: 'not_affected' },
|
|
28
|
+
affected: { accepts: false, cdx: 'exploitable', openvex: 'affected' },
|
|
29
|
+
under_investigation: { accepts: false, cdx: 'in_triage', openvex: 'under_investigation' },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Justifications accepted in the policy (CycloneDX or OpenVEX vocabulary) and
|
|
34
|
+
* their equivalent in the other format.
|
|
35
|
+
*/
|
|
36
|
+
const JUSTIFICATIONS = {
|
|
37
|
+
code_not_present: { cdx: 'code_not_present', openvex: 'vulnerable_code_not_present' },
|
|
38
|
+
code_not_reachable: { cdx: 'code_not_reachable', openvex: 'vulnerable_code_not_in_execute_path' },
|
|
39
|
+
requires_configuration: { cdx: 'requires_configuration', openvex: 'vulnerable_code_cannot_be_controlled_by_adversary' },
|
|
40
|
+
requires_dependency: { cdx: 'requires_dependency', openvex: 'vulnerable_code_not_in_execute_path' },
|
|
41
|
+
requires_environment: { cdx: 'requires_environment', openvex: 'vulnerable_code_cannot_be_controlled_by_adversary' },
|
|
42
|
+
protected_by_compiler: { cdx: 'protected_by_compiler', openvex: 'inline_mitigations_already_exist' },
|
|
43
|
+
protected_at_runtime: { cdx: 'protected_at_runtime', openvex: 'inline_mitigations_already_exist' },
|
|
44
|
+
protected_at_perimeter: { cdx: 'protected_at_perimeter', openvex: 'inline_mitigations_already_exist' },
|
|
45
|
+
protected_by_mitigating_control: { cdx: 'protected_by_mitigating_control', openvex: 'inline_mitigations_already_exist' },
|
|
46
|
+
component_not_present: { cdx: 'code_not_present', openvex: 'component_not_present' },
|
|
47
|
+
vulnerable_code_not_present: { cdx: 'code_not_present', openvex: 'vulnerable_code_not_present' },
|
|
48
|
+
vulnerable_code_not_in_execute_path: { cdx: 'code_not_reachable', openvex: 'vulnerable_code_not_in_execute_path' },
|
|
49
|
+
vulnerable_code_cannot_be_controlled_by_adversary: { cdx: 'requires_environment', openvex: 'vulnerable_code_cannot_be_controlled_by_adversary' },
|
|
50
|
+
inline_mitigations_already_exist: { cdx: 'protected_by_mitigating_control', openvex: 'inline_mitigations_already_exist' },
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Normalizes the policy allowlist into assessment objects.
|
|
55
|
+
* @returns {Array<{ id: string|null, package: string|null, status: string, justification: string|null, detail: string|null }>}
|
|
56
|
+
*/
|
|
57
|
+
function normalizeAllowlist(policy) {
|
|
58
|
+
const list = (policy && policy.vulnerabilities && policy.vulnerabilities.allowlist) || [];
|
|
59
|
+
return list.map((entry) => {
|
|
60
|
+
if (typeof entry === 'string') {
|
|
61
|
+
// Legacy form: a package name, an advisory id/alias or an advisory URL fragment.
|
|
62
|
+
return { id: entry, package: null, status: 'not_affected', justification: null, detail: null, legacy: true };
|
|
63
|
+
}
|
|
64
|
+
const status = STATUSES[entry.status] ? entry.status : 'not_affected';
|
|
65
|
+
return {
|
|
66
|
+
id: entry.id || null,
|
|
67
|
+
package: entry.package || null,
|
|
68
|
+
status,
|
|
69
|
+
justification: JUSTIFICATIONS[entry.justification] ? entry.justification : null,
|
|
70
|
+
detail: entry.detail || null,
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Does an assessment apply to one advisory of a finding? */
|
|
76
|
+
function entryMatches(entry, finding, source) {
|
|
77
|
+
if (entry.legacy && entry.id === finding.name) return true;
|
|
78
|
+
if (entry.package && entry.package !== finding.name) return false;
|
|
79
|
+
if (!entry.id) return Boolean(entry.package);
|
|
80
|
+
const ids = [source.id, ...(source.aliases || [])].filter(Boolean);
|
|
81
|
+
return ids.includes(entry.id) || Boolean(source.url && source.url.includes(entry.id));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The assessment recorded for one advisory of a finding, if any. */
|
|
85
|
+
function assessmentFor(finding, source, entries) {
|
|
86
|
+
return entries.find((e) => entryMatches(e, finding, source)) || null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A finding is accepted when every advisory on it has an accepting assessment
|
|
91
|
+
* (not_affected / false_positive). Malicious packages are never accepted.
|
|
92
|
+
*
|
|
93
|
+
* @returns {{ accepted: boolean, unjustified: number }}
|
|
94
|
+
*/
|
|
95
|
+
function acceptance(finding, policy) {
|
|
96
|
+
const entries = normalizeAllowlist(policy);
|
|
97
|
+
if (!entries.length || finding.malicious) return { accepted: false, unjustified: 0 };
|
|
98
|
+
const assessments = finding.sources.map((s) => assessmentFor(finding, s, entries));
|
|
99
|
+
const accepted = assessments.every((a) => a && STATUSES[a.status].accepts);
|
|
100
|
+
const unjustified = accepted ? assessments.filter((a) => !a.justification && !a.detail).length : 0;
|
|
101
|
+
return { accepted, unjustified };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- Documents -------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Builds a VEX document for the vulnerability findings of an audit.
|
|
108
|
+
*
|
|
109
|
+
* @param {{ name: string, version: string }} product
|
|
110
|
+
* @param {object} vulnSection Result of scanVulnerabilities().
|
|
111
|
+
* @param {object} policy
|
|
112
|
+
* @param {{ format?: 'cyclonedx'|'openvex', author?: string|null }} [options]
|
|
113
|
+
*/
|
|
114
|
+
function buildVex(product, vulnSection, policy, { format = 'cyclonedx', author = null } = {}) {
|
|
115
|
+
const entries = normalizeAllowlist(policy);
|
|
116
|
+
const productPurl = buildPurl(product.name, product.version);
|
|
117
|
+
const statements = [];
|
|
118
|
+
|
|
119
|
+
for (const finding of vulnSection.vulnerabilities || []) {
|
|
120
|
+
const purl = buildPurl(finding.name, finding.version);
|
|
121
|
+
for (const source of finding.sources) {
|
|
122
|
+
const assessment = finding.malicious ? null : assessmentFor(finding, source, entries);
|
|
123
|
+
statements.push({ finding, source, purl, assessment });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return format === 'openvex'
|
|
128
|
+
? buildOpenVex(productPurl, statements, author)
|
|
129
|
+
: buildCycloneDxVex(product, productPurl, statements);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function buildCycloneDxVex(product, productPurl, statements) {
|
|
133
|
+
const vulnerabilities = statements.map(({ finding, source, purl, assessment }) => {
|
|
134
|
+
const { primary, others } = identifiers(source);
|
|
135
|
+
const analysis = { state: cdxState(finding, assessment) };
|
|
136
|
+
if (assessment && assessment.justification) analysis.justification = JUSTIFICATIONS[assessment.justification].cdx;
|
|
137
|
+
const detail = analysisDetail(finding, source, assessment);
|
|
138
|
+
if (detail) analysis.detail = detail;
|
|
139
|
+
if (analysis.state === 'exploitable' || analysis.state === 'in_triage') {
|
|
140
|
+
analysis.response = finding.malicious ? ['update'] : finding.fixAvailable ? ['update'] : ['can_not_fix'];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const vuln = {
|
|
144
|
+
'bom-ref': `${primary}@${purl}`,
|
|
145
|
+
id: primary,
|
|
146
|
+
source: { name: 'OSV', url: `https://osv.dev/vulnerability/${source.id}` },
|
|
147
|
+
references: others.map((id) => ({ id, source: { name: sourceName(id), url: advisoryUrl(id) } })),
|
|
148
|
+
ratings: [{
|
|
149
|
+
source: { name: 'OSV' },
|
|
150
|
+
severity: cdxSeverity(source.severity),
|
|
151
|
+
...(source.cvss !== null && source.cvss !== undefined ? { score: source.cvss, method: 'CVSSv31' } : {}),
|
|
152
|
+
}],
|
|
153
|
+
cwes: (source.cwe || []).map((c) => parseInt(String(c).replace(/^CWE-/i, ''), 10)).filter(Number.isFinite),
|
|
154
|
+
description: source.title || undefined,
|
|
155
|
+
advisories: source.url ? [{ url: source.url }] : undefined,
|
|
156
|
+
affects: [{ ref: purl }],
|
|
157
|
+
analysis,
|
|
158
|
+
};
|
|
159
|
+
if (source.kev) {
|
|
160
|
+
vuln.properties = [
|
|
161
|
+
{ name: 'cra-audit:kev', value: 'true' },
|
|
162
|
+
{ name: 'cra-audit:kev:dateAdded', value: String(source.kev.dateAdded) },
|
|
163
|
+
];
|
|
164
|
+
}
|
|
165
|
+
if (!vuln.references.length) delete vuln.references;
|
|
166
|
+
if (!vuln.cwes.length) delete vuln.cwes;
|
|
167
|
+
return vuln;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
bomFormat: 'CycloneDX',
|
|
172
|
+
specVersion: '1.6',
|
|
173
|
+
serialNumber: `urn:uuid:${crypto.randomUUID()}`,
|
|
174
|
+
version: 1,
|
|
175
|
+
metadata: {
|
|
176
|
+
timestamp: new Date().toISOString(),
|
|
177
|
+
tools: { components: [{ type: 'application', name: TOOL_NAME, version: TOOL_VERSION }] },
|
|
178
|
+
component: { type: 'application', 'bom-ref': productPurl, name: product.name, version: product.version, purl: productPurl },
|
|
179
|
+
},
|
|
180
|
+
vulnerabilities,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function buildOpenVex(productPurl, statements, author) {
|
|
185
|
+
return {
|
|
186
|
+
'@context': 'https://openvex.dev/ns/v0.2.0',
|
|
187
|
+
'@id': `https://openvex.dev/docs/public/cra-audit-${crypto.randomUUID()}`,
|
|
188
|
+
author: author || TOOL_NAME,
|
|
189
|
+
timestamp: new Date().toISOString(),
|
|
190
|
+
version: 1,
|
|
191
|
+
tooling: `${TOOL_NAME}/${TOOL_VERSION}`,
|
|
192
|
+
statements: statements.map(({ finding, source, purl, assessment }) => {
|
|
193
|
+
const { primary, others } = identifiers(source);
|
|
194
|
+
const status = finding.malicious ? 'affected' : assessment ? STATUSES[assessment.status].openvex : 'under_investigation';
|
|
195
|
+
const statement = {
|
|
196
|
+
vulnerability: { name: primary, ...(others.length ? { aliases: others } : {}) },
|
|
197
|
+
products: [{ '@id': productPurl, subcomponents: [{ '@id': purl }] }],
|
|
198
|
+
status,
|
|
199
|
+
};
|
|
200
|
+
const detail = analysisDetail(finding, source, assessment);
|
|
201
|
+
if (status === 'not_affected') {
|
|
202
|
+
if (assessment.justification) statement.justification = JUSTIFICATIONS[assessment.justification].openvex;
|
|
203
|
+
// OpenVEX requires a justification or an impact statement.
|
|
204
|
+
statement.impact_statement = detail || 'Assessed as not affected; justification not recorded in the cra-audit policy.';
|
|
205
|
+
} else if (status === 'affected') {
|
|
206
|
+
statement.action_statement = actionStatement(finding);
|
|
207
|
+
} else if (detail) {
|
|
208
|
+
statement.status_notes = detail;
|
|
209
|
+
}
|
|
210
|
+
return statement;
|
|
211
|
+
}),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function cdxState(finding, assessment) {
|
|
216
|
+
if (finding.malicious) return 'exploitable';
|
|
217
|
+
return assessment ? STATUSES[assessment.status].cdx : 'in_triage';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function analysisDetail(finding, source, assessment) {
|
|
221
|
+
const parts = [];
|
|
222
|
+
if (finding.malicious) parts.push('Malicious package (OpenSSF malicious-packages): remove it and rotate exposed credentials.');
|
|
223
|
+
if (source.kev) parts.push(`Listed in CISA KEV (actively exploited) since ${source.kev.dateAdded}.`);
|
|
224
|
+
if (assessment && assessment.detail) parts.push(assessment.detail);
|
|
225
|
+
if (assessment && !assessment.detail && !assessment.justification && STATUSES[assessment.status].accepts) {
|
|
226
|
+
parts.push('Accepted in the cra-audit policy without a recorded justification.');
|
|
227
|
+
}
|
|
228
|
+
return parts.join(' ') || undefined;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function actionStatement(finding) {
|
|
232
|
+
if (finding.malicious) return `Remove ${finding.name}@${finding.version}, reinstall from a clean lockfile and rotate exposed credentials.`;
|
|
233
|
+
if (finding.fixAvailable && typeof finding.fixAvailable === 'object') {
|
|
234
|
+
return `Update ${finding.name} to ${finding.fixAvailable.version} or later.`;
|
|
235
|
+
}
|
|
236
|
+
return `No fixed version of ${finding.name} is available: remove or replace the dependency, or mitigate.`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Prefer the CVE as the VEX id; keep the rest as references/aliases. */
|
|
240
|
+
function identifiers(source) {
|
|
241
|
+
const all = [source.id, ...(source.aliases || [])].filter(Boolean);
|
|
242
|
+
const primary = all.find((id) => /^CVE-/i.test(id)) || all[0] || 'UNKNOWN';
|
|
243
|
+
return { primary, others: all.filter((id) => id !== primary) };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function sourceName(id) {
|
|
247
|
+
if (/^CVE-/i.test(id)) return 'NVD';
|
|
248
|
+
if (/^GHSA-/i.test(id)) return 'GitHub';
|
|
249
|
+
return 'OSV';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function advisoryUrl(id) {
|
|
253
|
+
if (/^CVE-/i.test(id)) return `https://nvd.nist.gov/vuln/detail/${id}`;
|
|
254
|
+
if (/^GHSA-/i.test(id)) return `https://github.com/advisories/${id}`;
|
|
255
|
+
return `https://osv.dev/vulnerability/${id}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** CycloneDX uses "medium" where npm/GHSA say "moderate". */
|
|
259
|
+
function cdxSeverity(severity) {
|
|
260
|
+
if (severity === 'moderate') return 'medium';
|
|
261
|
+
return ['critical', 'high', 'medium', 'low', 'info', 'none'].includes(severity) ? severity : 'unknown';
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
module.exports = { buildVex, normalizeAllowlist, assessmentFor, acceptance, STATUSES, JUSTIFICATIONS };
|
package/src/index.js
CHANGED
|
@@ -18,6 +18,9 @@ const { checkLicenses } = require('./core/license-checker');
|
|
|
18
18
|
const { parseLockfile } = require('./core/lockfile-parser');
|
|
19
19
|
const { enrichComponents } = require('./core/enrich');
|
|
20
20
|
const { buildHtml } = require('./reporters/html');
|
|
21
|
+
const { buildSarif } = require('./reporters/sarif');
|
|
22
|
+
const { buildVex } = require('./core/vex');
|
|
23
|
+
const { checkReadiness } = require('./core/readiness');
|
|
21
24
|
|
|
22
25
|
module.exports = {
|
|
23
26
|
runAudit,
|
|
@@ -30,4 +33,7 @@ module.exports = {
|
|
|
30
33
|
parseLockfile,
|
|
31
34
|
enrichComponents,
|
|
32
35
|
buildHtml,
|
|
36
|
+
buildSarif,
|
|
37
|
+
buildVex,
|
|
38
|
+
checkReadiness,
|
|
33
39
|
};
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { writeJson } = require('../utils/fs');
|
|
6
|
+
const { logger } = require('../utils/logger');
|
|
7
|
+
const { parseLockfile } = require('../core/lockfile-parser');
|
|
8
|
+
const { normalizeAllowlist, assessmentFor, STATUSES } = require('../core/vex');
|
|
9
|
+
|
|
10
|
+
const TOOL_VERSION = require('../../package.json').version;
|
|
11
|
+
const INFO_URI = 'https://github.com/migohe14/cra-audit';
|
|
12
|
+
|
|
13
|
+
/** GitHub code scanning ranks alerts by this 0-10 score. */
|
|
14
|
+
const SECURITY_SEVERITY = { critical: 9.5, high: 8.0, moderate: 5.5, low: 2.0, info: 0.5, unknown: 5.0 };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Converts an audit report to SARIF 2.1.0, the format GitHub code scanning
|
|
18
|
+
* ingests (`github/codeql-action/upload-sarif`). Each advisory on each
|
|
19
|
+
* vulnerable component becomes one result, located on the component's entry
|
|
20
|
+
* in the lockfile; license problems become results too.
|
|
21
|
+
*
|
|
22
|
+
* Advisories assessed as not_affected in the policy are kept as suppressed
|
|
23
|
+
* `note` results carrying the justification, so the decision stays visible.
|
|
24
|
+
*
|
|
25
|
+
* @param {import('../core/auditor').AuditResult} report
|
|
26
|
+
* @param {string} projectRoot
|
|
27
|
+
* @param {object} [policy]
|
|
28
|
+
*/
|
|
29
|
+
function buildSarif(report, projectRoot, policy = {}) {
|
|
30
|
+
const rules = new Map();
|
|
31
|
+
const results = [];
|
|
32
|
+
const lock = lockfileLocator(projectRoot);
|
|
33
|
+
const assessments = normalizeAllowlist(policy);
|
|
34
|
+
|
|
35
|
+
const vulns = report.sections.vulnerabilities;
|
|
36
|
+
if (vulns && vulns.ok) {
|
|
37
|
+
for (const finding of vulns.vulnerabilities) {
|
|
38
|
+
for (const source of finding.sources) {
|
|
39
|
+
const ruleId = source.id || (source.url ? source.url.split('/').pop() : `${finding.name}-vulnerability`);
|
|
40
|
+
if (!rules.has(ruleId)) rules.set(ruleId, vulnerabilityRule(ruleId, finding, source));
|
|
41
|
+
const assessment = finding.malicious ? null : assessmentFor(finding, source, assessments);
|
|
42
|
+
const accepted = Boolean(assessment && STATUSES[assessment.status].accepts);
|
|
43
|
+
const result = {
|
|
44
|
+
ruleId,
|
|
45
|
+
level: accepted ? 'note'
|
|
46
|
+
: finding.malicious || source.kev || ['critical', 'high'].includes(source.severity || finding.severity) ? 'error'
|
|
47
|
+
: (source.severity || finding.severity) === 'moderate' ? 'warning' : 'note',
|
|
48
|
+
message: { text: vulnerabilityMessage(finding, source) },
|
|
49
|
+
locations: [lock.locate(finding.name, finding.version)],
|
|
50
|
+
partialFingerprints: { 'craAudit/v1': `${ruleId}:${finding.name}@${finding.version || ''}` },
|
|
51
|
+
};
|
|
52
|
+
if (accepted) {
|
|
53
|
+
result.suppressions = [{
|
|
54
|
+
kind: 'external',
|
|
55
|
+
status: 'accepted',
|
|
56
|
+
justification: [assessment.status, assessment.justification, assessment.detail].filter(Boolean).join(' — '),
|
|
57
|
+
}];
|
|
58
|
+
}
|
|
59
|
+
results.push(result);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const licenses = report.sections.licenses;
|
|
65
|
+
if (licenses && licenses.ok) {
|
|
66
|
+
const s = licenses.summary;
|
|
67
|
+
const groups = [
|
|
68
|
+
['cra-audit/license-denied', s.denied, 'error', (c) => `${c.name}@${c.version} is licensed under ${c.license}, which the policy denies.`],
|
|
69
|
+
['cra-audit/license-not-allowed', s.notAllowed, 'warning', (c) => `${c.name}@${c.version} is licensed under ${c.license}, which is not in the policy allowlist.`],
|
|
70
|
+
['cra-audit/license-missing', s.missing, 'warning', (c) => `${c.name}@${c.version} has no documented license (TR-03183-2 §5.2.2).`],
|
|
71
|
+
];
|
|
72
|
+
for (const [ruleId, list, level, text] of groups) {
|
|
73
|
+
if (!list.length) continue;
|
|
74
|
+
rules.set(ruleId, licenseRule(ruleId));
|
|
75
|
+
for (const c of list) {
|
|
76
|
+
results.push({
|
|
77
|
+
ruleId,
|
|
78
|
+
level,
|
|
79
|
+
message: { text: text(c) },
|
|
80
|
+
locations: [lock.locate(c.name, c.version)],
|
|
81
|
+
partialFingerprints: { 'craAudit/v1': `${ruleId}:${c.name}@${c.version}` },
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
|
|
89
|
+
version: '2.1.0',
|
|
90
|
+
runs: [{
|
|
91
|
+
tool: {
|
|
92
|
+
driver: {
|
|
93
|
+
name: 'cra-audit',
|
|
94
|
+
version: TOOL_VERSION,
|
|
95
|
+
semanticVersion: TOOL_VERSION,
|
|
96
|
+
informationUri: INFO_URI,
|
|
97
|
+
rules: [...rules.values()],
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
originalUriBaseIds: { '%SRCROOT%': { uri: toFileUri(lock.repoRoot) } },
|
|
101
|
+
results,
|
|
102
|
+
}],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function vulnerabilityRule(ruleId, finding, source) {
|
|
107
|
+
const severity = source.severity || finding.severity;
|
|
108
|
+
const score = finding.malicious ? 10 : source.cvss || SECURITY_SEVERITY[severity] || 5;
|
|
109
|
+
const tags = ['security', 'vulnerability', 'supply-chain', 'cra'];
|
|
110
|
+
if (finding.malicious) tags.push('malicious');
|
|
111
|
+
if (source.kev) tags.push('actively-exploited');
|
|
112
|
+
const cves = (source.aliases || []).filter((a) => /^CVE-/i.test(a));
|
|
113
|
+
const title = finding.malicious ? `Malicious package: ${finding.name}` : source.title || ruleId;
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
id: ruleId,
|
|
117
|
+
name: ruleId.replace(/[^A-Za-z0-9]/g, ''),
|
|
118
|
+
shortDescription: { text: title },
|
|
119
|
+
fullDescription: { text: [title, cves.length ? `(${cves.join(', ')})` : ''].join(' ').trim() },
|
|
120
|
+
helpUri: source.url || INFO_URI,
|
|
121
|
+
help: {
|
|
122
|
+
text: finding.malicious
|
|
123
|
+
? 'This release was published by an attacker. Remove it, reinstall from a clean lockfile, and rotate every credential available to machines that installed it.'
|
|
124
|
+
: 'Update the dependency to a fixed version, or record in .cra-audit.json why it is not exploitable in your product (the assessment goes to the VEX document).',
|
|
125
|
+
},
|
|
126
|
+
properties: {
|
|
127
|
+
tags,
|
|
128
|
+
precision: 'very-high',
|
|
129
|
+
'security-severity': String(Math.min(10, Number(score)).toFixed(1)),
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function vulnerabilityMessage(finding, source) {
|
|
135
|
+
const pkg = `${finding.name}${finding.version ? `@${finding.version}` : ''}`;
|
|
136
|
+
if (finding.malicious) {
|
|
137
|
+
return `[MALICIOUS] ${pkg} is a known compromised release (${source.id}). Remove it and rotate exposed credentials.`;
|
|
138
|
+
}
|
|
139
|
+
const parts = [];
|
|
140
|
+
if (source.kev) parts.push(`[ACTIVELY EXPLOITED — CISA KEV since ${source.kev.dateAdded}; CRA Art. 14 reporting may apply]`);
|
|
141
|
+
parts.push(`${pkg}: ${source.title || source.id}`);
|
|
142
|
+
const cves = (source.aliases || []).filter((a) => /^CVE-/i.test(a));
|
|
143
|
+
if (cves.length) parts.push(`(${cves.join(', ')})`);
|
|
144
|
+
const fixed = source.fixed || (finding.fixAvailable && finding.fixAvailable.version);
|
|
145
|
+
parts.push(fixed ? `Fixed in ${fixed}.` : 'No fixed version available.');
|
|
146
|
+
if (!finding.direct) parts.push('Transitive dependency.');
|
|
147
|
+
return parts.join(' ');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function licenseRule(ruleId) {
|
|
151
|
+
const texts = {
|
|
152
|
+
'cra-audit/license-denied': 'Dependency license denied by the policy',
|
|
153
|
+
'cra-audit/license-not-allowed': 'Dependency license outside the policy allowlist',
|
|
154
|
+
'cra-audit/license-missing': 'Dependency without a documented license',
|
|
155
|
+
};
|
|
156
|
+
return {
|
|
157
|
+
id: ruleId,
|
|
158
|
+
name: ruleId.replace(/[^A-Za-z0-9]/g, ''),
|
|
159
|
+
shortDescription: { text: texts[ruleId] },
|
|
160
|
+
helpUri: INFO_URI,
|
|
161
|
+
properties: { tags: ['license', 'compliance', 'cra'], precision: 'high' },
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Finds the line of `name@version` in the project lockfile so each alert
|
|
167
|
+
* points at the exact entry. Falls back to package.json line 1.
|
|
168
|
+
*/
|
|
169
|
+
function lockfileLocator(projectRoot) {
|
|
170
|
+
const repoRoot = findRepoRoot(projectRoot);
|
|
171
|
+
const parsed = parseLockfile(projectRoot);
|
|
172
|
+
const file = parsed.lockfileName ? path.join(projectRoot, parsed.lockfileName) : path.join(projectRoot, 'package.json');
|
|
173
|
+
let lines = [];
|
|
174
|
+
try {
|
|
175
|
+
lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
|
|
176
|
+
} catch {
|
|
177
|
+
// Unreadable lockfile: every result points at line 1.
|
|
178
|
+
}
|
|
179
|
+
const uri = path.relative(repoRoot, file).split(path.sep).join('/');
|
|
180
|
+
|
|
181
|
+
const locate = (name, version) => {
|
|
182
|
+
const line = findEntryLine(lines, name, version);
|
|
183
|
+
return {
|
|
184
|
+
physicalLocation: {
|
|
185
|
+
artifactLocation: { uri, uriBaseId: '%SRCROOT%' },
|
|
186
|
+
region: { startLine: line },
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
};
|
|
190
|
+
return { repoRoot, locate };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function findEntryLine(lines, name, version) {
|
|
194
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
195
|
+
// npm: "node_modules/name": { | yarn: name@range: / "name@npm:range": | pnpm: name@version: / /name/version:
|
|
196
|
+
const header = new RegExp(`(node_modules/${escaped}"\\s*:|^\\s*["']?/?${escaped}(@|/)[^\\s]*:?\\s*$|^\\s*["']?/?${escaped}@)`);
|
|
197
|
+
for (let i = 0; i < lines.length; i++) {
|
|
198
|
+
if (!header.test(lines[i])) continue;
|
|
199
|
+
if (!version) return i + 1;
|
|
200
|
+
// The entry's version is on the key itself (pnpm) or within the next lines.
|
|
201
|
+
if (lines[i].includes(version)) return i + 1;
|
|
202
|
+
for (let j = i + 1; j < Math.min(lines.length, i + 8); j++) {
|
|
203
|
+
if (lines[j].includes(`"version": "${version}"`) || lines[j].includes(`version "${version}"`) ||
|
|
204
|
+
new RegExp(`^\\s*version:\\s*["']?${version.replace(/\./g, '\\.')}["']?\\s*$`).test(lines[j])) {
|
|
205
|
+
return i + 1;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return 1;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Nearest folder with a .git entry: SARIF paths must be repository-relative. */
|
|
213
|
+
function findRepoRoot(start) {
|
|
214
|
+
let dir = path.resolve(start);
|
|
215
|
+
for (;;) {
|
|
216
|
+
if (fs.existsSync(path.join(dir, '.git'))) return dir;
|
|
217
|
+
const parent = path.dirname(dir);
|
|
218
|
+
if (parent === dir) return path.resolve(start);
|
|
219
|
+
dir = parent;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function toFileUri(dir) {
|
|
224
|
+
const normalized = dir.split(path.sep).join('/');
|
|
225
|
+
return `file://${normalized.startsWith('/') ? '' : '/'}${normalized}/`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Writes the SARIF report to disk. `quiet` keeps stdout clean when it
|
|
230
|
+
* carries the JSON report.
|
|
231
|
+
*/
|
|
232
|
+
function reportSarif(report, projectRoot, outputPath, { quiet = false, policy } = {}) {
|
|
233
|
+
const outPath = path.isAbsolute(outputPath) ? outputPath : path.join(process.cwd(), outputPath);
|
|
234
|
+
writeJson(outPath, buildSarif(report, projectRoot, policy));
|
|
235
|
+
if (!quiet) logger.success(`SARIF report written to: ${outPath}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
module.exports = { buildSarif, reportSarif, findEntryLine };
|