@ulisesgascon/openjs-cna-tools 1.0.0-alpha.1 → 1.0.0-alpha.3

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/README.md CHANGED
@@ -29,11 +29,12 @@ For project maintainers under the OpenJS CNA who need to reserve, publish, updat
29
29
  |---|---|
30
30
  | `openjs-cna-experimental reserve` | Reserve a CVE id and print it on stdout. |
31
31
  | `openjs-cna-experimental publish <cve-id> -f container.json` | Publish a v5.2 CNA Container. Runs the reviewer first by default. |
32
+ | `openjs-cna-experimental publish -f a.json -f b.json …` | Batch publish. Reviews every record first and sends nothing unless all pass, then publishes each in order. `--skip-published` makes reruns idempotent, `--json` prints one result per record. |
32
33
  | `openjs-cna-experimental update <cve-id> -f container.json` | Update a published CVE record. Same review semantics as `publish`. |
33
34
  | `openjs-cna-experimental get <cve-id>` | Fetch a published CVE's container from MITRE. |
34
35
  | `openjs-cna-experimental list` | List every CVE id in the bucket. |
35
36
  | `openjs-cna-experimental convert <advisory-url>` | Turn a GitHub Security Advisory into a draft CNA Container. Accepts published per-repo URLs (`https://github.com/<owner>/<repo>/security/advisories/<GHSA>`) and global-database URLs (`https://github.com/advisories/<GHSA>`) for public ones. |
36
- | `openjs-cna-experimental review <container.json>` | Run the offline reviewer against a container. Defaults to showing a preview + issues. Exits non-zero on blockers. |
37
+ | `openjs-cna-experimental review <container.json> [more.json …]` | Run the offline reviewer against one or more records. Defaults to showing a preview + issues. Exits non-zero on blockers. With several files `--json` emits an array. |
37
38
  | `openjs-cna-experimental preview <container.json>` | Print a compact summary of a container — title, description, affected, CVSS, CWE, credits, refs, etc. |
38
39
  | `openjs-cna-experimental llm-review-prompt <record.json>` | Print a prompt for an LLM-driven editorial review of the record's title and description. The tool does not call an LLM; paste the prompt into the LLM of your choice. |
39
40
  | `openjs-cna-experimental health` | Unauthenticated liveness check against the CNA API. |
@@ -138,6 +139,35 @@ Useful flags:
138
139
  - `--no-preview` (on `review`): drop the container summary; print issues only. Useful in CI.
139
140
  - `--tier mitre-common|openjs-extra` (on `review`): filter the printed issues to a single tier without changing the exit code.
140
141
  - `--yes` / `OPENJS_CNA_YES=1`: accept confirmation prompts non-interactively.
142
+ - `--skip-published` (on batch `publish`): ask the CNA first (`get-cve`, one sequential call per id) and skip ids that already have a published record, so a rerun after a partial failure only sends what is missing. Only a record-shaped answer counts as published; a failed or empty lookup sends the record, since a second publish fails loudly at the CNA while a wrong skip would silently send nothing.
143
+
144
+ Publishing a release with several CVEs from CI:
145
+
146
+ ```sh
147
+ # Every record carries cveMetadata.cveId. Review all, send nothing on any
148
+ # blocker, otherwise publish each in order; exit 1 if anything failed.
149
+ openjs-cna-experimental review records/*.json --no-preview
150
+ openjs-cna-experimental publish -f records/*.json --yes --skip-published --json > publish-result.json
151
+ ```
152
+
153
+ How a batch behaves, and what to expect on a retry:
154
+
155
+ - **Everything is sequential.** Records are reviewed, checked and published
156
+ one at a time, in the order given. There is no concurrency against the
157
+ CNA API.
158
+ - **Without `--skip-published` the batch assumes a green field.** No lookup
159
+ is made; every record is sent. An id that was already published fails at
160
+ the CNA with its `correlation_id` / `run_id`, the remaining records still
161
+ go out, and the exit code is 1. Nothing is ever published twice, MITRE
162
+ rejects the second publish.
163
+ - **With `--skip-published` the batch is a safe retry.** Each id is looked
164
+ up first with `get-cve`; ids that already have a published record are
165
+ reported as `skipped`, the rest are sent, and the exit code only reflects
166
+ what was sent. Use it whenever a run may repeat a previous one: a manual
167
+ re-run after a partial failure, or a pipeline that triggers more than
168
+ once for the same release. The lookups roughly double the run time.
169
+ - **Review blockers stop everything.** If any record has a blocker nothing
170
+ is sent, with or without `--skip-published`.
141
171
 
142
172
  ### Troubleshooting
143
173
 
@@ -182,9 +212,88 @@ await cna.publishCve(cveId, record.containers.cna)
182
212
 
183
213
  Error classes (`CnaAuthError`, `CnaTimeoutError`, `CnaOperationError`, `CnaReviewBlockedError`) are exported from the package root.
184
214
 
215
+ ### CVSS
216
+
217
+ Trackers such as HackerOne store only the CVSS vector, while MITRE requires
218
+ `baseScore` and `baseSeverity` on every metric block. The package ships a
219
+ dependency-free CVSS v3.0 / v3.1 base score calculator for that gap:
220
+
221
+ ```js
222
+ import { calculateBaseScore } from '@ulisesgascon/openjs-cna-tools/cvss'
223
+
224
+ calculateBaseScore('CVSS:3.0/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:H/A:N')
225
+ // { version: '3.0', vectorString: 'CVSS:3.0/…', baseScore: 6.5, baseSeverity: 'MEDIUM' }
226
+ ```
227
+
228
+ Only the base metric group is scored; temporal and environmental components
229
+ are kept in the vector string and ignored. `parseCvssVector`, `roundUp` and
230
+ `severityFromScore` are exported as well. CVSS 4.0 is not supported yet.
231
+
232
+ ### Batch review and publish
233
+
234
+ The CLI's multi-file mode is a thin wrapper over two library functions:
235
+
236
+ ```js
237
+ import { reviewRecords, publishRecords } from '@ulisesgascon/openjs-cna-tools/batch'
238
+
239
+ const entries = files.map((file) => ({ file, record: JSON.parse(readFileSync(file, 'utf8')) }))
240
+
241
+ // [{ file, cveId, ready, summary, issues }] — a record without
242
+ // cveMetadata.cveId gets a `cve-id-present` blocker.
243
+ const review = reviewRecords(entries, { strict: true })
244
+
245
+ // Reviews again, sends nothing if any record has a blocker, then publishes
246
+ // each one; `results` is [{ file, cveId, status, correlationId, runId, error }]
247
+ // with status published | skipped | failed.
248
+ const { blocked, results } = await publishRecords(entries, {
249
+ client: cna,
250
+ skipPublished: true,
251
+ onProgress: (event) => console.error(event.cveId, event.status)
252
+ })
253
+ ```
254
+
255
+ ### Record helpers
256
+
257
+ Converters only know their own source. The parts of a record that are about
258
+ MITRE's format rather than the source live in one place, so no consumer
259
+ hardcodes the OpenJS CNA org id, the generator string or the CVSS block
260
+ layout:
261
+
262
+ ```js
263
+ import { buildCvssMetric, buildProblemType, buildRecordEnvelope } from '@ulisesgascon/openjs-cna-tools/record'
264
+
265
+ const record = buildRecordEnvelope({
266
+ cveId: 'CVE-2026-0001',
267
+ cna: {
268
+ title: '…',
269
+ descriptions: [{ lang: 'en', value: '…' }],
270
+ affected: [/* … */],
271
+ references: [/* … */],
272
+ // id normalised to CWE-<number>; description is the name, or the id
273
+ problemTypes: [buildProblemType({ cweId: 400, name: 'Uncontrolled Resource Consumption' })],
274
+ // key (cvssV3_0 / cvssV3_1 / cvssV4_0) follows the vector prefix;
275
+ // a missing v3.x score is computed, a v4.0 score must be passed in
276
+ metrics: [buildCvssMetric('CVSS:3.0/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:H/A:N')]
277
+ }
278
+ })
279
+ // record.dataType / dataVersion / cveMetadata set, providerMetadata.orgId
280
+ // and x_generator.engine filled on the container unless already present.
281
+ ```
282
+
283
+ The GHSA converter is built on the same helpers.
284
+
285
+ The same calculator backs two behaviours:
286
+
287
+ - the reviewer's `cvss-score-matches-vector` rule (blocker) recomputes the
288
+ score of every v3.x block and rejects a `baseScore` that disagrees with its
289
+ vector, since MITRE accepts the record as sent and never re-scores it;
290
+ - the GHSA converter fills `baseScore` and `baseSeverity` from the vector when
291
+ the advisory carries a v3.1 vector without a score.
292
+
185
293
  ## Design
186
294
 
187
295
  - **Two runtime dependencies.** Built on Node.js 22+ built-ins (`fetch`, `node:test`, `node:util parseArgs`, `node:readline/promises`) with [`ajv`](https://github.com/ajv-validator/ajv) + [`ajv-formats`](https://github.com/ajv-validator/ajv-formats) — used only for validating containers against MITRE's official v5.2 schema before publish.
296
+ - **Scores vectors itself.** A bundled CVSS v3.0 / v3.1 calculator (`lib/cvss.js`) lets the reviewer verify that every `baseScore` matches its vector and lets converters fill a missing score. No external calculator, no network.
188
297
  - **Validates against MITRE's official schema.** The CVE Record Format schema and its companion files (CVSS v2/v3/v4, CNA/ADP/reference tags) ship bundled from the [CVE Project](https://github.com/CVEProject/cve-schema). `openjs-cna-experimental review` and the default behavior of `openjs-cna-experimental publish` catch anything that would fail at MITRE.
189
298
  - **Two-tier rule classification.** Issues carry a `tier`: `mitre-common` (MITRE's schema itself would reject) or `openjs-extra` (OpenJS Foundation editorial preference, stricter than MITRE). The `--tier mitre-common` flag on `review` filters the printed list without changing the exit code.
190
299
  - **Preview before commit.** `openjs-cna-experimental preview` and the default `openjs-cna-experimental review` render a full summary of the container (title, full description, every affected package, CVSS, CWE, credits, references) so an operator can see *exactly* what they're about to send. The output includes a tip to edit the JSON visually at <https://vulnogram.org/#source>.
@@ -1,11 +1,21 @@
1
- // openjs-cna-experimental publish <cve-id> [-f container.json] [--skip-review] [--no-strict]
1
+ // openjs-cna-experimental publish <cve-id> [-f record.json] [--skip-review] [--no-strict]
2
+ // openjs-cna-experimental publish -f a.json -f b.json … [--skip-published] [--json]
2
3
  //
3
- // Cross-checks the CLI <cve-id> against `container.cveMetadata.cveId` if
4
- // present (hard-error on mismatch); fills cveMetadata in when absent.
5
- // Then runs the reviewer (strict by default — warnings block too). On a
6
- // clean review (or `--skip-review`), prompts for confirmation and
7
- // publishes. Surfaces `correlation_id` + `run_id` after publish so the
8
- // operator can forward to a CNA coordinator if needed.
4
+ // Single record: cross-checks the CLI <cve-id> against `record.cveMetadata.cveId`
5
+ // if present (hard-error on mismatch); the CLI arg is the sole source of
6
+ // truth when the record has none. Runs the reviewer (strict by default —
7
+ // warnings block too). On a clean review (or `--skip-review`), prompts for
8
+ // confirmation and publishes. Surfaces `correlation_id` + `run_id` after
9
+ // publish so the operator can forward them to a CNA coordinator if needed.
10
+ //
11
+ // Batch (no <cve-id>, one or more -f): every record must carry
12
+ // cveMetadata.cveId. All records are reviewed first and nothing is sent
13
+ // unless all of them pass; then each one is published in order, a failure
14
+ // is recorded and the rest still go out. One confirmation covers the whole
15
+ // batch. --skip-published asks the CNA first and skips ids that already
16
+ // have a published record, so reruns after a partial failure stay green.
17
+ // --json prints one result per record on stdout. Exit code is 1 when the
18
+ // review blocked or any record failed.
9
19
 
10
20
  import {
11
21
  parseComponentArgs,
@@ -14,36 +24,44 @@ import {
14
24
  readFileOrStdin
15
25
  } from '../lib/component-utils.js'
16
26
  import { reviewRecord } from '../lib/review/index.js'
27
+ import { publishRecords } from '../lib/batch.js'
17
28
  import { CnaReviewBlockedError } from '../lib/errors.js'
18
29
 
19
30
  export const command = 'publish'
20
- export const describe = 'Publish a v5.2 CNA Container for a reserved CVE.'
31
+ export const describe = 'Publish one or more v5.2 CVE Records for reserved CVE ids.'
21
32
 
22
33
  export async function handler (argv) {
23
34
  const parsed = parseComponentArgs(argv, {
24
- file: { type: 'string', short: 'f' },
35
+ file: { type: 'string', short: 'f', multiple: true },
25
36
  'skip-review': { type: 'boolean' },
26
- 'no-strict': { type: 'boolean' }
37
+ 'no-strict': { type: 'boolean' },
38
+ 'skip-published': { type: 'boolean' }
27
39
  }, { allowPositionals: true })
28
40
  if (parsed.values.help) return printHelp()
29
41
 
30
42
  const cveId = parsed.positionals[0]
43
+ const files = parsed.values.file ?? []
44
+ const strict = !parsed.values['no-strict']
45
+ const skipReview = parsed.values['skip-review']
46
+
31
47
  if (!cveId) {
32
- process.stderr.write('✖ Missing CVE id. Usage: openjs-cna-experimental publish <CVE-...> -f container.json\n')
48
+ if (files.length === 0) {
49
+ process.stderr.write('✖ Missing CVE id. Usage: openjs-cna-experimental publish <CVE-...> -f record.json\n' +
50
+ ' or, for a batch: openjs-cna-experimental publish -f a.json -f b.json\n')
51
+ process.exit(64)
52
+ }
53
+ return publishMany(files, {
54
+ parsed, strict, skipReview, skipPublished: parsed.values['skip-published']
55
+ })
56
+ }
57
+ if (files.length > 1) {
58
+ process.stderr.write('✖ Several -f files need batch mode: drop the <cve-id> argument.\n')
33
59
  process.exit(64)
34
60
  }
35
61
 
36
62
  const cli = buildCli(parsed)
37
63
  const client = buildClient(parsed)
38
-
39
- const raw = await readFileOrStdin(parsed.values.file)
40
- let record
41
- try {
42
- record = JSON.parse(raw)
43
- } catch (e) {
44
- process.stderr.write(`✖ File is not valid JSON: ${e.message}\n`)
45
- process.exit(64)
46
- }
64
+ const record = await readRecord(files[0])
47
65
 
48
66
  // CVE id consistency check. The Record's cveMetadata.cveId must match
49
67
  // the CLI arg; mismatch is a hard error (operator was probably about to
@@ -63,8 +81,7 @@ export async function handler (argv) {
63
81
  // assignerOrgId and dateUpdated MITRE injects).
64
82
  const container = record.containers.cna
65
83
 
66
- if (!parsed.values['skip-review']) {
67
- const strict = !parsed.values['no-strict']
84
+ if (!skipReview) {
68
85
  const report = reviewRecord(record, { strict })
69
86
  // Strict mode promotes warnings to blockers inside reviewRecord, so
70
87
  // the `summary.blockers` count already reflects strictness. We just
@@ -73,10 +90,7 @@ export async function handler (argv) {
73
90
  cli.error(
74
91
  `Review blocked publish: ${report.summary.blockers} blocker(s)`
75
92
  )
76
- for (const issue of report.issues) {
77
- const tag = issue.severity === 'blocker' ? '✖' : issue.severity === 'warning' ? '⚠' : '·'
78
- cli.log(` ${tag} [${issue.rule}] ${issue.message}`)
79
- }
93
+ printIssues(cli, report.issues)
80
94
  cli.log('')
81
95
  cli.log(' Re-run with --skip-review to bypass.')
82
96
  throw new CnaReviewBlockedError(
@@ -95,7 +109,7 @@ export async function handler (argv) {
95
109
  // (and we got here without --skip-review). Honors --yes / OPENJS_CNA_YES.
96
110
  const confirmed = await cli.prompt(
97
111
  `Publish ${cveId}? This is irreversible.`,
98
- { defaultAnswer: !parsed.values['skip-review'] }
112
+ { defaultAnswer: !skipReview }
99
113
  )
100
114
  if (!confirmed) {
101
115
  cli.info('Aborted.')
@@ -115,18 +129,109 @@ export async function handler (argv) {
115
129
  if (result) process.stdout.write(JSON.stringify(result, null, 2) + '\n')
116
130
  }
117
131
 
132
+ async function publishMany (files, { parsed, strict, skipReview, skipPublished }) {
133
+ const cli = buildCli(parsed)
134
+ const entries = []
135
+ for (const file of files) {
136
+ entries.push({ file, record: await readRecord(file) })
137
+ }
138
+
139
+ // The reviewer runs inside publishRecords too; this early pass exists so
140
+ // the operator sees the ids before confirming, and so a missing id is
141
+ // caught before credentials are even resolved.
142
+ const ids = entries.map((entry) => entry.record?.cveMetadata?.cveId ?? `${entry.file} (no cveMetadata.cveId)`)
143
+ cli.info(`Publishing ${entries.length} record(s): ${ids.join(', ')}`)
144
+ const confirmed = await cli.prompt(
145
+ `Publish ${entries.length} record(s)? This is irreversible.`,
146
+ { defaultAnswer: !skipReview }
147
+ )
148
+ if (!confirmed) {
149
+ cli.info('Aborted.')
150
+ return
151
+ }
152
+
153
+ const client = buildClient(parsed)
154
+ const outcome = await publishRecords(entries, {
155
+ client,
156
+ strict,
157
+ skipReview,
158
+ skipPublished,
159
+ onProgress: (event) => {
160
+ const trace = traceOf(event)
161
+ if (event.status === 'published') cli.ok(`Published ${event.cveId}${trace}`)
162
+ else if (event.status === 'skipped') cli.info(`Skipped ${event.cveId}: ${event.reason}`)
163
+ else cli.error(`Failed ${event.cveId}: ${event.error}${trace}`)
164
+ }
165
+ })
166
+
167
+ if (outcome.blocked) {
168
+ const blocked = outcome.review.filter((item) => !item.ready)
169
+ cli.error(`Review blocked publish: ${blocked.length} of ${outcome.review.length} record(s) have blockers. Nothing was sent.`)
170
+ for (const item of blocked) {
171
+ cli.log(` ${item.file}${item.cveId ? ` (${item.cveId})` : ''}`)
172
+ printIssues(cli, item.issues.filter((issue) => issue.severity === 'blocker'), ' ')
173
+ }
174
+ if (parsed.values.json) process.stdout.write(JSON.stringify(outcome.review, null, 2) + '\n')
175
+ process.exit(1)
176
+ }
177
+
178
+ if (parsed.values.json) process.stdout.write(JSON.stringify(outcome.results, null, 2) + '\n')
179
+ const failed = outcome.results.filter((item) => item.status === 'failed')
180
+ const published = outcome.results.filter((item) => item.status === 'published').length
181
+ const skipped = outcome.results.filter((item) => item.status === 'skipped').length
182
+ cli.log('')
183
+ cli.info(`${published} published · ${skipped} skipped · ${failed.length} failed`)
184
+ if (failed.length) {
185
+ cli.info('Forward the correlation_id / run_id pairs above to your CNA coordinator.')
186
+ process.exit(1)
187
+ }
188
+ }
189
+
190
+ function traceOf (event) {
191
+ const parts = []
192
+ if (event.correlationId) parts.push(`correlation_id=${event.correlationId}`)
193
+ if (event.runId) parts.push(`run_id=${event.runId}`)
194
+ return parts.length ? ` (${parts.join(', ')})` : ''
195
+ }
196
+
197
+ function printIssues (cli, issues, indent = ' ') {
198
+ for (const issue of issues) {
199
+ const tag = issue.severity === 'blocker' ? '✖' : issue.severity === 'warning' ? '⚠' : '·'
200
+ cli.log(`${indent}${tag} [${issue.rule}] ${issue.message}`)
201
+ }
202
+ }
203
+
204
+ async function readRecord (file) {
205
+ const raw = await readFileOrStdin(file)
206
+ try {
207
+ return JSON.parse(raw)
208
+ } catch (e) {
209
+ process.stderr.write(`✖ ${file ?? 'stdin'} is not valid JSON: ${e.message}\n`)
210
+ process.exit(64)
211
+ }
212
+ }
213
+
118
214
  function printHelp () {
119
215
  process.stderr.write(
120
- `openjs-cna-experimental publish <cve-id> [-f container.json]
216
+ `openjs-cna-experimental publish <cve-id> [-f record.json]
217
+ openjs-cna-experimental publish -f a.json -f b.json … [--skip-published]
121
218
 
122
- Publish a v5.2 CNA Container against an already-reserved CVE id. Default
219
+ Publish v5.2 CVE Records against already-reserved CVE ids. Default
123
220
  behavior runs the reviewer first (strict — warnings block too) and aborts
124
221
  on any issue.
125
222
 
223
+ Without <cve-id> every -f record is published as a batch: each record must
224
+ carry cveMetadata.cveId, all records are reviewed before anything is sent,
225
+ then each is published in order (a failure does not stop the others). One
226
+ confirmation covers the batch. Exit code is 1 if the review blocked or any
227
+ record failed.
228
+
126
229
  Options:
127
- -f, --file <path> read the container from a file (default: stdin)
230
+ -f, --file <path> read a record from a file (default: stdin); repeatable
128
231
  --skip-review do not run the reviewer
129
232
  --no-strict let warnings pass through (only true blockers abort)
233
+ --skip-published batch only: skip ids the CNA already has published
234
+ --json batch only: print one result per record on stdout
130
235
  [global flags]
131
236
  `
132
237
  )
@@ -1,17 +1,24 @@
1
- // openjs-cna-experimental review <container.json> [--no-strict] [--no-preview] [--tier <name>] [--json]
1
+ // openjs-cna-experimental review <record.json> [<record.json>…] [--no-strict] [--no-preview] [--tier <name>] [--json]
2
2
  //
3
- // Runs the offline reviewer against a CNA Container. Strict by default
4
- // (warnings block); --no-strict surfaces them as warnings. Renders the
5
- // preview before the issues list unless --no-preview. --tier filters the
6
- // printed issues to a single severity tier (mitre-common / openjs-extra)
3
+ // Runs the offline reviewer against one or more CVE Records. Strict by
4
+ // default (warnings block); --no-strict surfaces them as warnings. Renders
5
+ // the preview before the issues list unless --no-preview. --tier filters
6
+ // the printed issues to a single severity tier (mitre-common / openjs-extra)
7
7
  // without changing the exit code, which always reflects the full review.
8
+ //
9
+ // With several files every record is reviewed (no early stop), each block
10
+ // is headed by the file and CVE id, --json emits an array, and the exit
11
+ // code is 1 when any record has a blocker. A record without
12
+ // cveMetadata.cveId is a blocker in batch mode, since batch publish needs
13
+ // the id from the record.
8
14
 
9
15
  import { parseComponentArgs, buildCli, readFileOrStdin } from '../lib/component-utils.js'
10
16
  import { reviewRecord } from '../lib/review/index.js'
17
+ import { reviewRecords } from '../lib/batch.js'
11
18
  import { renderPreview } from '../lib/preview.js'
12
19
 
13
20
  export const command = 'review'
14
- export const describe = 'Review a v5.2 CNA Container against best-practice rules.'
21
+ export const describe = 'Review one or more v5.2 CVE Records against best-practice rules.'
15
22
 
16
23
  const VALID_TIERS = new Set(['mitre-common', 'openjs-extra'])
17
24
 
@@ -24,7 +31,7 @@ export async function handler (argv) {
24
31
  if (parsed.values.help) return printHelp()
25
32
 
26
33
  const cli = buildCli(parsed)
27
- const file = parsed.positionals[0]
34
+ const files = parsed.positionals
28
35
 
29
36
  // Validate the --tier filter early so a typo doesn't silently match
30
37
  // nothing and look like a clean review.
@@ -36,16 +43,15 @@ export async function handler (argv) {
36
43
  process.exit(64)
37
44
  }
38
45
 
39
- const raw = await readFileOrStdin(file)
40
- let container
41
- try {
42
- container = JSON.parse(raw)
43
- } catch (e) {
44
- process.stderr.write(`✖ Container is not valid JSON: ${e.message}\n`)
45
- process.exit(64)
46
+ const strict = !parsed.values['no-strict']
47
+ const showPreview = !parsed.values['no-preview']
48
+
49
+ if (files.length > 1) {
50
+ return reviewMany(files, { cli, strict, showPreview, tierFilter, json: parsed.values.json })
46
51
  }
47
52
 
48
- const report = reviewRecord(container, { strict: !parsed.values['no-strict'] })
53
+ const container = await readRecord(files[0])
54
+ const report = reviewRecord(container, { strict })
49
55
 
50
56
  if (parsed.values.json) {
51
57
  process.stdout.write(JSON.stringify(report, null, 2) + '\n')
@@ -53,7 +59,7 @@ export async function handler (argv) {
53
59
  // Preview goes first so the operator sees what they're about to ship
54
60
  // alongside what the reviewer caught. --no-preview drops it for
55
61
  // CI / pipeline use.
56
- if (!parsed.values['no-preview']) renderPreview(container, cli)
62
+ if (showPreview) renderPreview(container, cli)
57
63
  printHumanReport(cli, report, { tierFilter })
58
64
  }
59
65
 
@@ -61,6 +67,44 @@ export async function handler (argv) {
61
67
  if (report.summary.blockers > 0) process.exit(1)
62
68
  }
63
69
 
70
+ async function reviewMany (files, { cli, strict, showPreview, tierFilter, json }) {
71
+ const entries = []
72
+ for (const file of files) {
73
+ entries.push({ file, record: await readRecord(file) })
74
+ }
75
+ const review = reviewRecords(entries, { strict })
76
+
77
+ if (json) {
78
+ process.stdout.write(JSON.stringify(review, null, 2) + '\n')
79
+ } else {
80
+ review.forEach((item, index) => {
81
+ cli.log('')
82
+ cli.log(`── ${item.file}${item.cveId ? ` (${item.cveId})` : ''}`)
83
+ if (showPreview) renderPreview(entries[index].record, cli)
84
+ printHumanReport(cli, item, { tierFilter })
85
+ })
86
+ const blocked = review.filter((item) => !item.ready).length
87
+ cli.log('')
88
+ if (blocked) {
89
+ cli.error(`${blocked} of ${review.length} record(s) blocked`)
90
+ } else {
91
+ cli.ok(`${review.length} record(s) ready`)
92
+ }
93
+ }
94
+
95
+ if (review.some((item) => !item.ready)) process.exit(1)
96
+ }
97
+
98
+ async function readRecord (file) {
99
+ const raw = await readFileOrStdin(file)
100
+ try {
101
+ return JSON.parse(raw)
102
+ } catch (e) {
103
+ process.stderr.write(`✖ ${file ?? 'stdin'} is not valid JSON: ${e.message}\n`)
104
+ process.exit(64)
105
+ }
106
+ }
107
+
64
108
  function printHumanReport (cli, report, { tierFilter } = {}) {
65
109
  const { summary, issues } = report
66
110
  const ratingLabel = {
@@ -105,16 +149,19 @@ function printHumanReport (cli, report, { tierFilter } = {}) {
105
149
 
106
150
  function printHelp () {
107
151
  process.stderr.write(
108
- `openjs-cna-experimental review <container.json>
152
+ `openjs-cna-experimental review <record.json> [<record.json>…]
109
153
 
110
- Run the offline reviewer against a CNA Container. Strict by default —
111
- warnings are treated as blockers because publishing to MITRE is
154
+ Run the offline reviewer against one or more CVE Records. Strict by
155
+ default — warnings are treated as blockers because publishing to MITRE is
112
156
  irreversible. Pass --no-strict to surface warnings as warnings.
113
157
 
158
+ With several files every record is reviewed, --json emits an array with
159
+ one entry per file, and the exit code is 1 when any record has a blocker.
160
+
114
161
  Options:
115
162
  --no-strict surface warnings as warnings (default treats them as blockers)
116
163
  --json emit a structured JSON report instead of the human view
117
- --no-preview skip the container preview (for CI / pipelines)
164
+ --no-preview skip the record preview (for CI / pipelines)
118
165
  --tier <tier> filter the printed issue list to a single tier
119
166
  (mitre-common | openjs-extra). Summary counts still
120
167
  reflect the full review; exit code is unaffected.
package/lib/batch.js ADDED
@@ -0,0 +1,141 @@
1
+ // Review and publish several CVE Records in one go.
2
+ //
3
+ // This is the "all or none" flow a release pipeline wants: every record is
4
+ // reviewed first and nothing is sent unless all of them pass. Publication
5
+ // itself is best effort per record, since MITRE has no batch operation and
6
+ // the records are independent; a failed id is reported and the rest still
7
+ // go out. `--skip-published` style reruns are supported by asking the CNA
8
+ // whether an id already has a published container before sending it.
9
+ //
10
+ // Both functions take `entries`: an array of `{ record, file? }` where
11
+ // `record` is a full v5.2 CVE Record (`containers.cna` plus `cveMetadata`)
12
+ // and `file` is only used to label output. The CLI `review` and `publish`
13
+ // commands are thin wrappers over them.
14
+
15
+ import { reviewRecord } from './review/index.js'
16
+ import { CnaOperationError } from './errors.js'
17
+
18
+ function labelOf (entry) {
19
+ return entry.record?.cveMetadata?.cveId ?? entry.file ?? '<record>'
20
+ }
21
+
22
+ /**
23
+ * Review every entry. Never stops early, so the caller sees all problems at
24
+ * once. A record without `cveMetadata.cveId` gets an extra blocker: in a
25
+ * batch there is no CLI argument to fall back on for the id.
26
+ *
27
+ * Returns `[{ file, cveId, ready, summary, issues }]` in input order.
28
+ */
29
+ export function reviewRecords (entries, { strict = true } = {}) {
30
+ return entries.map((entry) => {
31
+ const report = reviewRecord(entry.record, { strict })
32
+ const cveId = entry.record?.cveMetadata?.cveId
33
+ const issues = [...report.issues]
34
+ const summary = { ...report.summary }
35
+ if (!cveId) {
36
+ issues.unshift({
37
+ rule: 'cve-id-present',
38
+ tier: 'mitre-common',
39
+ severity: 'blocker',
40
+ path: 'cveMetadata.cveId',
41
+ message: 'Record carries no cveMetadata.cveId; batch publish needs one per record'
42
+ })
43
+ summary.blockers += 1
44
+ summary.rating = 'blocked'
45
+ }
46
+ return {
47
+ file: entry.file,
48
+ cveId,
49
+ ready: summary.blockers === 0,
50
+ summary,
51
+ issues
52
+ }
53
+ })
54
+ }
55
+
56
+ // `getCve` resolves with the container when MITRE has a published record.
57
+ // For a RESERVED id there is nothing to fetch and the CNA either fails the
58
+ // run or completes it with an empty result; neither is pinned down, so
59
+ // only positive evidence counts: an operational failure, or a result that
60
+ // does not look like a record, means "not published" and the id is sent.
61
+ // Erring towards publishing is the safe side, since the CNA rejects a
62
+ // second publish loudly while a wrong skip would exit 0 having sent
63
+ // nothing. Auth and timeout errors propagate so a broken setup does not
64
+ // look like a clean slate.
65
+ async function isPublished (client, cveId) {
66
+ let result
67
+ try {
68
+ result = await client.getCve(cveId)
69
+ } catch (error) {
70
+ if (error instanceof CnaOperationError) return false
71
+ throw error
72
+ }
73
+ const cna = result?.containers?.cna ?? result
74
+ return result?.cveMetadata?.state === 'PUBLISHED' ||
75
+ Array.isArray(cna?.descriptions) ||
76
+ Array.isArray(cna?.affected)
77
+ }
78
+
79
+ /**
80
+ * Review every entry, then publish each one through `client.publishCve`.
81
+ *
82
+ * Options:
83
+ * client — a CnaClient (required unless every record is skipped)
84
+ * strict — reviewer strictness (default true, warnings block)
85
+ * skipReview — publish without reviewing (discouraged)
86
+ * skipPublished — ask the CNA first and skip ids already published
87
+ * onProgress — called with `{ phase, cveId, file, status, ... }` as
88
+ * each step completes, for CLI progress lines
89
+ *
90
+ * Returns `{ blocked, review, results }`. When `blocked` is true the review
91
+ * found blockers, `results` is empty and nothing was sent. Otherwise
92
+ * `results` has one `{ file, cveId, status, ... }` per entry, with status
93
+ * `published`, `skipped` or `failed`, plus `correlationId` / `runId` when
94
+ * the CNA returned them and `error` for failures.
95
+ */
96
+ export async function publishRecords (entries, {
97
+ client,
98
+ strict = true,
99
+ skipReview = false,
100
+ skipPublished = false,
101
+ onProgress = () => {}
102
+ } = {}) {
103
+ const review = skipReview ? [] : reviewRecords(entries, { strict })
104
+ if (review.some((item) => !item.ready)) {
105
+ return { blocked: true, review, results: [] }
106
+ }
107
+ if (skipReview && entries.some((entry) => !entry.record?.cveMetadata?.cveId)) {
108
+ throw new Error('Every record needs cveMetadata.cveId to be published in a batch')
109
+ }
110
+
111
+ const results = []
112
+ for (const entry of entries) {
113
+ const cveId = labelOf(entry)
114
+ const base = { file: entry.file, cveId }
115
+ let result
116
+ if (skipPublished && await isPublished(client, cveId)) {
117
+ result = { ...base, status: 'skipped', reason: 'already published' }
118
+ } else {
119
+ try {
120
+ const response = await client.publishCve(cveId, entry.record.containers.cna)
121
+ result = {
122
+ ...base,
123
+ status: 'published',
124
+ correlationId: response.correlationId,
125
+ runId: response.runId
126
+ }
127
+ } catch (error) {
128
+ result = {
129
+ ...base,
130
+ status: 'failed',
131
+ error: error.message,
132
+ correlationId: error.correlationId,
133
+ runId: error.runId
134
+ }
135
+ }
136
+ }
137
+ results.push(result)
138
+ onProgress({ phase: 'publish', ...result })
139
+ }
140
+ return { blocked: false, review, results }
141
+ }
@@ -24,6 +24,7 @@
24
24
  // shape-translation logic is testable without touching the network.
25
25
 
26
26
  import { USER_AGENT } from '../client.js'
27
+ import { buildCvssMetric, buildProblemType, buildRecordEnvelope } from '../record.js'
27
28
  import { OPENJS_CNA_ORG_ID } from '../review/schema.js'
28
29
  import { parseVersionRange } from './version-range.js'
29
30
 
@@ -32,11 +33,6 @@ import { parseVersionRange } from './version-range.js'
32
33
  // the converter don't have to reach into the transport module.
33
34
  export const ENGINE_ID = USER_AGENT
34
35
 
35
- // MITRE schema version we emit on every Record. Matches what every CVE
36
- // the OpenJS CNA has published carries (5.2 — Vulnogram templates use
37
- // 5.1 but the on-the-wire format is 5.2).
38
- const DATA_VERSION = '5.2'
39
-
40
36
  // GHSA credit `type` values use underscores. MITRE's CVE Record Format
41
37
  // schema (cnaPublishedContainer → credits → type) requires the
42
38
  // space-separated forms. Map each known GHSA role to its MITRE spelling.
@@ -234,26 +230,9 @@ function buildAffected (advisory) {
234
230
  function buildProblemTypes (advisory) {
235
231
  const cwes = advisory.cwes ?? []
236
232
  if (cwes.length === 0) return undefined
237
- return [{
238
- descriptions: cwes.map((cwe) => ({
239
- lang: 'en',
240
- cweId: cwe.cwe_id,
241
- type: 'CWE',
242
- description: cwe.name ?? cwe.cwe_id
243
- }))
244
- }]
245
- }
246
-
247
- // CVSS v3.1 and v4.0 share the same severity bands. The MITRE-bundled
248
- // CVSS schemas require `baseSeverity` whenever a baseScore is present,
249
- // so we derive it here when the GHSA payload only carries the score.
250
- function severityForScore (score) {
251
- if (typeof score !== 'number') return null
252
- if (score === 0) return 'NONE'
253
- if (score < 4.0) return 'LOW'
254
- if (score < 7.0) return 'MEDIUM'
255
- if (score < 9.0) return 'HIGH'
256
- return 'CRITICAL'
233
+ // GHSA lists every CWE of the advisory; MITRE groups them as the
234
+ // descriptions of one problemType entry.
235
+ return [buildProblemType(cwes.map((cwe) => ({ cweId: cwe.cwe_id, name: cwe.name })))]
257
236
  }
258
237
 
259
238
  function buildMetrics (advisory) {
@@ -269,7 +248,9 @@ function buildMetrics (advisory) {
269
248
  // Emit a `cvssV3_1` block whenever a v3.1 vector exists, and a
270
249
  // `cvssV4_0` block whenever a v4 vector exists. Both can appear; we
271
250
  // emit each in its own metric entry so the MITRE schema (which gates
272
- // additional properties on cvssVx blocks) is happy.
251
+ // additional properties on cvssVx blocks) is happy. GHSA only issues
252
+ // 3.1 and 4.0 vectors; `buildCvssMetric` computes a missing v3.1 score
253
+ // and passes through a vector it cannot parse for the reviewer to flag.
273
254
  const metrics = []
274
255
  const v3Vector = advisory.cvss?.vector_string ??
275
256
  advisory.cvss_v3?.vector_string ??
@@ -278,32 +259,14 @@ function buildMetrics (advisory) {
278
259
  advisory.cvss_v3?.score ??
279
260
  advisory.cvss_severities?.cvss_v3?.score
280
261
  if (v3Vector) {
281
- const v3Severity = severityForScore(v3Score)
282
- metrics.push({
283
- format: 'CVSS',
284
- cvssV3_1: {
285
- version: '3.1',
286
- vectorString: v3Vector,
287
- ...(typeof v3Score === 'number' ? { baseScore: v3Score } : {}),
288
- ...(v3Severity ? { baseSeverity: v3Severity } : {})
289
- }
290
- })
262
+ metrics.push(buildCvssMetric(v3Vector, { baseScore: v3Score }))
291
263
  }
292
264
  const v4Vector = advisory.cvss_v4?.vector_string ??
293
265
  advisory.cvss_severities?.cvss_v4?.vector_string
294
266
  const v4Score = advisory.cvss_v4?.score ??
295
267
  advisory.cvss_severities?.cvss_v4?.score
296
268
  if (v4Vector) {
297
- const v4Severity = severityForScore(v4Score)
298
- metrics.push({
299
- format: 'CVSS',
300
- cvssV4_0: {
301
- version: '4.0',
302
- vectorString: v4Vector,
303
- ...(typeof v4Score === 'number' ? { baseScore: v4Score } : {}),
304
- ...(v4Severity ? { baseSeverity: v4Severity } : {})
305
- }
306
- })
269
+ metrics.push(buildCvssMetric(v4Vector, { baseScore: v4Score }))
307
270
  }
308
271
  return metrics.length > 0 ? metrics : undefined
309
272
  }
@@ -458,28 +421,22 @@ export function mapAdvisoryToRecord (advisory, {
458
421
  }
459
422
  const resolvedCveId = cveId ?? advisoryCveId
460
423
 
461
- const cna = {
462
- providerMetadata: { orgId: assignerOrgId },
463
- title: advisory.summary,
464
- descriptions: buildDescriptions(advisory),
465
- affected: buildAffected(advisory),
466
- problemTypes: buildProblemTypes(advisory),
467
- metrics: buildMetrics(advisory),
468
- credits: buildCredits(advisory),
469
- references: buildReferences(advisory),
470
- // x_ prefixes are MITRE's extension namespace. x_generator is the
471
- // canonical place to surface "this container was produced by …".
472
- x_generator: { engine: engineId }
473
- }
474
-
475
- return {
476
- dataType: 'CVE_RECORD',
477
- dataVersion: DATA_VERSION,
478
- ...(resolvedCveId
479
- ? { cveMetadata: { cveId: resolvedCveId, assignerOrgId, state: 'PUBLISHED' } }
480
- : {}),
481
- containers: { cna }
482
- }
424
+ // The envelope, providerMetadata and x_generator come from
425
+ // buildRecordEnvelope so every converter emits them the same way.
426
+ return buildRecordEnvelope({
427
+ cveId: resolvedCveId,
428
+ assignerOrgId,
429
+ engineId,
430
+ cna: {
431
+ title: advisory.summary,
432
+ descriptions: buildDescriptions(advisory),
433
+ affected: buildAffected(advisory),
434
+ problemTypes: buildProblemTypes(advisory),
435
+ metrics: buildMetrics(advisory),
436
+ credits: buildCredits(advisory),
437
+ references: buildReferences(advisory)
438
+ }
439
+ })
483
440
  }
484
441
 
485
442
  // Fetch + map convenience. Returns the full CVE Record (Vulnogram-
package/lib/cvss.js ADDED
@@ -0,0 +1,113 @@
1
+ // CVSS v3.0 / v3.1 base score calculator.
2
+ //
3
+ // Vulnerability trackers often store only the vector string, while MITRE's
4
+ // record format requires `baseScore` and `baseSeverity` on every CVSS
5
+ // block. This module derives both from the vector so converters never have
6
+ // to guess or call out to a third-party calculator, and so the reviewer can
7
+ // verify that a submitted score actually matches its vector.
8
+ //
9
+ // Only the base metric group is scored. Temporal and environmental metrics
10
+ // that may follow in the vector are preserved in the string but ignored.
11
+
12
+ const SUPPORTED_VERSIONS = new Set(['3.0', '3.1'])
13
+
14
+ const WEIGHTS = {
15
+ AV: { N: 0.85, A: 0.62, L: 0.55, P: 0.2 },
16
+ AC: { L: 0.77, H: 0.44 },
17
+ // Privileges Required depends on Scope: [unchanged, changed].
18
+ PR: { N: [0.85, 0.85], L: [0.62, 0.68], H: [0.27, 0.5] },
19
+ UI: { N: 0.85, R: 0.62 },
20
+ S: { U: 'U', C: 'C' },
21
+ C: { H: 0.56, L: 0.22, N: 0 },
22
+ I: { H: 0.56, L: 0.22, N: 0 },
23
+ A: { H: 0.56, L: 0.22, N: 0 }
24
+ }
25
+
26
+ const BASE_METRICS = ['AV', 'AC', 'PR', 'UI', 'S', 'C', 'I', 'A']
27
+
28
+ /**
29
+ * Parse a CVSS v3.x vector string into its version and metrics.
30
+ * Throws on unsupported versions and on missing or invalid base metrics.
31
+ */
32
+ export function parseCvssVector (vectorString) {
33
+ const text = String(vectorString || '').trim()
34
+ const match = text.match(/^CVSS:(3\.[01])\/(.+)$/)
35
+ if (!match) {
36
+ throw new Error(`Unsupported CVSS vector: ${JSON.stringify(vectorString)}`)
37
+ }
38
+ const [, version, body] = match
39
+ if (!SUPPORTED_VERSIONS.has(version)) {
40
+ throw new Error(`Unsupported CVSS version ${version}`)
41
+ }
42
+ const metrics = {}
43
+ for (const part of body.split('/')) {
44
+ const [key, value] = part.split(':')
45
+ if (!key || value === undefined) {
46
+ throw new Error(`Malformed CVSS component ${JSON.stringify(part)}`)
47
+ }
48
+ metrics[key] = value
49
+ }
50
+ for (const key of BASE_METRICS) {
51
+ if (!metrics[key] || !Object.hasOwn(WEIGHTS[key], metrics[key])) {
52
+ throw new Error(`CVSS vector is missing or has an invalid ${key} metric`)
53
+ }
54
+ }
55
+ return { version, vectorString: text, metrics }
56
+ }
57
+
58
+ /**
59
+ * CVSS v3.1 "Roundup": smallest number, to one decimal place, that is equal
60
+ * to or higher than the input. v3.1 specified an integer-based algorithm to
61
+ * avoid floating-point surprises; it yields the same results as the v3.0
62
+ * definition for every realistic input, so it is used for both versions.
63
+ */
64
+ export function roundUp (value) {
65
+ const scaled = Math.round(value * 100000)
66
+ if (scaled % 10000 === 0) return scaled / 100000
67
+ return (Math.floor(scaled / 10000) + 1) / 10
68
+ }
69
+
70
+ /** Qualitative severity band for a base score (shared by v3.0, v3.1 and v4.0). */
71
+ export function severityFromScore (score) {
72
+ if (score === 0) return 'NONE'
73
+ if (score < 4) return 'LOW'
74
+ if (score < 7) return 'MEDIUM'
75
+ if (score < 9) return 'HIGH'
76
+ return 'CRITICAL'
77
+ }
78
+
79
+ /**
80
+ * Compute the base score and severity of a CVSS v3.0 / v3.1 vector.
81
+ * Returns `{ version, vectorString, baseScore, baseSeverity }`.
82
+ */
83
+ export function calculateBaseScore (vectorString) {
84
+ const { version, metrics, vectorString: text } = parseCvssVector(vectorString)
85
+ const scopeChanged = metrics.S === 'C'
86
+ const iss = 1 - (
87
+ (1 - WEIGHTS.C[metrics.C]) *
88
+ (1 - WEIGHTS.I[metrics.I]) *
89
+ (1 - WEIGHTS.A[metrics.A])
90
+ )
91
+ const impact = scopeChanged
92
+ ? 7.52 * (iss - 0.029) - 3.25 * ((iss - 0.02) ** 15)
93
+ : 6.42 * iss
94
+ const exploitability = 8.22 *
95
+ WEIGHTS.AV[metrics.AV] *
96
+ WEIGHTS.AC[metrics.AC] *
97
+ WEIGHTS.PR[metrics.PR][scopeChanged ? 1 : 0] *
98
+ WEIGHTS.UI[metrics.UI]
99
+
100
+ let baseScore = 0
101
+ if (impact > 0) {
102
+ baseScore = scopeChanged
103
+ ? roundUp(Math.min(1.08 * (impact + exploitability), 10))
104
+ : roundUp(Math.min(impact + exploitability, 10))
105
+ }
106
+
107
+ return {
108
+ version,
109
+ vectorString: text,
110
+ baseScore,
111
+ baseSeverity: severityFromScore(baseScore)
112
+ }
113
+ }
package/lib/preview.js CHANGED
@@ -20,22 +20,23 @@ function getDescription (c) {
20
20
  ''
21
21
  }
22
22
 
23
+ // Metric blocks the record format accepts, in display preference order.
24
+ const CVSS_BLOCKS = [
25
+ { key: 'cvssV3_1', version: '3.1' },
26
+ { key: 'cvssV4_0', version: '4.0' },
27
+ { key: 'cvssV3_0', version: '3.0' }
28
+ ]
29
+
23
30
  function getCvss (c) {
24
31
  for (const m of c?.metrics ?? []) {
25
- if (m?.cvssV3_1?.vectorString) {
26
- return {
27
- version: '3.1',
28
- score: m.cvssV3_1.baseScore,
29
- severity: m.cvssV3_1.baseSeverity,
30
- vector: m.cvssV3_1.vectorString
31
- }
32
- }
33
- if (m?.cvssV4_0?.vectorString) {
34
- return {
35
- version: '4.0',
36
- score: m.cvssV4_0.baseScore,
37
- severity: m.cvssV4_0.baseSeverity,
38
- vector: m.cvssV4_0.vectorString
32
+ for (const { key, version } of CVSS_BLOCKS) {
33
+ if (m?.[key]?.vectorString) {
34
+ return {
35
+ version,
36
+ score: m[key].baseScore,
37
+ severity: m[key].baseSeverity,
38
+ vector: m[key].vectorString
39
+ }
39
40
  }
40
41
  }
41
42
  }
package/lib/prompt.js CHANGED
@@ -43,15 +43,20 @@ function summarizeAffected (cna) {
43
43
  })
44
44
  }
45
45
 
46
+ // Metric blocks the record format accepts, in display preference order.
47
+ const CVSS_BLOCKS = [
48
+ { key: 'cvssV3_1', label: 'CVSS 3.1' },
49
+ { key: 'cvssV4_0', label: 'CVSS 4.0' },
50
+ { key: 'cvssV3_0', label: 'CVSS 3.0' }
51
+ ]
52
+
46
53
  function summarizeCvss (cna) {
47
54
  for (const m of cna?.metrics ?? []) {
48
- if (m?.cvssV3_1?.vectorString) {
49
- const { vectorString, baseScore, baseSeverity } = m.cvssV3_1
50
- return { label: 'CVSS 3.1', body: `${baseScore ?? '?'} ${baseSeverity ?? ''} (${vectorString})`.replace(/\s+/g, ' ').trim() }
51
- }
52
- if (m?.cvssV4_0?.vectorString) {
53
- const { vectorString, baseScore, baseSeverity } = m.cvssV4_0
54
- return { label: 'CVSS 4.0', body: `${baseScore ?? '?'} ${baseSeverity ?? ''} (${vectorString})`.replace(/\s+/g, ' ').trim() }
55
+ for (const { key, label } of CVSS_BLOCKS) {
56
+ if (m?.[key]?.vectorString) {
57
+ const { vectorString, baseScore, baseSeverity } = m[key]
58
+ return { label, body: `${baseScore ?? '?'} ${baseSeverity ?? ''} (${vectorString})`.replace(/\s+/g, ' ').trim() }
59
+ }
55
60
  }
56
61
  }
57
62
  return null
package/lib/record.js ADDED
@@ -0,0 +1,123 @@
1
+ // Helpers for the parts of a v5.2 CVE Record that are about the record
2
+ // format rather than about where the data came from: the CVSS metric block
3
+ // and the record envelope. Converters (GHSA here, release trackers in
4
+ // downstream projects) build the container fields they know about and use
5
+ // these so the format-level details are written once.
6
+
7
+ import { USER_AGENT } from './client.js'
8
+ import { calculateBaseScore, severityFromScore } from './cvss.js'
9
+ import { OPENJS_CNA_ORG_ID } from './review/schema.js'
10
+
11
+ // MITRE schema version we emit on every Record. Matches what every CVE
12
+ // published by the OpenJS CNA carries today.
13
+ export const DATA_VERSION = '5.2'
14
+
15
+ // Metric block key per CVSS version prefix. v2 is not emitted by anything
16
+ // the foundation publishes, so it is left out on purpose.
17
+ const METRIC_KEYS = new Map([
18
+ ['3.0', 'cvssV3_0'],
19
+ ['3.1', 'cvssV3_1'],
20
+ ['4.0', 'cvssV4_0']
21
+ ])
22
+
23
+ /**
24
+ * Build one `metrics[]` entry from a CVSS vector string.
25
+ *
26
+ * The block key (`cvssV3_0`, `cvssV3_1`, `cvssV4_0`) follows the vector's
27
+ * `CVSS:<version>/` prefix, which is what MITRE validates each block
28
+ * against. `baseScore` is taken from `options.baseScore` when given and
29
+ * computed from the vector otherwise (v3.x only; v4.0 has no bundled
30
+ * calculator). `baseSeverity` is always derived from the final score.
31
+ *
32
+ * A vector with an unknown prefix or one the calculator rejects is passed
33
+ * through under the closest key with no score, so the reviewer can report
34
+ * it instead of this helper guessing.
35
+ */
36
+ export function buildCvssMetric (vectorString, { baseScore } = {}) {
37
+ const vector = String(vectorString ?? '').trim()
38
+ const version = vector.match(/^CVSS:(\d\.\d)\//)?.[1]
39
+ const key = METRIC_KEYS.get(version) ?? 'cvssV3_1'
40
+
41
+ let score = typeof baseScore === 'number' ? baseScore : undefined
42
+ if (score === undefined && (version === '3.0' || version === '3.1')) {
43
+ try {
44
+ score = calculateBaseScore(vector).baseScore
45
+ } catch {}
46
+ }
47
+
48
+ return {
49
+ format: 'CVSS',
50
+ [key]: {
51
+ version: version ?? '3.1',
52
+ vectorString: vector,
53
+ ...(score !== undefined ? { baseScore: score, baseSeverity: severityFromScore(score) } : {})
54
+ }
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Build one `problemTypes[]` entry from one or more CWE references.
60
+ *
61
+ * Accepts a single `{ cweId, name? }` or an array of them. `cweId` may be
62
+ * given as `400`, `CWE-400` or `cwe-400` and is normalised to the
63
+ * `CWE-<number>` form MITRE's pattern requires; anything else throws so
64
+ * the mistake surfaces at build time. `description` is required by MITRE,
65
+ * so it is the CWE name when known and the id otherwise.
66
+ */
67
+ export function buildProblemType (cwes) {
68
+ const list = Array.isArray(cwes) ? cwes : [cwes]
69
+ if (list.length === 0) {
70
+ throw new TypeError('buildProblemType: at least one CWE reference is required')
71
+ }
72
+ return {
73
+ descriptions: list.map(({ cweId, name } = {}) => {
74
+ const id = String(cweId ?? '').trim().match(/^(?:CWE-)?([1-9]\d*)$/i)?.[1]
75
+ if (!id) {
76
+ throw new TypeError(`buildProblemType: '${cweId}' is not a CWE id (expected CWE-<number>)`)
77
+ }
78
+ const text = String(name ?? '').trim()
79
+ return {
80
+ lang: 'en',
81
+ type: 'CWE',
82
+ cweId: `CWE-${id}`,
83
+ description: text || `CWE-${id}`
84
+ }
85
+ })
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Wrap a CNA container in the v5.2 Record envelope.
91
+ *
92
+ * Sets `providerMetadata.orgId` and `x_generator.engine` on the container
93
+ * when the caller has not, so no consumer needs to know the OpenJS CNA org
94
+ * id or the generator string. `cveMetadata` is emitted only when `cveId` is
95
+ * given; without it the publisher's CLI argument is the sole source of
96
+ * truth for the id.
97
+ */
98
+ export function buildRecordEnvelope ({
99
+ cna,
100
+ cveId,
101
+ assignerOrgId = OPENJS_CNA_ORG_ID,
102
+ engineId = USER_AGENT,
103
+ dataVersion = DATA_VERSION
104
+ } = {}) {
105
+ if (!cna || typeof cna !== 'object') {
106
+ throw new TypeError('buildRecordEnvelope: `cna` must be the CNA container object')
107
+ }
108
+ const container = {
109
+ providerMetadata: { orgId: assignerOrgId },
110
+ ...cna,
111
+ // x_ prefixes are MITRE's extension namespace. x_generator is the
112
+ // canonical place to surface "this container was produced by …".
113
+ x_generator: cna.x_generator ?? { engine: engineId }
114
+ }
115
+ return {
116
+ dataType: 'CVE_RECORD',
117
+ dataVersion,
118
+ ...(cveId
119
+ ? { cveMetadata: { cveId, assignerOrgId, state: 'PUBLISHED' } }
120
+ : {}),
121
+ containers: { cna: container }
122
+ }
123
+ }
@@ -16,6 +16,8 @@
16
16
  // See docs/mitre-schema-summary.md for the constraint inventory the tiers
17
17
  // are derived from.
18
18
 
19
+ import { calculateBaseScore } from '../cvss.js'
20
+
19
21
  const isString = (v) => typeof v === 'string'
20
22
  const isNonEmptyArray = (v) => Array.isArray(v) && v.length > 0
21
23
 
@@ -26,13 +28,30 @@ function getDescription (c) {
26
28
  c?.descriptions?.[0]
27
29
  }
28
30
 
29
- function getCvssVector (c) {
30
- return c?.metrics?.find(m => m?.cvssV3_1?.vectorString)?.cvssV3_1?.vectorString
31
+ // CVSS v3 blocks the record format accepts, newest first. v3.0 and v3.1
32
+ // share the same metrics and vector grammar; only the `CVSS:<version>/`
33
+ // prefix differs, and MITRE validates each block against its own schema.
34
+ const CVSS_V3_KEYS = [
35
+ { key: 'cvssV3_1', version: '3.1' },
36
+ { key: 'cvssV3_0', version: '3.0' }
37
+ ]
38
+
39
+ // First CVSS v3 block (3.1 or 3.0) with a vector, plus the key it lives
40
+ // under so issue paths and prefix checks can name the right version.
41
+ function getCvssV3 (c) {
42
+ for (const m of c?.metrics ?? []) {
43
+ for (const { key, version } of CVSS_V3_KEYS) {
44
+ if (m?.[key]?.vectorString) return { key, version, block: m[key] }
45
+ }
46
+ }
47
+ return null
31
48
  }
32
49
 
33
50
  function getCvssScore (c) {
34
51
  for (const m of c?.metrics ?? []) {
35
- if (typeof m?.cvssV3_1?.baseScore === 'number') return m.cvssV3_1.baseScore
52
+ for (const { key } of CVSS_V3_KEYS) {
53
+ if (typeof m?.[key]?.baseScore === 'number') return m[key].baseScore
54
+ }
36
55
  }
37
56
  return null
38
57
  }
@@ -196,38 +215,42 @@ export const RULES = [
196
215
  id: 'metrics-present',
197
216
  severity: 'warning',
198
217
  // MITRE doesn't require a metrics block, but every published OpenJS
199
- // CVE carries at least one (v3.1 or v4.0). Without metrics the
218
+ // CVE carries at least one (v3.0, v3.1 or v4.0). Without metrics the
200
219
  // record looks half-finished to consumers parsing NVD/OSV.
201
220
  tier: 'openjs-extra',
202
221
  check (c) {
203
222
  const metrics = c?.metrics ?? []
204
- const hasCvss = metrics.some(m => m?.cvssV3_1?.vectorString || m?.cvssV4_0?.vectorString)
223
+ const hasCvss = getCvssV3(c) != null || metrics.some(m => m?.cvssV4_0?.vectorString)
205
224
  if (hasCvss) return null
206
225
  return {
207
226
  path: 'metrics',
208
- message: 'No CVSS metric present. Add at least one cvssV3_1 or cvssV4_0 block.'
227
+ message: 'No CVSS metric present. Add at least one cvssV3_1, cvssV3_0 or cvssV4_0 block.'
209
228
  }
210
229
  }
211
230
  },
212
231
  {
213
232
  id: 'cvss-vector-parseable',
214
233
  severity: 'blocker',
215
- // MITRE imports the FIRST.org CVSS v3.1 schema, which constrains
216
- // `vectorString` with a pattern that requires the `CVSS:3.1/` prefix and
217
- // validates every KEY:VAL component. Same failure as this rule.
234
+ // MITRE imports the FIRST.org CVSS v3.x schemas, which constrain
235
+ // `vectorString` with a pattern that requires the `CVSS:3.1/` (or
236
+ // `CVSS:3.0/` in a cvssV3_0 block) prefix and validates every KEY:VAL
237
+ // component. Same failure as this rule.
218
238
  tier: 'mitre-common',
219
239
  check (c) {
220
- const vec = getCvssVector(c)
221
- if (!vec) return null
222
- // Minimal CVSS v3.1 sanity check. Full parsing would need an external
223
- // library; we just verify the prefix and that every component looks
224
- // like KEY:VAL pairs.
225
- if (!vec.startsWith('CVSS:3.1/')) {
226
- return { path: 'metrics[].cvssV3_1.vectorString', message: 'CVSS vector does not start with CVSS:3.1/' }
240
+ const cvss = getCvssV3(c)
241
+ if (!cvss) return null
242
+ const vec = cvss.block.vectorString
243
+ const prefix = `CVSS:${cvss.version}/`
244
+ const path = `metrics[].${cvss.key}.vectorString`
245
+ // Minimal CVSS v3 sanity check. Full parsing would need an external
246
+ // library; we just verify the prefix matches the block's version and
247
+ // that every component looks like KEY:VAL pairs.
248
+ if (!vec.startsWith(prefix)) {
249
+ return { path, message: `CVSS vector does not start with ${prefix}` }
227
250
  }
228
- const parts = vec.slice('CVSS:3.1/'.length).split('/')
251
+ const parts = vec.slice(prefix.length).split('/')
229
252
  if (parts.some(p => !/^[A-Z]+:[A-Z]+$/.test(p))) {
230
- return { path: 'metrics[].cvssV3_1.vectorString', message: 'CVSS vector has malformed components' }
253
+ return { path, message: 'CVSS vector has malformed components' }
231
254
  }
232
255
  return null
233
256
  }
@@ -235,24 +258,52 @@ export const RULES = [
235
258
  {
236
259
  id: 'cvss-score-matches-band',
237
260
  severity: 'warning',
238
- // MITRE's bundled CVSS v3.1 schema has an `anyOf` that pairs each score
261
+ // MITRE's bundled CVSS v3.x schemas have an `anyOf` that pairs each score
239
262
  // band (none/low/medium/high/critical) with the matching baseSeverity
240
263
  // enum, so a band mismatch is a schema violation upstream too.
241
264
  tier: 'mitre-common',
242
265
  check (c) {
243
266
  const score = getCvssScore(c)
244
- const band = c?.metrics?.find(m => m?.cvssV3_1)?.cvssV3_1?.baseSeverity
267
+ const cvss = getCvssV3(c)
268
+ const band = cvss?.block.baseSeverity
245
269
  if (score == null || !band) return null
246
270
  const expected = expectedSeverityBand(score)
247
271
  if (expected !== band) {
248
272
  return {
249
- path: 'metrics[].cvssV3_1',
273
+ path: `metrics[].${cvss.key}`,
250
274
  message: `baseSeverity is '${band}' but the score ${score} falls in band '${expected}'`
251
275
  }
252
276
  }
253
277
  return null
254
278
  }
255
279
  },
280
+ {
281
+ id: 'cvss-score-matches-vector',
282
+ severity: 'blocker',
283
+ // MITRE does not recompute scores, so a baseScore that disagrees with
284
+ // its vector is accepted upstream and then contradicts every consumer
285
+ // that scores the vector itself (NVD, OSV, scanners). The vector is the
286
+ // source of truth; the score must be the one FIRST.org's v3.x formula
287
+ // yields for it. Unparseable vectors are left to cvss-vector-parseable.
288
+ tier: 'openjs-extra',
289
+ check (c) {
290
+ const cvss = getCvssV3(c)
291
+ if (!cvss || typeof cvss.block.baseScore !== 'number') return null
292
+ let computed
293
+ try {
294
+ computed = calculateBaseScore(cvss.block.vectorString)
295
+ } catch {
296
+ return null
297
+ }
298
+ if (computed.baseScore !== cvss.block.baseScore) {
299
+ return {
300
+ path: `metrics[].${cvss.key}.baseScore`,
301
+ message: `baseScore is ${cvss.block.baseScore} but the vector scores ${computed.baseScore}`
302
+ }
303
+ }
304
+ return null
305
+ }
306
+ },
256
307
 
257
308
  // CWE
258
309
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ulisesgascon/openjs-cna-tools",
3
- "version": "1.0.0-alpha.1",
3
+ "version": "1.0.0-alpha.3",
4
4
  "description": "EXPERIMENTAL fork of the OpenJS Foundation CNA tools: CLI and library for the OpenJS CNA API",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -11,14 +11,17 @@
11
11
  "node": ">=22.0.0"
12
12
  },
13
13
  "bin": {
14
- "openjs-cna-experimental": "./bin/openjs-cna.js"
14
+ "openjs-cna-experimental": "bin/openjs-cna.js"
15
15
  },
16
16
  "main": "./lib/index.js",
17
17
  "exports": {
18
18
  ".": "./lib/index.js",
19
19
  "./client": "./lib/client.js",
20
20
  "./review": "./lib/review/index.js",
21
- "./convert": "./lib/convert/ghsa.js"
21
+ "./convert": "./lib/convert/ghsa.js",
22
+ "./cvss": "./lib/cvss.js",
23
+ "./record": "./lib/record.js",
24
+ "./batch": "./lib/batch.js"
22
25
  },
23
26
  "files": [
24
27
  "bin/",