@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.
- package/LICENSE +21 -0
- package/README.md +312 -2
- package/bin/openjs-cna.js +128 -0
- package/components/config.js +92 -0
- package/components/convert.js +81 -0
- package/components/get.js +35 -0
- package/components/health.js +30 -0
- package/components/list.js +36 -0
- package/components/llm-review-prompt.js +84 -0
- package/components/preview.js +42 -0
- package/components/publish.js +238 -0
- package/components/reserve.js +48 -0
- package/components/review.js +170 -0
- package/components/update.js +104 -0
- package/lib/auth.js +20 -0
- package/lib/batch.js +141 -0
- package/lib/cli.js +81 -0
- package/lib/client.js +244 -0
- package/lib/component-utils.js +74 -0
- package/lib/config.js +121 -0
- package/lib/convert/ghsa.js +450 -0
- package/lib/convert/version-range.js +251 -0
- package/lib/cvss.js +113 -0
- package/lib/errors.js +44 -0
- package/lib/index.js +9 -0
- package/lib/preview.js +145 -0
- package/lib/prompt.js +170 -0
- package/lib/record.js +123 -0
- package/lib/review/index.js +78 -0
- package/lib/review/rules.js +433 -0
- package/lib/review/schema.js +99 -0
- package/lib/review/schemas/cve-record-format.json +1362 -0
- package/lib/review/schemas/imports/cvss/cvss-v2.0.json +195 -0
- package/lib/review/schemas/imports/cvss/cvss-v3.0.json +577 -0
- package/lib/review/schemas/imports/cvss/cvss-v3.1.json +577 -0
- package/lib/review/schemas/imports/cvss/cvss-v4.0.json +784 -0
- package/lib/review/schemas/tags/adp-tags.json +7 -0
- package/lib/review/schemas/tags/cna-tags.json +7 -0
- package/lib/review/schemas/tags/reference-tags.json +27 -0
- 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
|
+
}
|