@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 +110 -1
- package/components/publish.js +135 -30
- package/components/review.js +67 -20
- package/lib/batch.js +141 -0
- package/lib/convert/ghsa.js +25 -68
- package/lib/cvss.js +113 -0
- package/lib/preview.js +15 -14
- package/lib/prompt.js +12 -7
- package/lib/record.js +123 -0
- package/lib/review/rules.js +72 -21
- package/package.json +6 -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,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>.
|
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.
|
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
|
+
}
|
package/lib/convert/ghsa.js
CHANGED
|
@@ -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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
+
}
|
package/lib/review/rules.js
CHANGED
|
@@ -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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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?.
|
|
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.
|
|
216
|
-
// `vectorString` with a pattern that requires the `CVSS:3.1/`
|
|
217
|
-
//
|
|
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
|
|
221
|
-
if (!
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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(
|
|
251
|
+
const parts = vec.slice(prefix.length).split('/')
|
|
229
252
|
if (parts.some(p => !/^[A-Z]+:[A-Z]+$/.test(p))) {
|
|
230
|
-
return { path
|
|
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.
|
|
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
|
|
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:
|
|
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.
|
|
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": "
|
|
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/",
|