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 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`: package names or advisory ids (GHSA, CVE) accepted with documented justification, e.g. when the vulnerable code is not reachable in your product. Malicious packages (`MAL-*`) cannot be allowlisted.
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
- # Example in GitHub Actions
256
- - name: CRA compliance audit
257
- run: npx cra-audit --fail-on high --production --json -o cra-report.json
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.0.0",
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
  `);
@@ -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 };
@@ -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 the policy allowlist names the package, or every
159
- * advisory on it by id/alias (GHSA, CVE) or advisory URL.
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
- const allowlist = (policy.vulnerabilities && policy.vulnerabilities.allowlist) || [];
163
- if (!allowlist.length) return false;
164
- if (allowlist.includes(vuln.name)) return true;
165
- return vuln.sources.every((src) => allowlist.some((id) =>
166
- [src.id, ...(src.aliases || [])].includes(id) || (src.url && src.url.includes(id))));
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 };
@@ -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 };