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

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.
@@ -0,0 +1,382 @@
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
+ const isString = (v) => typeof v === 'string'
20
+ const isNonEmptyArray = (v) => Array.isArray(v) && v.length > 0
21
+
22
+ // ----- Helpers --------------------------------------------------------------
23
+
24
+ function getDescription (c) {
25
+ return c?.descriptions?.find(d => d?.lang === 'en') ??
26
+ c?.descriptions?.[0]
27
+ }
28
+
29
+ function getCvssVector (c) {
30
+ return c?.metrics?.find(m => m?.cvssV3_1?.vectorString)?.cvssV3_1?.vectorString
31
+ }
32
+
33
+ function getCvssScore (c) {
34
+ for (const m of c?.metrics ?? []) {
35
+ if (typeof m?.cvssV3_1?.baseScore === 'number') return m.cvssV3_1.baseScore
36
+ }
37
+ return null
38
+ }
39
+
40
+ function expectedSeverityBand (score) {
41
+ if (score >= 9.0) return 'CRITICAL'
42
+ if (score >= 7.0) return 'HIGH'
43
+ if (score >= 4.0) return 'MEDIUM'
44
+ if (score >= 0.1) return 'LOW'
45
+ return 'NONE'
46
+ }
47
+
48
+ // ----- Rules ----------------------------------------------------------------
49
+ //
50
+ // The MITRE schema check (in ./schema.js) runs before these rules and
51
+ // handles structural concerns: required fields, type shape, CWE/CVSS id
52
+ // format, version pairings. The rules below cover the editorial / style
53
+ // dimensions schema validation can't enforce.
54
+
55
+ export const RULES = [
56
+ // Descriptions
57
+ {
58
+ id: 'description-present',
59
+ severity: 'blocker',
60
+ // MITRE: `descriptions` is required on the container, the array has
61
+ // minItems: 1, and `contains: englishLanguageDescription` requires at
62
+ // least one en-tagged entry. Same failure as this rule.
63
+ tier: 'mitre-common',
64
+ check (c) {
65
+ if (!isNonEmptyArray(c?.descriptions)) {
66
+ return { path: 'descriptions', message: 'No descriptions provided' }
67
+ }
68
+ const en = getDescription(c)
69
+ if (!en || !isString(en.value)) {
70
+ return { path: 'descriptions[lang=en]', message: 'No English description provided' }
71
+ }
72
+ return null
73
+ }
74
+ },
75
+ {
76
+ id: 'description-not-empty',
77
+ severity: 'blocker',
78
+ // MITRE's `description.value` has minLength: 1, so an empty string is a
79
+ // schema violation. This rule additionally rejects whitespace-only values
80
+ // (e.g. " "), which MITRE would accept. Classified as openjs-extra
81
+ // because the marginal coverage over MITRE is the editorial bit.
82
+ tier: 'openjs-extra',
83
+ check (c) {
84
+ const en = getDescription(c)
85
+ if (!en) return null // covered by description-present
86
+ if (!en.value.trim()) {
87
+ return { path: 'descriptions[lang=en].value', message: 'Description is empty' }
88
+ }
89
+ return null
90
+ }
91
+ },
92
+ {
93
+ id: 'description-length',
94
+ severity: 'warning',
95
+ // MITRE: minLength 1, maxLength 4096. The 200-2000 band here is purely
96
+ // editorial — readable prose, not too long.
97
+ tier: 'openjs-extra',
98
+ check (c) {
99
+ const en = getDescription(c)
100
+ const text = en?.value?.trim() ?? ''
101
+ if (!text) return null
102
+ if (text.length < 200) {
103
+ return {
104
+ path: 'descriptions[lang=en].value',
105
+ message: `Description is ${text.length} chars; recommend at least 200`
106
+ }
107
+ }
108
+ if (text.length > 2000) {
109
+ return {
110
+ path: 'descriptions[lang=en].value',
111
+ message: `Description is ${text.length} chars; recommend at most 2000`
112
+ }
113
+ }
114
+ return null
115
+ }
116
+ },
117
+ // Credits
118
+ {
119
+ id: 'reporter-credit-present',
120
+ severity: 'warning',
121
+ // MITRE makes `credits` itself optional and does not require any
122
+ // particular role to appear. OpenJS prefers a reporter/finder for
123
+ // attribution hygiene but does not strictly require one — some
124
+ // advisories are self-reported by the maintainer team.
125
+ tier: 'openjs-extra',
126
+ check (c) {
127
+ const credits = c?.credits ?? []
128
+ const hasReporter = credits.some(cr =>
129
+ cr?.type === 'reporter' || cr?.type === 'finder'
130
+ )
131
+ if (!hasReporter) {
132
+ return { path: 'credits', message: 'No reporter or finder credit found' }
133
+ }
134
+ return null
135
+ }
136
+ },
137
+ {
138
+ id: 'remediation-credit-present',
139
+ severity: 'warning',
140
+ // OpenJS editorial preference: at least one of the remediation roles
141
+ // (developer / reviewer / verifier) should be credited so consumers
142
+ // can trace who shipped the fix. MITRE does not require any specific
143
+ // role. We accept the MITRE space-separated form AND the GHSA
144
+ // underscore form so hand-written containers pass either way.
145
+ tier: 'openjs-extra',
146
+ check (c) {
147
+ const credits = c?.credits ?? []
148
+ const remediationRoles = new Set([
149
+ 'remediation developer',
150
+ 'remediation_developer',
151
+ 'remediation reviewer',
152
+ 'remediation_reviewer',
153
+ 'remediation verifier',
154
+ 'remediation_verifier'
155
+ ])
156
+ const has = credits.some(cr => remediationRoles.has(cr?.type))
157
+ if (!has) {
158
+ return {
159
+ path: 'credits',
160
+ message: 'No remediation credit found (expected one of: remediation developer / reviewer / verifier)'
161
+ }
162
+ }
163
+ return null
164
+ }
165
+ },
166
+ {
167
+ id: 'no-duplicate-credit-role',
168
+ severity: 'warning',
169
+ // MITRE's `credits` array has uniqueItems, but items with the same
170
+ // identity and different `type` values are NOT equal items, so the schema
171
+ // accepts them. Editorial preference to flag the dupe.
172
+ tier: 'openjs-extra',
173
+ check (c) {
174
+ const seen = new Map()
175
+ for (const cr of c?.credits ?? []) {
176
+ // `value` carries the free-form identity (login, name, etc.).
177
+ // `user` is the UUID-typed field per MITRE; we fall back to it
178
+ // for hand-written containers that prefer that shape.
179
+ const identity = cr?.value ?? cr?.user
180
+ if (!identity) continue
181
+ const key = typeof identity === 'string' ? identity : JSON.stringify(identity)
182
+ if (seen.has(key)) {
183
+ return {
184
+ path: 'credits',
185
+ message: `Identity appears in credits with more than one role: ${seen.get(key)} + ${cr.type}`
186
+ }
187
+ }
188
+ seen.set(key, cr.type)
189
+ }
190
+ return null
191
+ }
192
+ },
193
+
194
+ // Severity / CVSS
195
+ {
196
+ id: 'metrics-present',
197
+ severity: 'warning',
198
+ // 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
200
+ // record looks half-finished to consumers parsing NVD/OSV.
201
+ tier: 'openjs-extra',
202
+ check (c) {
203
+ const metrics = c?.metrics ?? []
204
+ const hasCvss = metrics.some(m => m?.cvssV3_1?.vectorString || m?.cvssV4_0?.vectorString)
205
+ if (hasCvss) return null
206
+ return {
207
+ path: 'metrics',
208
+ message: 'No CVSS metric present. Add at least one cvssV3_1 or cvssV4_0 block.'
209
+ }
210
+ }
211
+ },
212
+ {
213
+ id: 'cvss-vector-parseable',
214
+ severity: 'blocker',
215
+ // MITRE imports the FIRST.org CVSS v3.1 schema, which constrains
216
+ // `vectorString` with a pattern that requires the `CVSS:3.1/` prefix and
217
+ // validates every KEY:VAL component. Same failure as this rule.
218
+ tier: 'mitre-common',
219
+ check (c) {
220
+ const vec = getCvssVector(c)
221
+ if (!vec) return null
222
+ // Minimal CVSS v3.1 sanity check. Full parsing would need an external
223
+ // library; we just verify the prefix and that every component looks
224
+ // like KEY:VAL pairs.
225
+ if (!vec.startsWith('CVSS:3.1/')) {
226
+ return { path: 'metrics[].cvssV3_1.vectorString', message: 'CVSS vector does not start with CVSS:3.1/' }
227
+ }
228
+ const parts = vec.slice('CVSS:3.1/'.length).split('/')
229
+ if (parts.some(p => !/^[A-Z]+:[A-Z]+$/.test(p))) {
230
+ return { path: 'metrics[].cvssV3_1.vectorString', message: 'CVSS vector has malformed components' }
231
+ }
232
+ return null
233
+ }
234
+ },
235
+ {
236
+ id: 'cvss-score-matches-band',
237
+ severity: 'warning',
238
+ // MITRE's bundled CVSS v3.1 schema has an `anyOf` that pairs each score
239
+ // band (none/low/medium/high/critical) with the matching baseSeverity
240
+ // enum, so a band mismatch is a schema violation upstream too.
241
+ tier: 'mitre-common',
242
+ check (c) {
243
+ const score = getCvssScore(c)
244
+ const band = c?.metrics?.find(m => m?.cvssV3_1)?.cvssV3_1?.baseSeverity
245
+ if (score == null || !band) return null
246
+ const expected = expectedSeverityBand(score)
247
+ if (expected !== band) {
248
+ return {
249
+ path: 'metrics[].cvssV3_1',
250
+ message: `baseSeverity is '${band}' but the score ${score} falls in band '${expected}'`
251
+ }
252
+ }
253
+ return null
254
+ }
255
+ },
256
+
257
+ // CWE
258
+ {
259
+ id: 'cwe-id-format',
260
+ severity: 'blocker',
261
+ // MITRE constrains cweId with pattern ^CWE-[1-9][0-9]*$ (actually a bit
262
+ // stricter than this rule, which would accept CWE-0). Either way, any
263
+ // value this rule rejects also fails MITRE.
264
+ tier: 'mitre-common',
265
+ check (c) {
266
+ for (const pt of c?.problemTypes ?? []) {
267
+ for (const d of pt?.descriptions ?? []) {
268
+ if (d?.cweId && !/^CWE-\d+$/.test(d.cweId)) {
269
+ return { path: 'problemTypes[].descriptions[].cweId', message: `'${d.cweId}' is not a valid CWE id` }
270
+ }
271
+ }
272
+ }
273
+ return null
274
+ }
275
+ },
276
+
277
+ // Affected
278
+ {
279
+ id: 'affected-not-empty',
280
+ severity: 'blocker',
281
+ // MITRE: `affected` is required on the container with minItems: 1. Same
282
+ // failure as this rule.
283
+ tier: 'mitre-common',
284
+ check (c) {
285
+ if (!isNonEmptyArray(c?.affected)) {
286
+ return { path: 'affected', message: 'No affected products listed' }
287
+ }
288
+ return null
289
+ }
290
+ },
291
+ {
292
+ id: 'affected-product-present',
293
+ severity: 'blocker',
294
+ // MITRE accepts EITHER (vendor + product) OR (collectionURL + packageName)
295
+ // via anyOf. This rule only allows the first combo, so it rejects
296
+ // schema-valid entries that use collectionURL + packageName. Editorial
297
+ // preference for the vendor/product convention.
298
+ tier: 'openjs-extra',
299
+ check (c) {
300
+ for (const a of c?.affected ?? []) {
301
+ if (!isString(a?.vendor) || !isString(a?.product)) {
302
+ return { path: 'affected[]', message: 'Each affected entry needs vendor + product' }
303
+ }
304
+ }
305
+ return null
306
+ }
307
+ },
308
+ {
309
+ id: 'affected-versions-present',
310
+ severity: 'blocker',
311
+ // MITRE accepts EITHER `versions` OR `defaultStatus` per the product
312
+ // anyOf, so an entry with only `defaultStatus` is schema-valid. This rule
313
+ // insists on an explicit versions list; that's editorial.
314
+ tier: 'openjs-extra',
315
+ check (c) {
316
+ for (const a of c?.affected ?? []) {
317
+ if (!isNonEmptyArray(a?.versions)) {
318
+ return { path: 'affected[].versions', message: 'Each affected entry needs at least one version' }
319
+ }
320
+ }
321
+ return null
322
+ }
323
+ },
324
+ {
325
+ id: 'less-than-on-same-major',
326
+ severity: 'warning',
327
+ // MITRE has no opinion on the semantic relationship between `version`
328
+ // and `lessThan`. Editorial heuristic for catching typo-style mistakes.
329
+ tier: 'openjs-extra',
330
+ check (c) {
331
+ for (const a of c?.affected ?? []) {
332
+ for (const v of a?.versions ?? []) {
333
+ if (v?.version && v?.lessThan) {
334
+ // `version: '0'` is the "no lower bound" sentinel the
335
+ // converter emits for shapes like "< 2.1.1". The cross-
336
+ // major check doesn't apply here — the bound is intentional.
337
+ if (v.version === '0') continue
338
+ const lhsMajor = String(v.version).split('.')[0]
339
+ const rhsMajor = String(v.lessThan).split('.')[0]
340
+ if (lhsMajor && rhsMajor && lhsMajor !== rhsMajor) {
341
+ return {
342
+ path: 'affected[].versions[]',
343
+ message: `version '${v.version}' and lessThan '${v.lessThan}' span different majors`
344
+ }
345
+ }
346
+ }
347
+ }
348
+ }
349
+ return null
350
+ }
351
+ },
352
+
353
+ // References
354
+ {
355
+ id: 'references-present',
356
+ severity: 'warning',
357
+ // MITRE: `references` is required on the container with minItems: 1.
358
+ // Same failure as this rule.
359
+ tier: 'mitre-common',
360
+ check (c) {
361
+ if (!isNonEmptyArray(c?.references)) {
362
+ return { path: 'references', message: 'No references provided' }
363
+ }
364
+ return null
365
+ }
366
+ },
367
+ {
368
+ id: 'references-https',
369
+ severity: 'warning',
370
+ // MITRE's `uriType` accepts any URI scheme (http, https, ftp, ...).
371
+ // Editorial preference for transport-secure links.
372
+ tier: 'openjs-extra',
373
+ check (c) {
374
+ for (const r of c?.references ?? []) {
375
+ if (r?.url && !/^https:\/\//.test(r.url)) {
376
+ return { path: 'references[].url', message: `Non-HTTPS reference: '${r.url}'` }
377
+ }
378
+ }
379
+ return null
380
+ }
381
+ }
382
+ ]
@@ -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
+ }