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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +312 -2
  3. package/bin/openjs-cna.js +128 -0
  4. package/components/config.js +92 -0
  5. package/components/convert.js +81 -0
  6. package/components/get.js +35 -0
  7. package/components/health.js +30 -0
  8. package/components/list.js +36 -0
  9. package/components/llm-review-prompt.js +84 -0
  10. package/components/preview.js +42 -0
  11. package/components/publish.js +238 -0
  12. package/components/reserve.js +48 -0
  13. package/components/review.js +170 -0
  14. package/components/update.js +104 -0
  15. package/lib/auth.js +20 -0
  16. package/lib/batch.js +141 -0
  17. package/lib/cli.js +81 -0
  18. package/lib/client.js +244 -0
  19. package/lib/component-utils.js +74 -0
  20. package/lib/config.js +121 -0
  21. package/lib/convert/ghsa.js +450 -0
  22. package/lib/convert/version-range.js +251 -0
  23. package/lib/cvss.js +113 -0
  24. package/lib/errors.js +44 -0
  25. package/lib/index.js +9 -0
  26. package/lib/preview.js +145 -0
  27. package/lib/prompt.js +170 -0
  28. package/lib/record.js +123 -0
  29. package/lib/review/index.js +78 -0
  30. package/lib/review/rules.js +433 -0
  31. package/lib/review/schema.js +99 -0
  32. package/lib/review/schemas/cve-record-format.json +1362 -0
  33. package/lib/review/schemas/imports/cvss/cvss-v2.0.json +195 -0
  34. package/lib/review/schemas/imports/cvss/cvss-v3.0.json +577 -0
  35. package/lib/review/schemas/imports/cvss/cvss-v3.1.json +577 -0
  36. package/lib/review/schemas/imports/cvss/cvss-v4.0.json +784 -0
  37. package/lib/review/schemas/tags/adp-tags.json +7 -0
  38. package/lib/review/schemas/tags/cna-tags.json +7 -0
  39. package/lib/review/schemas/tags/reference-tags.json +27 -0
  40. package/package.json +55 -4
@@ -0,0 +1,433 @@
1
+ // One universal review ruleset. No profiles — these rules are the things
2
+ // every CNA we know about agrees on (schema validity, basic credit hygiene,
3
+ // readable descriptions, references that aren't lies). Org-specific style
4
+ // preferences live in *callers*, not here.
5
+ //
6
+ // Adding a rule: append an entry below. Each rule is
7
+ // `{ id, severity, tier, check }` where check(container) returns either null
8
+ // (passes) or { path, message } (fails). The reviewer in ./index.js handles
9
+ // aggregation.
10
+ //
11
+ // `tier` classifies the rule against MITRE's v5.2 schema:
12
+ // - `mitre-common`: MITRE's schema (or a CVSS subschema MITRE imports)
13
+ // enforces the same constraint, so publish to MITRE would also fail.
14
+ // - `openjs-extra`: OpenJS Foundation editorial preference that is stricter
15
+ // than what MITRE itself enforces.
16
+ // See docs/mitre-schema-summary.md for the constraint inventory the tiers
17
+ // are derived from.
18
+
19
+ import { calculateBaseScore } from '../cvss.js'
20
+
21
+ const isString = (v) => typeof v === 'string'
22
+ const isNonEmptyArray = (v) => Array.isArray(v) && v.length > 0
23
+
24
+ // ----- Helpers --------------------------------------------------------------
25
+
26
+ function getDescription (c) {
27
+ return c?.descriptions?.find(d => d?.lang === 'en') ??
28
+ c?.descriptions?.[0]
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
48
+ }
49
+
50
+ function getCvssScore (c) {
51
+ for (const m of c?.metrics ?? []) {
52
+ for (const { key } of CVSS_V3_KEYS) {
53
+ if (typeof m?.[key]?.baseScore === 'number') return m[key].baseScore
54
+ }
55
+ }
56
+ return null
57
+ }
58
+
59
+ function expectedSeverityBand (score) {
60
+ if (score >= 9.0) return 'CRITICAL'
61
+ if (score >= 7.0) return 'HIGH'
62
+ if (score >= 4.0) return 'MEDIUM'
63
+ if (score >= 0.1) return 'LOW'
64
+ return 'NONE'
65
+ }
66
+
67
+ // ----- Rules ----------------------------------------------------------------
68
+ //
69
+ // The MITRE schema check (in ./schema.js) runs before these rules and
70
+ // handles structural concerns: required fields, type shape, CWE/CVSS id
71
+ // format, version pairings. The rules below cover the editorial / style
72
+ // dimensions schema validation can't enforce.
73
+
74
+ export const RULES = [
75
+ // Descriptions
76
+ {
77
+ id: 'description-present',
78
+ severity: 'blocker',
79
+ // MITRE: `descriptions` is required on the container, the array has
80
+ // minItems: 1, and `contains: englishLanguageDescription` requires at
81
+ // least one en-tagged entry. Same failure as this rule.
82
+ tier: 'mitre-common',
83
+ check (c) {
84
+ if (!isNonEmptyArray(c?.descriptions)) {
85
+ return { path: 'descriptions', message: 'No descriptions provided' }
86
+ }
87
+ const en = getDescription(c)
88
+ if (!en || !isString(en.value)) {
89
+ return { path: 'descriptions[lang=en]', message: 'No English description provided' }
90
+ }
91
+ return null
92
+ }
93
+ },
94
+ {
95
+ id: 'description-not-empty',
96
+ severity: 'blocker',
97
+ // MITRE's `description.value` has minLength: 1, so an empty string is a
98
+ // schema violation. This rule additionally rejects whitespace-only values
99
+ // (e.g. " "), which MITRE would accept. Classified as openjs-extra
100
+ // because the marginal coverage over MITRE is the editorial bit.
101
+ tier: 'openjs-extra',
102
+ check (c) {
103
+ const en = getDescription(c)
104
+ if (!en) return null // covered by description-present
105
+ if (!en.value.trim()) {
106
+ return { path: 'descriptions[lang=en].value', message: 'Description is empty' }
107
+ }
108
+ return null
109
+ }
110
+ },
111
+ {
112
+ id: 'description-length',
113
+ severity: 'warning',
114
+ // MITRE: minLength 1, maxLength 4096. The 200-2000 band here is purely
115
+ // editorial — readable prose, not too long.
116
+ tier: 'openjs-extra',
117
+ check (c) {
118
+ const en = getDescription(c)
119
+ const text = en?.value?.trim() ?? ''
120
+ if (!text) return null
121
+ if (text.length < 200) {
122
+ return {
123
+ path: 'descriptions[lang=en].value',
124
+ message: `Description is ${text.length} chars; recommend at least 200`
125
+ }
126
+ }
127
+ if (text.length > 2000) {
128
+ return {
129
+ path: 'descriptions[lang=en].value',
130
+ message: `Description is ${text.length} chars; recommend at most 2000`
131
+ }
132
+ }
133
+ return null
134
+ }
135
+ },
136
+ // Credits
137
+ {
138
+ id: 'reporter-credit-present',
139
+ severity: 'warning',
140
+ // MITRE makes `credits` itself optional and does not require any
141
+ // particular role to appear. OpenJS prefers a reporter/finder for
142
+ // attribution hygiene but does not strictly require one — some
143
+ // advisories are self-reported by the maintainer team.
144
+ tier: 'openjs-extra',
145
+ check (c) {
146
+ const credits = c?.credits ?? []
147
+ const hasReporter = credits.some(cr =>
148
+ cr?.type === 'reporter' || cr?.type === 'finder'
149
+ )
150
+ if (!hasReporter) {
151
+ return { path: 'credits', message: 'No reporter or finder credit found' }
152
+ }
153
+ return null
154
+ }
155
+ },
156
+ {
157
+ id: 'remediation-credit-present',
158
+ severity: 'warning',
159
+ // OpenJS editorial preference: at least one of the remediation roles
160
+ // (developer / reviewer / verifier) should be credited so consumers
161
+ // can trace who shipped the fix. MITRE does not require any specific
162
+ // role. We accept the MITRE space-separated form AND the GHSA
163
+ // underscore form so hand-written containers pass either way.
164
+ tier: 'openjs-extra',
165
+ check (c) {
166
+ const credits = c?.credits ?? []
167
+ const remediationRoles = new Set([
168
+ 'remediation developer',
169
+ 'remediation_developer',
170
+ 'remediation reviewer',
171
+ 'remediation_reviewer',
172
+ 'remediation verifier',
173
+ 'remediation_verifier'
174
+ ])
175
+ const has = credits.some(cr => remediationRoles.has(cr?.type))
176
+ if (!has) {
177
+ return {
178
+ path: 'credits',
179
+ message: 'No remediation credit found (expected one of: remediation developer / reviewer / verifier)'
180
+ }
181
+ }
182
+ return null
183
+ }
184
+ },
185
+ {
186
+ id: 'no-duplicate-credit-role',
187
+ severity: 'warning',
188
+ // MITRE's `credits` array has uniqueItems, but items with the same
189
+ // identity and different `type` values are NOT equal items, so the schema
190
+ // accepts them. Editorial preference to flag the dupe.
191
+ tier: 'openjs-extra',
192
+ check (c) {
193
+ const seen = new Map()
194
+ for (const cr of c?.credits ?? []) {
195
+ // `value` carries the free-form identity (login, name, etc.).
196
+ // `user` is the UUID-typed field per MITRE; we fall back to it
197
+ // for hand-written containers that prefer that shape.
198
+ const identity = cr?.value ?? cr?.user
199
+ if (!identity) continue
200
+ const key = typeof identity === 'string' ? identity : JSON.stringify(identity)
201
+ if (seen.has(key)) {
202
+ return {
203
+ path: 'credits',
204
+ message: `Identity appears in credits with more than one role: ${seen.get(key)} + ${cr.type}`
205
+ }
206
+ }
207
+ seen.set(key, cr.type)
208
+ }
209
+ return null
210
+ }
211
+ },
212
+
213
+ // Severity / CVSS
214
+ {
215
+ id: 'metrics-present',
216
+ severity: 'warning',
217
+ // MITRE doesn't require a metrics block, but every published OpenJS
218
+ // CVE carries at least one (v3.0, v3.1 or v4.0). Without metrics the
219
+ // record looks half-finished to consumers parsing NVD/OSV.
220
+ tier: 'openjs-extra',
221
+ check (c) {
222
+ const metrics = c?.metrics ?? []
223
+ const hasCvss = getCvssV3(c) != null || metrics.some(m => m?.cvssV4_0?.vectorString)
224
+ if (hasCvss) return null
225
+ return {
226
+ path: 'metrics',
227
+ message: 'No CVSS metric present. Add at least one cvssV3_1, cvssV3_0 or cvssV4_0 block.'
228
+ }
229
+ }
230
+ },
231
+ {
232
+ id: 'cvss-vector-parseable',
233
+ severity: 'blocker',
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.
238
+ tier: 'mitre-common',
239
+ check (c) {
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}` }
250
+ }
251
+ const parts = vec.slice(prefix.length).split('/')
252
+ if (parts.some(p => !/^[A-Z]+:[A-Z]+$/.test(p))) {
253
+ return { path, message: 'CVSS vector has malformed components' }
254
+ }
255
+ return null
256
+ }
257
+ },
258
+ {
259
+ id: 'cvss-score-matches-band',
260
+ severity: 'warning',
261
+ // MITRE's bundled CVSS v3.x schemas have an `anyOf` that pairs each score
262
+ // band (none/low/medium/high/critical) with the matching baseSeverity
263
+ // enum, so a band mismatch is a schema violation upstream too.
264
+ tier: 'mitre-common',
265
+ check (c) {
266
+ const score = getCvssScore(c)
267
+ const cvss = getCvssV3(c)
268
+ const band = cvss?.block.baseSeverity
269
+ if (score == null || !band) return null
270
+ const expected = expectedSeverityBand(score)
271
+ if (expected !== band) {
272
+ return {
273
+ path: `metrics[].${cvss.key}`,
274
+ message: `baseSeverity is '${band}' but the score ${score} falls in band '${expected}'`
275
+ }
276
+ }
277
+ return null
278
+ }
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
+ },
307
+
308
+ // CWE
309
+ {
310
+ id: 'cwe-id-format',
311
+ severity: 'blocker',
312
+ // MITRE constrains cweId with pattern ^CWE-[1-9][0-9]*$ (actually a bit
313
+ // stricter than this rule, which would accept CWE-0). Either way, any
314
+ // value this rule rejects also fails MITRE.
315
+ tier: 'mitre-common',
316
+ check (c) {
317
+ for (const pt of c?.problemTypes ?? []) {
318
+ for (const d of pt?.descriptions ?? []) {
319
+ if (d?.cweId && !/^CWE-\d+$/.test(d.cweId)) {
320
+ return { path: 'problemTypes[].descriptions[].cweId', message: `'${d.cweId}' is not a valid CWE id` }
321
+ }
322
+ }
323
+ }
324
+ return null
325
+ }
326
+ },
327
+
328
+ // Affected
329
+ {
330
+ id: 'affected-not-empty',
331
+ severity: 'blocker',
332
+ // MITRE: `affected` is required on the container with minItems: 1. Same
333
+ // failure as this rule.
334
+ tier: 'mitre-common',
335
+ check (c) {
336
+ if (!isNonEmptyArray(c?.affected)) {
337
+ return { path: 'affected', message: 'No affected products listed' }
338
+ }
339
+ return null
340
+ }
341
+ },
342
+ {
343
+ id: 'affected-product-present',
344
+ severity: 'blocker',
345
+ // MITRE accepts EITHER (vendor + product) OR (collectionURL + packageName)
346
+ // via anyOf. This rule only allows the first combo, so it rejects
347
+ // schema-valid entries that use collectionURL + packageName. Editorial
348
+ // preference for the vendor/product convention.
349
+ tier: 'openjs-extra',
350
+ check (c) {
351
+ for (const a of c?.affected ?? []) {
352
+ if (!isString(a?.vendor) || !isString(a?.product)) {
353
+ return { path: 'affected[]', message: 'Each affected entry needs vendor + product' }
354
+ }
355
+ }
356
+ return null
357
+ }
358
+ },
359
+ {
360
+ id: 'affected-versions-present',
361
+ severity: 'blocker',
362
+ // MITRE accepts EITHER `versions` OR `defaultStatus` per the product
363
+ // anyOf, so an entry with only `defaultStatus` is schema-valid. This rule
364
+ // insists on an explicit versions list; that's editorial.
365
+ tier: 'openjs-extra',
366
+ check (c) {
367
+ for (const a of c?.affected ?? []) {
368
+ if (!isNonEmptyArray(a?.versions)) {
369
+ return { path: 'affected[].versions', message: 'Each affected entry needs at least one version' }
370
+ }
371
+ }
372
+ return null
373
+ }
374
+ },
375
+ {
376
+ id: 'less-than-on-same-major',
377
+ severity: 'warning',
378
+ // MITRE has no opinion on the semantic relationship between `version`
379
+ // and `lessThan`. Editorial heuristic for catching typo-style mistakes.
380
+ tier: 'openjs-extra',
381
+ check (c) {
382
+ for (const a of c?.affected ?? []) {
383
+ for (const v of a?.versions ?? []) {
384
+ if (v?.version && v?.lessThan) {
385
+ // `version: '0'` is the "no lower bound" sentinel the
386
+ // converter emits for shapes like "< 2.1.1". The cross-
387
+ // major check doesn't apply here — the bound is intentional.
388
+ if (v.version === '0') continue
389
+ const lhsMajor = String(v.version).split('.')[0]
390
+ const rhsMajor = String(v.lessThan).split('.')[0]
391
+ if (lhsMajor && rhsMajor && lhsMajor !== rhsMajor) {
392
+ return {
393
+ path: 'affected[].versions[]',
394
+ message: `version '${v.version}' and lessThan '${v.lessThan}' span different majors`
395
+ }
396
+ }
397
+ }
398
+ }
399
+ }
400
+ return null
401
+ }
402
+ },
403
+
404
+ // References
405
+ {
406
+ id: 'references-present',
407
+ severity: 'warning',
408
+ // MITRE: `references` is required on the container with minItems: 1.
409
+ // Same failure as this rule.
410
+ tier: 'mitre-common',
411
+ check (c) {
412
+ if (!isNonEmptyArray(c?.references)) {
413
+ return { path: 'references', message: 'No references provided' }
414
+ }
415
+ return null
416
+ }
417
+ },
418
+ {
419
+ id: 'references-https',
420
+ severity: 'warning',
421
+ // MITRE's `uriType` accepts any URI scheme (http, https, ftp, ...).
422
+ // Editorial preference for transport-secure links.
423
+ tier: 'openjs-extra',
424
+ check (c) {
425
+ for (const r of c?.references ?? []) {
426
+ if (r?.url && !/^https:\/\//.test(r.url)) {
427
+ return { path: 'references[].url', message: `Non-HTTPS reference: '${r.url}'` }
428
+ }
429
+ }
430
+ return null
431
+ }
432
+ }
433
+ ]
@@ -0,0 +1,99 @@
1
+ // MITRE schema validation for v5.2 CVE Records.
2
+ //
3
+ // Wraps ajv around the official CVE Record Format schema from the CVE
4
+ // Project at https://github.com/CVEProject/cve-schema. The schema lives
5
+ // in this repo at `lib/review/schemas/cve-record-format.json` and is
6
+ // refreshed by hand when MITRE publishes a new revision.
7
+ //
8
+ // Two notes on the integration:
9
+ //
10
+ // 1. The schema is JSON Schema draft-07. ajv supports draft-07 natively
11
+ // via `Ajv` (not `Ajv2019`/`Ajv2020`). We layer `ajv-formats` on top
12
+ // so the `format: 'uri'`/`format: 'date'` keywords MITRE uses are
13
+ // actually validated rather than silently ignored.
14
+ //
15
+ // 2. The CVE Project schemas mix drafts (the main file is draft-07, the
16
+ // CVSS v2.0 import is draft-04). We pre-normalize the CVSS v2/v3.0/
17
+ // v3.1 files in `lib/review/schemas/imports/cvss/` to use `$id`
18
+ // instead of the draft-04 `id` keyword so ajv can compile them; the
19
+ // normalization is documented in BACKLOG (rebundle step).
20
+
21
+ import { createRequire } from 'node:module'
22
+ import Ajv from 'ajv'
23
+ import addFormats from 'ajv-formats'
24
+
25
+ // `createRequire` lets us load the bundled JSON schemas without the ESM
26
+ // `import ... with { type: 'json' }` attribute syntax, which standard's
27
+ // parser does not yet support.
28
+ const require = createRequire(import.meta.url)
29
+ const schemaFile = require('./schemas/cve-record-format.json')
30
+ const adpTags = require('./schemas/tags/adp-tags.json')
31
+ const cnaTags = require('./schemas/tags/cna-tags.json')
32
+ const referenceTags = require('./schemas/tags/reference-tags.json')
33
+ const cvssV2 = require('./schemas/imports/cvss/cvss-v2.0.json')
34
+ const cvssV30 = require('./schemas/imports/cvss/cvss-v3.0.json')
35
+ const cvssV31 = require('./schemas/imports/cvss/cvss-v3.1.json')
36
+ const cvssV4 = require('./schemas/imports/cvss/cvss-v4.0.json')
37
+
38
+ // OpenJS Foundation CNA orgId in MITRE's directory. Source: any of the
39
+ // CVEs the foundation has already published, e.g. `cves/CVE-2026-*.json`
40
+ // in the security-work repo each carry this orgId in their
41
+ // `containers.cna.providerMetadata.orgId`.
42
+ export const OPENJS_CNA_ORG_ID = 'ce714d77-add3-4f53-aff5-83d477b104bb'
43
+
44
+ const ajv = new Ajv({
45
+ allErrors: true,
46
+ // `validateSchema: false` because the bundled MITRE schemas span
47
+ // multiple JSON Schema drafts and we don't ship the older meta-
48
+ // schemas. We trust MITRE's schemas; the job here is to validate
49
+ // containers AGAINST them, not validate the schemas themselves.
50
+ validateSchema: false,
51
+ // `strict: false` because MITRE's schemas use a custom `license`
52
+ // keyword and several definitions omit explicit `type: 'object'`
53
+ // declarations. ajv's strict mode would refuse to compile them.
54
+ // The actual data-validation behavior (the thing operators rely on)
55
+ // is unaffected — strict mode is about strictness of the SCHEMAS,
56
+ // not strictness of the validation against them. Where strict
57
+ // matters most for us — `format: 'uri'` / `format: 'date'` — is
58
+ // covered by ajv-formats below.
59
+ strict: false
60
+ })
61
+
62
+ // ajv-formats teaches ajv about the `uri`, `date`, `email`, `uuid` etc.
63
+ // formats that MITRE's schemas use. Without this they'd parse-but-not-
64
+ // actually-validate.
65
+ addFormats(ajv)
66
+
67
+ // Register every companion schema under the `file:` URI that the main
68
+ // CVE_Record_Format.json file uses to reference them. The CVE Project's
69
+ // schemas use bare `file:tags/...` and `file:imports/...` refs because
70
+ // the canonical layout assumes a checked-out copy of the schema repo.
71
+ ajv.addSchema(adpTags, 'file:tags/adp-tags.json')
72
+ ajv.addSchema(cnaTags, 'file:tags/cna-tags.json')
73
+ ajv.addSchema(referenceTags, 'file:tags/reference-tags.json')
74
+ ajv.addSchema(cvssV2, 'file:imports/cvss/cvss-v2.0.json')
75
+ ajv.addSchema(cvssV30, 'file:imports/cvss/cvss-v3.0.json')
76
+ ajv.addSchema(cvssV31, 'file:imports/cvss/cvss-v3.1.json')
77
+ ajv.addSchema(cvssV4, 'file:imports/cvss/cvss-v4.0.json')
78
+
79
+ // Compile the schema's top-level oneOf (Published-Record / Rejected-
80
+ // Record). This validates the WHOLE Record in one pass — `dataType`,
81
+ // `dataVersion`, `cveMetadata`, and `containers.cna` — against the same
82
+ // shape Vulnogram and every published OpenJS CVE on MITRE use.
83
+ const validator = ajv.compile(schemaFile)
84
+
85
+ // Validate a full CVE Record against MITRE's schema. Returns an array of
86
+ // { path, message } per violation. Empty array means clean.
87
+ export function validateAgainstMitreSchema (record) {
88
+ if (!record || typeof record !== 'object') {
89
+ return [{ path: '/', message: 'record is not an object' }]
90
+ }
91
+ const ok = validator(record)
92
+ if (ok) return []
93
+ return (validator.errors || []).map((err) => ({
94
+ path: err.instancePath || '/',
95
+ message: err.message + (err.params && Object.keys(err.params).length
96
+ ? ' ' + JSON.stringify(err.params)
97
+ : '')
98
+ }))
99
+ }