@ulisesgascon/openjs-cna-tools 0.0.0-stage → 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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +312 -2
  3. package/bin/openjs-cna.js +128 -0
  4. package/components/config.js +92 -0
  5. package/components/convert.js +81 -0
  6. package/components/get.js +35 -0
  7. package/components/health.js +30 -0
  8. package/components/list.js +36 -0
  9. package/components/llm-review-prompt.js +84 -0
  10. package/components/preview.js +42 -0
  11. package/components/publish.js +238 -0
  12. package/components/reserve.js +48 -0
  13. package/components/review.js +170 -0
  14. package/components/update.js +104 -0
  15. package/lib/auth.js +20 -0
  16. package/lib/batch.js +141 -0
  17. package/lib/cli.js +81 -0
  18. package/lib/client.js +244 -0
  19. package/lib/component-utils.js +74 -0
  20. package/lib/config.js +121 -0
  21. package/lib/convert/ghsa.js +450 -0
  22. package/lib/convert/version-range.js +251 -0
  23. package/lib/cvss.js +113 -0
  24. package/lib/errors.js +44 -0
  25. package/lib/index.js +9 -0
  26. package/lib/preview.js +145 -0
  27. package/lib/prompt.js +170 -0
  28. package/lib/record.js +123 -0
  29. package/lib/review/index.js +78 -0
  30. package/lib/review/rules.js +433 -0
  31. package/lib/review/schema.js +99 -0
  32. package/lib/review/schemas/cve-record-format.json +1362 -0
  33. package/lib/review/schemas/imports/cvss/cvss-v2.0.json +195 -0
  34. package/lib/review/schemas/imports/cvss/cvss-v3.0.json +577 -0
  35. package/lib/review/schemas/imports/cvss/cvss-v3.1.json +577 -0
  36. package/lib/review/schemas/imports/cvss/cvss-v4.0.json +784 -0
  37. package/lib/review/schemas/tags/adp-tags.json +7 -0
  38. package/lib/review/schemas/tags/cna-tags.json +7 -0
  39. package/lib/review/schemas/tags/reference-tags.json +27 -0
  40. package/package.json +55 -4
@@ -0,0 +1,30 @@
1
+ // openjs-cna-experimental health
2
+ // Unauthenticated liveness check against the configured CNA API.
3
+
4
+ import { parseComponentArgs, buildClient, buildCli } from '../lib/component-utils.js'
5
+
6
+ export const command = 'health'
7
+ export const describe = 'Check that the CNA API is reachable.'
8
+
9
+ export async function handler (argv) {
10
+ const parsed = parseComponentArgs(argv)
11
+ if (parsed.values.help) return printHelp()
12
+ const cli = buildCli(parsed)
13
+ const client = buildClient(parsed)
14
+ const status = await client.health()
15
+ cli.ok(`API is reachable (${status?.status ?? 'ok'})`)
16
+ process.stdout.write(JSON.stringify(status, null, 2) + '\n')
17
+ }
18
+
19
+ function printHelp () {
20
+ process.stderr.write(
21
+ `openjs-cna-experimental health
22
+
23
+ Probe the configured CNA API's /health endpoint. Useful to catch base-url
24
+ misconfiguration before burning a workflow dispatch.
25
+
26
+ Usage:
27
+ openjs-cna-experimental health [global flags]
28
+ `
29
+ )
30
+ }
@@ -0,0 +1,36 @@
1
+ // openjs-cna-experimental list
2
+ // Lists all CVE ids in the bucket. One id per line by default; --json for array.
3
+
4
+ import { parseComponentArgs, buildClient, buildCli } from '../lib/component-utils.js'
5
+
6
+ export const command = 'list'
7
+ export const describe = 'List all CVEs in the bucket.'
8
+
9
+ export async function handler (argv) {
10
+ const parsed = parseComponentArgs(argv)
11
+ if (parsed.values.help) return printHelp()
12
+ const cli = buildCli(parsed)
13
+ const client = buildClient(parsed)
14
+ cli.info('Listing CVEs…')
15
+ const ids = await client.listCves({
16
+ onPoll: (run) => { if (run.status !== 'completed') cli.info(`run status: ${run.status}`) }
17
+ })
18
+ cli.ok(`Found ${ids.length} CVE id(s)`)
19
+ if (parsed.values.json) {
20
+ process.stdout.write(JSON.stringify(ids, null, 2) + '\n')
21
+ } else {
22
+ for (const id of ids) process.stdout.write(`${id}\n`)
23
+ }
24
+ }
25
+
26
+ function printHelp () {
27
+ process.stderr.write(
28
+ `openjs-cna-experimental list
29
+
30
+ List every CVE id in the bucket. Default output: one id per line.
31
+
32
+ Options:
33
+ --json emit a JSON array instead
34
+ `
35
+ )
36
+ }
@@ -0,0 +1,84 @@
1
+ // openjs-cna-experimental llm-review-prompt <record.json>
2
+ //
3
+ // Print a prompt for an LLM-driven editorial review of the record's
4
+ // natural-language fields (title + description). The tool does not call
5
+ // an LLM itself. Paste the output into the LLM of your choice. Pure
6
+ // read; no network.
7
+
8
+ import { parseComponentArgs, buildCli, readFileOrStdin } from '../lib/component-utils.js'
9
+ import { buildAuditPrompt } from '../lib/prompt.js'
10
+
11
+ export const command = 'llm-review-prompt'
12
+ export const describe = 'Print a prompt for an LLM editorial review of the record\'s title + description.'
13
+
14
+ export async function handler (argv) {
15
+ const parsed = parseComponentArgs(argv, {}, { allowPositionals: true })
16
+ if (parsed.values.help) return printHelp()
17
+
18
+ // Keep buildCli for help/diagnostic plumbing even though we don't
19
+ // print status. Keeps the component shape consistent with peers.
20
+ buildCli(parsed)
21
+
22
+ const file = parsed.positionals[0]
23
+ const raw = await readFileOrStdin(file)
24
+ let record
25
+ try {
26
+ record = JSON.parse(raw)
27
+ } catch (e) {
28
+ process.stderr.write(`✖ Record is not valid JSON: ${e.message}\n`)
29
+ process.exit(64)
30
+ }
31
+
32
+ // Banner goes to stderr so a pipe like `| pbcopy` or `| llm` only
33
+ // captures the prompt itself, not the human-facing how-to.
34
+ process.stderr.write(buildBanner())
35
+ process.stdout.write(buildAuditPrompt(record))
36
+ }
37
+
38
+ function buildBanner () {
39
+ const lines = [
40
+ 'How to use this prompt',
41
+ '',
42
+ '1. Paste everything that follows into an LLM (Claude, ChatGPT,',
43
+ ' Gemini, etc.).',
44
+ '2. Also attach the full CVE Record JSON so the LLM can cross-check',
45
+ ' the natural-language fields against the rest of the record.',
46
+ '3. The LLM returns a Markdown review you can act on.'
47
+ ]
48
+ const width = Math.max(...lines.map(l => l.length))
49
+ const top = '┌' + '─'.repeat(width + 2) + '┐'
50
+ const bottom = '└' + '─'.repeat(width + 2) + '┘'
51
+ const body = lines.map(l => '│ ' + l.padEnd(width) + ' │').join('\n')
52
+ return top + '\n' + body + '\n' + bottom + '\n'
53
+ }
54
+
55
+ function printHelp () {
56
+ process.stderr.write(
57
+ `openjs-cna-experimental llm-review-prompt <record.json>
58
+
59
+ Build a prompt that asks an LLM for an editorial review of the record's
60
+ title and description. The prompt includes the OpenJS naming and tone
61
+ guidelines, the texts to audit, and a short facts block (affected,
62
+ CVSS, CWE) so the LLM can ground its suggestions.
63
+
64
+ The tool does not call any LLM. Paste the output into the LLM of your
65
+ choice and read the reply. Attach the full CVE Record JSON in the same
66
+ turn so the LLM can cross-check the prose against the structured data.
67
+
68
+ Reads the record from a file (positional) or from stdin if the
69
+ positional is '-' or absent.
70
+
71
+ A how-to banner is written to stderr so a pipe like \`| pbcopy\` only
72
+ captures the prompt itself.
73
+
74
+ Pipeline use (audit a freshly converted draft before publish):
75
+
76
+ openjs-cna-experimental convert https://github.com/<owner>/<repo>/security/advisories/<GHSA> \\
77
+ | tee draft.json \\
78
+ | openjs-cna-experimental llm-review-prompt - | pbcopy
79
+
80
+ Open the LLM, paste, attach \`draft.json\`, then iterate before
81
+ \`openjs-cna-experimental publish\`.
82
+ `
83
+ )
84
+ }
@@ -0,0 +1,42 @@
1
+ // openjs-cna-experimental preview <container.json>
2
+ // Compact terminal view of a v5.2 CNA Container. Pure read; no network.
3
+
4
+ import { parseComponentArgs, buildCli, readFileOrStdin } from '../lib/component-utils.js'
5
+ import { renderPreview } from '../lib/preview.js'
6
+
7
+ export const command = 'preview'
8
+ export const describe = 'Print a compact preview of a CNA Container.'
9
+
10
+ export async function handler (argv) {
11
+ const parsed = parseComponentArgs(argv, {}, { allowPositionals: true })
12
+ if (parsed.values.help) return printHelp()
13
+
14
+ const cli = buildCli(parsed)
15
+ const file = parsed.positionals[0]
16
+ const raw = await readFileOrStdin(file)
17
+ let container
18
+ try {
19
+ container = JSON.parse(raw)
20
+ } catch (e) {
21
+ process.stderr.write(`✖ Container is not valid JSON: ${e.message}\n`)
22
+ process.exit(64)
23
+ }
24
+
25
+ renderPreview(container, cli)
26
+ }
27
+
28
+ function printHelp () {
29
+ process.stderr.write(
30
+ `openjs-cna-experimental preview <container.json>
31
+
32
+ Print a compact summary of the container — title, description, affected
33
+ products, CVSS, CWE, credits, references, and the x_generator engine.
34
+
35
+ Useful as a quick sanity check before publish, or as a step in a pipeline
36
+ ('openjs-cna-experimental get CVE-... | openjs-cna-experimental preview -').
37
+
38
+ Reads the container from a file (positional) or from stdin if the
39
+ positional is '-' or absent.
40
+ `
41
+ )
42
+ }
@@ -0,0 +1,238 @@
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]
3
+ //
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.
19
+
20
+ import {
21
+ parseComponentArgs,
22
+ buildClient,
23
+ buildCli,
24
+ readFileOrStdin
25
+ } from '../lib/component-utils.js'
26
+ import { reviewRecord } from '../lib/review/index.js'
27
+ import { publishRecords } from '../lib/batch.js'
28
+ import { CnaReviewBlockedError } from '../lib/errors.js'
29
+
30
+ export const command = 'publish'
31
+ export const describe = 'Publish one or more v5.2 CVE Records for reserved CVE ids.'
32
+
33
+ export async function handler (argv) {
34
+ const parsed = parseComponentArgs(argv, {
35
+ file: { type: 'string', short: 'f', multiple: true },
36
+ 'skip-review': { type: 'boolean' },
37
+ 'no-strict': { type: 'boolean' },
38
+ 'skip-published': { type: 'boolean' }
39
+ }, { allowPositionals: true })
40
+ if (parsed.values.help) return printHelp()
41
+
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
+
47
+ if (!cveId) {
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')
59
+ process.exit(64)
60
+ }
61
+
62
+ const cli = buildCli(parsed)
63
+ const client = buildClient(parsed)
64
+ const record = await readRecord(files[0])
65
+
66
+ // CVE id consistency check. The Record's cveMetadata.cveId must match
67
+ // the CLI arg; mismatch is a hard error (operator was probably about to
68
+ // publish the wrong record). If cveMetadata is missing, the CLI arg is
69
+ // the sole source of truth.
70
+ const recordCveId = record?.cveMetadata?.cveId
71
+ if (recordCveId && recordCveId !== cveId) {
72
+ process.stderr.write(
73
+ `✖ CVE id mismatch: CLI argument is ${cveId}, record.cveMetadata.cveId is ${recordCveId}.\n` +
74
+ ' Aborting before publish to prevent updating the wrong record.\n'
75
+ )
76
+ process.exit(64)
77
+ }
78
+
79
+ // Extract the inner cna container — the workflow expects just that,
80
+ // not the full Record (it builds the Record server-side with the
81
+ // assignerOrgId and dateUpdated MITRE injects).
82
+ const container = record.containers.cna
83
+
84
+ if (!skipReview) {
85
+ const report = reviewRecord(record, { strict })
86
+ // Strict mode promotes warnings to blockers inside reviewRecord, so
87
+ // the `summary.blockers` count already reflects strictness. We just
88
+ // need to check that single number.
89
+ if (report.summary.blockers > 0) {
90
+ cli.error(
91
+ `Review blocked publish: ${report.summary.blockers} blocker(s)`
92
+ )
93
+ printIssues(cli, report.issues)
94
+ cli.log('')
95
+ cli.log(' Re-run with --skip-review to bypass.')
96
+ throw new CnaReviewBlockedError(
97
+ `Review blocked publish for ${cveId}`,
98
+ { report }
99
+ )
100
+ }
101
+ if (report.summary.warnings) {
102
+ cli.warn(`Review surfaced ${report.summary.warnings} warning(s); proceeding.`)
103
+ } else {
104
+ cli.ok(`Review clean (${report.summary.suggestions} suggestion(s)).`)
105
+ }
106
+ }
107
+
108
+ // Confirmation prompt. Defaults to "yes" only when the review was clean
109
+ // (and we got here without --skip-review). Honors --yes / OPENJS_CNA_YES.
110
+ const confirmed = await cli.prompt(
111
+ `Publish ${cveId}? This is irreversible.`,
112
+ { defaultAnswer: !skipReview }
113
+ )
114
+ if (!confirmed) {
115
+ cli.info('Aborted.')
116
+ return
117
+ }
118
+
119
+ cli.info(`Publishing ${cveId}…`)
120
+ const { correlationId, runId, result } = await client.publishCve(cveId, container, {
121
+ onPoll: (run) => {
122
+ if (run.status && run.status !== 'completed') cli.info(`run status: ${run.status}`)
123
+ }
124
+ })
125
+ cli.ok(`Published ${cveId}`)
126
+ if (correlationId || runId) {
127
+ cli.info(`Forward to coordinator if needed: correlation_id=${correlationId ?? '?'}, run_id=${runId ?? '?'}`)
128
+ }
129
+ if (result) process.stdout.write(JSON.stringify(result, null, 2) + '\n')
130
+ }
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
+
214
+ function printHelp () {
215
+ process.stderr.write(
216
+ `openjs-cna-experimental publish <cve-id> [-f record.json]
217
+ openjs-cna-experimental publish -f a.json -f b.json … [--skip-published]
218
+
219
+ Publish v5.2 CVE Records against already-reserved CVE ids. Default
220
+ behavior runs the reviewer first (strict — warnings block too) and aborts
221
+ on any issue.
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
+
229
+ Options:
230
+ -f, --file <path> read a record from a file (default: stdin); repeatable
231
+ --skip-review do not run the reviewer
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
235
+ [global flags]
236
+ `
237
+ )
238
+ }
@@ -0,0 +1,48 @@
1
+ // openjs-cna-experimental reserve
2
+ // Reserves a CVE id via the CNA API and prints it on stdout.
3
+
4
+ import { parseComponentArgs, buildClient, buildCli } from '../lib/component-utils.js'
5
+
6
+ export const command = 'reserve'
7
+ export const describe = 'Reserve a CVE id via the OpenJS Foundation CNA.'
8
+
9
+ export async function handler (argv) {
10
+ const parsed = parseComponentArgs(argv)
11
+ if (parsed.values.help) return printHelp()
12
+ const cli = buildCli(parsed)
13
+ const client = buildClient(parsed)
14
+
15
+ cli.info('Reserving CVE…')
16
+ const { cveId, correlationId, runId } = await client.reserveCve({
17
+ onPoll: (run) => {
18
+ if (run.status && run.status !== 'completed') {
19
+ // Honest progress — one line per poll cycle, no animation.
20
+ cli.info(`run status: ${run.status}`)
21
+ }
22
+ }
23
+ })
24
+ cli.ok(`Reserved ${cveId}`)
25
+ if (correlationId || runId) {
26
+ cli.info(`Forward to coordinator if needed: correlation_id=${correlationId ?? '?'}, run_id=${runId ?? '?'}`)
27
+ }
28
+
29
+ // Data on stdout, no decoration — `CVE=$(openjs-cna-experimental reserve)` Just Works.
30
+ process.stdout.write(`${cveId}\n`)
31
+ }
32
+
33
+ function printHelp () {
34
+ process.stderr.write(
35
+ `openjs-cna-experimental reserve
36
+
37
+ Reserve a CVE id from the configured bucket. Prints the CVE id on stdout
38
+ once the workflow completes.
39
+
40
+ Usage:
41
+ openjs-cna-experimental reserve [global flags]
42
+
43
+ Example:
44
+ CVE=$(openjs-cna-experimental reserve)
45
+ openjs-cna-experimental publish "$CVE" -f container.json
46
+ `
47
+ )
48
+ }
@@ -0,0 +1,170 @@
1
+ // openjs-cna-experimental review <record.json> [<record.json>…] [--no-strict] [--no-preview] [--tier <name>] [--json]
2
+ //
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
+ // 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.
14
+
15
+ import { parseComponentArgs, buildCli, readFileOrStdin } from '../lib/component-utils.js'
16
+ import { reviewRecord } from '../lib/review/index.js'
17
+ import { reviewRecords } from '../lib/batch.js'
18
+ import { renderPreview } from '../lib/preview.js'
19
+
20
+ export const command = 'review'
21
+ export const describe = 'Review one or more v5.2 CVE Records against best-practice rules.'
22
+
23
+ const VALID_TIERS = new Set(['mitre-common', 'openjs-extra'])
24
+
25
+ export async function handler (argv) {
26
+ const parsed = parseComponentArgs(argv, {
27
+ 'no-strict': { type: 'boolean' },
28
+ 'no-preview': { type: 'boolean' },
29
+ tier: { type: 'string' }
30
+ }, { allowPositionals: true })
31
+ if (parsed.values.help) return printHelp()
32
+
33
+ const cli = buildCli(parsed)
34
+ const files = parsed.positionals
35
+
36
+ // Validate the --tier filter early so a typo doesn't silently match
37
+ // nothing and look like a clean review.
38
+ const tierFilter = parsed.values.tier
39
+ if (tierFilter && !VALID_TIERS.has(tierFilter)) {
40
+ process.stderr.write(
41
+ `✖ Unknown --tier '${tierFilter}'. Expected one of: ${[...VALID_TIERS].join(', ')}\n`
42
+ )
43
+ process.exit(64)
44
+ }
45
+
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 })
51
+ }
52
+
53
+ const container = await readRecord(files[0])
54
+ const report = reviewRecord(container, { strict })
55
+
56
+ if (parsed.values.json) {
57
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n')
58
+ } else {
59
+ // Preview goes first so the operator sees what they're about to ship
60
+ // alongside what the reviewer caught. --no-preview drops it for
61
+ // CI / pipeline use.
62
+ if (showPreview) renderPreview(container, cli)
63
+ printHumanReport(cli, report, { tierFilter })
64
+ }
65
+
66
+ // Exit code: 0 if ready, 1 if blockers (or strict + warnings).
67
+ if (report.summary.blockers > 0) process.exit(1)
68
+ }
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
+
108
+ function printHumanReport (cli, report, { tierFilter } = {}) {
109
+ const { summary, issues } = report
110
+ const ratingLabel = {
111
+ ready: '✔ ready',
112
+ 'needs-work': '⚠ needs work',
113
+ blocked: '✖ blocked'
114
+ }[summary.rating]
115
+ cli.log('')
116
+ cli.log(`Review: ${ratingLabel}`)
117
+ cli.log(` ${summary.blockers} blocker(s) · ${summary.warnings} warning(s) · ${summary.suggestions} suggestion(s)`)
118
+ if (tierFilter) {
119
+ cli.log(` (filtered to tier: ${tierFilter})`)
120
+ }
121
+ cli.log('')
122
+ // Filter at print time so the summary counts above still reflect the full
123
+ // review. This keeps `--tier mitre-common` from hiding the broader picture
124
+ // while still letting operators focus on the must-fix items.
125
+ const shown = tierFilter
126
+ ? issues.filter(i => i.tier === tierFilter)
127
+ : issues
128
+ if (shown.length === 0) {
129
+ if (tierFilter && issues.length > 0) {
130
+ cli.log(` No ${tierFilter} issues found.`)
131
+ } else {
132
+ cli.log(' No issues found.')
133
+ }
134
+ return
135
+ }
136
+ for (const issue of shown) {
137
+ const tag = issue.severity === 'blocker'
138
+ ? '✖'
139
+ : issue.severity === 'warning'
140
+ ? '⚠'
141
+ : '·'
142
+ // Tier rides inline with the rule id so a glance at the report tells
143
+ // the operator whether MITRE would have caught the issue too.
144
+ const tierLabel = issue.tier ? `${issue.tier}: ` : ''
145
+ cli.log(` ${tag} [${tierLabel}${issue.rule}] ${issue.path}`)
146
+ cli.log(` ${issue.message}`)
147
+ }
148
+ }
149
+
150
+ function printHelp () {
151
+ process.stderr.write(
152
+ `openjs-cna-experimental review <record.json> [<record.json>…]
153
+
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
156
+ irreversible. Pass --no-strict to surface warnings as warnings.
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
+
161
+ Options:
162
+ --no-strict surface warnings as warnings (default treats them as blockers)
163
+ --json emit a structured JSON report instead of the human view
164
+ --no-preview skip the record preview (for CI / pipelines)
165
+ --tier <tier> filter the printed issue list to a single tier
166
+ (mitre-common | openjs-extra). Summary counts still
167
+ reflect the full review; exit code is unaffected.
168
+ `
169
+ )
170
+ }