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

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,115 @@ 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
+ ### HackerOne weaknesses
256
+
257
+ HackerOne's REST API reports a finding's weakness as HackerOne's own numeric
258
+ `weakness_id`, not as a CWE number (weakness 26 is "Improper Access Control",
259
+ CWE-284). The taxonomy is shared by every program, so the mapping ships with
260
+ the package as data:
261
+
262
+ ```js
263
+ import { hackerOneWeakness, hackerOneWeaknessProblemType } from '@ulisesgascon/openjs-cna-tools/hackerone'
264
+
265
+ hackerOneWeakness(26)
266
+ // { cwe: 'CWE-284', name: 'Improper Access Control - Generic' }
267
+ hackerOneWeakness(75)
268
+ // { cwe: null, name: 'Privilege Escalation' } (a CAPEC entry on HackerOne's side)
269
+ hackerOneWeakness(999999)
270
+ // null (unknown id)
271
+
272
+ // Ready-made problemTypes[] entry, or null when there is no CWE to put on the record.
273
+ hackerOneWeaknessProblemType(26)
274
+ ```
275
+
276
+ `lib/hackerone/weaknesses.json` is regenerated with
277
+ `node scripts/build-hackerone-weaknesses.js <dump.json>` from the GraphQL
278
+ response the HackerOne web app loads for a report's weakness picker; the
279
+ script documents the shape it expects.
280
+
281
+ ### Record helpers
282
+
283
+ Converters only know their own source. The parts of a record that are about
284
+ MITRE's format rather than the source live in one place, so no consumer
285
+ hardcodes the OpenJS CNA org id, the generator string or the CVSS block
286
+ layout:
287
+
288
+ ```js
289
+ import { buildCvssMetric, buildDescription, buildProblemType, buildRecordEnvelope } from '@ulisesgascon/openjs-cna-tools/record'
290
+
291
+ const record = buildRecordEnvelope({
292
+ cveId: 'CVE-2026-0001',
293
+ cna: {
294
+ title: '…',
295
+ // plain text in `value`; the Markdown, if any, kept in supportingMedia
296
+ descriptions: [buildDescription('A flaw in `dns.resolve()` allows **remote** attackers …')],
297
+ affected: [/* … */],
298
+ references: [/* … */],
299
+ // id normalised to CWE-<number>; description is the name, or the id
300
+ problemTypes: [buildProblemType({ cweId: 400, name: 'Uncontrolled Resource Consumption' })],
301
+ // key (cvssV3_0 / cvssV3_1 / cvssV4_0) follows the vector prefix;
302
+ // a missing v3.x score is computed, a v4.0 score must be passed in
303
+ metrics: [buildCvssMetric('CVSS:3.0/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:H/A:N')]
304
+ }
305
+ })
306
+ // record.dataType / dataVersion / cveMetadata set, providerMetadata.orgId
307
+ // and x_generator.engine filled on the container unless already present.
308
+ ```
309
+
310
+ The GHSA converter is built on the same helpers.
311
+
312
+ The same calculator backs two behaviours:
313
+
314
+ - the reviewer's `cvss-score-matches-vector` rule (blocker) recomputes the
315
+ score of every v3.x block and rejects a `baseScore` that disagrees with its
316
+ vector, since MITRE accepts the record as sent and never re-scores it;
317
+ - the GHSA converter fills `baseScore` and `baseSeverity` from the vector when
318
+ the advisory carries a v3.1 vector without a score.
319
+
185
320
  ## Design
186
321
 
187
322
  - **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.
323
+ - **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
324
  - **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
325
  - **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
326
  - **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.