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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
- if (m?.cvssV3_1?.vectorString) {
26
- return {
27
- version: '3.1',
28
- score: m.cvssV3_1.baseScore,
29
- severity: m.cvssV3_1.baseSeverity,
30
- vector: m.cvssV3_1.vectorString
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
- if (m?.cvssV3_1?.vectorString) {
49
- const { vectorString, baseScore, baseSeverity } = m.cvssV3_1
50
- return { label: 'CVSS 3.1', body: `${baseScore ?? '?'} ${baseSeverity ?? ''} (${vectorString})`.replace(/\s+/g, ' ').trim() }
51
- }
52
- if (m?.cvssV4_0?.vectorString) {
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,173 @@
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 description, the
3
+ // CVSS metric block, the problem type and the record envelope. Converters
4
+ // (GHSA here, release trackers in downstream projects) build the container
5
+ // fields they know about and use these so the format-level details are
6
+ // written once.
7
+
8
+ import { USER_AGENT } from './client.js'
9
+ import { calculateBaseScore, severityFromScore } from './cvss.js'
10
+ import { OPENJS_CNA_ORG_ID } from './review/schema.js'
11
+
12
+ // MITRE schema version we emit on every Record. Matches what every CVE
13
+ // published by the OpenJS CNA carries today.
14
+ export const DATA_VERSION = '5.2'
15
+
16
+ /**
17
+ * Strip Markdown formatting down to readable plain text. Generic: headers
18
+ * become "Label:" lines (the published OpenJS CVE convention), emphasis,
19
+ * code spans and links collapse to their text, list bullets and blockquote
20
+ * markers drop, line endings normalise to `\n`.
21
+ */
22
+ export function stripMarkdown (md) {
23
+ if (typeof md !== 'string') return ''
24
+ let text = md.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
25
+ // Headers → "Label:" (any ATX heading, level 1–6). [ \t]* rather than
26
+ // \s* so the blank lines around the heading survive.
27
+ text = text.replace(/^[ \t]*#{1,6}[ \t]+(.+?)[ \t]*$/gm, '$1:')
28
+ // **bold** / __bold__ / *italic* / _italic_ → inner text
29
+ text = text.replace(/\*\*(.+?)\*\*/g, '$1')
30
+ text = text.replace(/__(.+?)__/g, '$1')
31
+ text = text.replace(/(?<![*\w])\*(?!\s)(.+?)(?<!\s)\*(?![*\w])/g, '$1')
32
+ text = text.replace(/(?<![_\w])_(?!\s)(.+?)(?<!\s)_(?![_\w])/g, '$1')
33
+ // `inline code` → inner text
34
+ text = text.replace(/`(.+?)`/g, '$1')
35
+ // [link](url) → link
36
+ text = text.replace(/\[(.+?)\]\([^)]+\)/g, '$1')
37
+ // Bullet / ordered-list markers at line start → strip the marker
38
+ text = text.replace(/^\s*(?:[-*+]|\d+\.)\s+/gm, '')
39
+ // Blockquote markers
40
+ text = text.replace(/^\s*>\s?/gm, '')
41
+ // Collapse 3+ blank lines to a single blank line.
42
+ text = text.replace(/\n{3,}/g, '\n\n')
43
+ return text.trim()
44
+ }
45
+
46
+ /**
47
+ * Build one `descriptions[]` entry from text that may carry Markdown.
48
+ *
49
+ * MITRE's `value` is plain text (NVD and cve.org show it verbatim), so the
50
+ * Markdown is stripped for `value` and the original is kept in
51
+ * `supportingMedia` as `text/markdown`, only when the two differ. Returns
52
+ * `null` for empty input so callers can leave `descriptions` out.
53
+ */
54
+ export function buildDescription (text, { lang = 'en' } = {}) {
55
+ const raw = String(text ?? '').replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim()
56
+ if (!raw) return null
57
+ const plain = stripMarkdown(raw) || raw
58
+ const description = { lang, value: plain }
59
+ if (raw !== plain) {
60
+ description.supportingMedia = [{ type: 'text/markdown', base64: false, value: raw }]
61
+ }
62
+ return description
63
+ }
64
+
65
+ // Metric block key per CVSS version prefix. v2 is not emitted by anything
66
+ // the foundation publishes, so it is left out on purpose.
67
+ const METRIC_KEYS = new Map([
68
+ ['3.0', 'cvssV3_0'],
69
+ ['3.1', 'cvssV3_1'],
70
+ ['4.0', 'cvssV4_0']
71
+ ])
72
+
73
+ /**
74
+ * Build one `metrics[]` entry from a CVSS vector string.
75
+ *
76
+ * The block key (`cvssV3_0`, `cvssV3_1`, `cvssV4_0`) follows the vector's
77
+ * `CVSS:<version>/` prefix, which is what MITRE validates each block
78
+ * against. `baseScore` is taken from `options.baseScore` when given and
79
+ * computed from the vector otherwise (v3.x only; v4.0 has no bundled
80
+ * calculator). `baseSeverity` is always derived from the final score.
81
+ *
82
+ * A vector with an unknown prefix or one the calculator rejects is passed
83
+ * through under the closest key with no score, so the reviewer can report
84
+ * it instead of this helper guessing.
85
+ */
86
+ export function buildCvssMetric (vectorString, { baseScore } = {}) {
87
+ const vector = String(vectorString ?? '').trim()
88
+ const version = vector.match(/^CVSS:(\d\.\d)\//)?.[1]
89
+ const key = METRIC_KEYS.get(version) ?? 'cvssV3_1'
90
+
91
+ let score = typeof baseScore === 'number' ? baseScore : undefined
92
+ if (score === undefined && (version === '3.0' || version === '3.1')) {
93
+ try {
94
+ score = calculateBaseScore(vector).baseScore
95
+ } catch {}
96
+ }
97
+
98
+ return {
99
+ format: 'CVSS',
100
+ [key]: {
101
+ version: version ?? '3.1',
102
+ vectorString: vector,
103
+ ...(score !== undefined ? { baseScore: score, baseSeverity: severityFromScore(score) } : {})
104
+ }
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Build one `problemTypes[]` entry from one or more CWE references.
110
+ *
111
+ * Accepts a single `{ cweId, name? }` or an array of them. `cweId` may be
112
+ * given as `400`, `CWE-400` or `cwe-400` and is normalised to the
113
+ * `CWE-<number>` form MITRE's pattern requires; anything else throws so
114
+ * the mistake surfaces at build time. `description` is required by MITRE,
115
+ * so it is the CWE name when known and the id otherwise.
116
+ */
117
+ export function buildProblemType (cwes) {
118
+ const list = Array.isArray(cwes) ? cwes : [cwes]
119
+ if (list.length === 0) {
120
+ throw new TypeError('buildProblemType: at least one CWE reference is required')
121
+ }
122
+ return {
123
+ descriptions: list.map(({ cweId, name } = {}) => {
124
+ const id = String(cweId ?? '').trim().match(/^(?:CWE-)?([1-9]\d*)$/i)?.[1]
125
+ if (!id) {
126
+ throw new TypeError(`buildProblemType: '${cweId}' is not a CWE id (expected CWE-<number>)`)
127
+ }
128
+ const text = String(name ?? '').trim()
129
+ return {
130
+ lang: 'en',
131
+ type: 'CWE',
132
+ cweId: `CWE-${id}`,
133
+ description: text || `CWE-${id}`
134
+ }
135
+ })
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Wrap a CNA container in the v5.2 Record envelope.
141
+ *
142
+ * Sets `providerMetadata.orgId` and `x_generator.engine` on the container
143
+ * when the caller has not, so no consumer needs to know the OpenJS CNA org
144
+ * id or the generator string. `cveMetadata` is emitted only when `cveId` is
145
+ * given; without it the publisher's CLI argument is the sole source of
146
+ * truth for the id.
147
+ */
148
+ export function buildRecordEnvelope ({
149
+ cna,
150
+ cveId,
151
+ assignerOrgId = OPENJS_CNA_ORG_ID,
152
+ engineId = USER_AGENT,
153
+ dataVersion = DATA_VERSION
154
+ } = {}) {
155
+ if (!cna || typeof cna !== 'object') {
156
+ throw new TypeError('buildRecordEnvelope: `cna` must be the CNA container object')
157
+ }
158
+ const container = {
159
+ providerMetadata: { orgId: assignerOrgId },
160
+ ...cna,
161
+ // x_ prefixes are MITRE's extension namespace. x_generator is the
162
+ // canonical place to surface "this container was produced by …".
163
+ x_generator: cna.x_generator ?? { engine: engineId }
164
+ }
165
+ return {
166
+ dataType: 'CVE_RECORD',
167
+ dataVersion,
168
+ ...(cveId
169
+ ? { cveMetadata: { cveId, assignerOrgId, state: 'PUBLISHED' } }
170
+ : {}),
171
+ containers: { cna: container }
172
+ }
173
+ }
@@ -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
- function getCvssVector (c) {
30
- return c?.metrics?.find(m => m?.cvssV3_1?.vectorString)?.cvssV3_1?.vectorString
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
- if (typeof m?.cvssV3_1?.baseScore === 'number') return m.cvssV3_1.baseScore
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?.cvssV3_1?.vectorString || m?.cvssV4_0?.vectorString)
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.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.
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 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/' }
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('CVSS:3.1/'.length).split('/')
251
+ const parts = vec.slice(prefix.length).split('/')
229
252
  if (parts.some(p => !/^[A-Z]+:[A-Z]+$/.test(p))) {
230
- return { path: 'metrics[].cvssV3_1.vectorString', message: 'CVSS vector has malformed components' }
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.1 schema has an `anyOf` that pairs each score
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 band = c?.metrics?.find(m => m?.cvssV3_1)?.cvssV3_1?.baseSeverity
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: 'metrics[].cvssV3_1',
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.1",
3
+ "version": "1.0.0-alpha.4",
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,18 @@
11
11
  "node": ">=22.0.0"
12
12
  },
13
13
  "bin": {
14
- "openjs-cna-experimental": "./bin/openjs-cna.js"
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",
25
+ "./hackerone": "./lib/hackerone/index.js"
22
26
  },
23
27
  "files": [
24
28
  "bin/",