@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 +137 -1
- package/components/publish.js +135 -30
- package/components/review.js +67 -20
- package/lib/batch.js +141 -0
- package/lib/convert/ghsa.js +30 -121
- package/lib/cvss.js +113 -0
- package/lib/hackerone/index.js +42 -0
- package/lib/hackerone/weaknesses.json +6550 -0
- package/lib/preview.js +15 -14
- package/lib/prompt.js +12 -7
- package/lib/record.js +173 -0
- package/lib/review/rules.js +72 -21
- package/package.json +7 -3
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
|
|
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>.
|
package/components/publish.js
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
|
-
// openjs-cna-experimental publish <cve-id> [-f
|
|
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
|
-
//
|
|
4
|
-
// present (hard-error on mismatch);
|
|
5
|
-
//
|
|
6
|
-
// clean review (or `--skip-review`), prompts for
|
|
7
|
-
// publishes. Surfaces `correlation_id` + `run_id` after
|
|
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
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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: !
|
|
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
|
|
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
|
|
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
|
|
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
|
)
|
package/components/review.js
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
|
-
// openjs-cna-experimental review <
|
|
1
|
+
// openjs-cna-experimental review <record.json> [<record.json>…] [--no-strict] [--no-preview] [--tier <name>] [--json]
|
|
2
2
|
//
|
|
3
|
-
// Runs the offline reviewer against
|
|
4
|
-
// (warnings block); --no-strict surfaces them as warnings. Renders
|
|
5
|
-
// preview before the issues list unless --no-preview. --tier filters
|
|
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
|
|
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
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
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 (
|
|
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 <
|
|
152
|
+
`openjs-cna-experimental review <record.json> [<record.json>…]
|
|
109
153
|
|
|
110
|
-
Run the offline reviewer against
|
|
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
|
|
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.
|