@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/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
+ }
@@ -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, buildDescription, 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.
@@ -153,63 +149,15 @@ function stripReferencesSection (md) {
153
149
  return refIdx >= 0 ? normalized.slice(0, refIdx).trimEnd() : normalized
154
150
  }
155
151
 
156
- // Strip Markdown formatting down to readable plain text. Generic — no
157
- // hard-coded section names beyond the References block (which is dropped
158
- // upstream by stripReferencesSection because cna.references[] is its
159
- // structured home). Headers become "Label:" lines (matches the published
160
- // OpenJS CVE convention), emphasis/code/links collapse to their text
161
- // content, list bullets drop the bullet marker, blockquote markers drop.
162
- function stripMarkdown (md) {
163
- if (typeof md !== 'string') return ''
164
- // Normalize line endings — GitHub serves CRLF, every regex below uses
165
- // `\n` as the line boundary.
166
- let text = md.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
167
- // Headers → "Label:" (any ATX heading, level 1–6, generic). We use
168
- // [ \t]* (not \s*) for leading/trailing whitespace so we don't eat the
169
- // preceding/following blank lines — those are what visually separate
170
- // the heading label from neighboring paragraphs.
171
- text = text.replace(/^[ \t]*#{1,6}[ \t]+(.+?)[ \t]*$/gm, '$1:')
172
- // **bold** / __bold__ / *italic* / _italic_ → inner text
173
- text = text.replace(/\*\*(.+?)\*\*/g, '$1')
174
- text = text.replace(/__(.+?)__/g, '$1')
175
- text = text.replace(/(?<![*\w])\*(?!\s)(.+?)(?<!\s)\*(?![*\w])/g, '$1')
176
- text = text.replace(/(?<![_\w])_(?!\s)(.+?)(?<!\s)_(?![_\w])/g, '$1')
177
- // `inline code` → inner text
178
- text = text.replace(/`(.+?)`/g, '$1')
179
- // [link](url) → link
180
- text = text.replace(/\[(.+?)\]\([^)]+\)/g, '$1')
181
- // Bullet / ordered-list markers at line start → strip the marker
182
- text = text.replace(/^\s*(?:[-*+]|\d+\.)\s+/gm, '')
183
- // Blockquote markers
184
- text = text.replace(/^\s*>\s?/gm, '')
185
- // Collapse 3+ blank lines to a single blank line.
186
- text = text.replace(/\n{3,}/g, '\n\n')
187
- return text.trim()
188
- }
189
-
190
152
  function buildDescriptions (advisory) {
191
- // GHSA advisory descriptions are authored in Markdown. MITRE's
192
- // descriptions[0].value field is plain-text in every published OpenJS
193
- // CVE on record, so we strip the Markdown for the value and put the
194
- // (de-duplicated) original Markdown into supportingMedia[0]. The
195
- // References block is removed from both representations because its
153
+ // GHSA advisory descriptions are authored in Markdown; buildDescription
154
+ // puts the plain text in `value` and keeps the Markdown in
155
+ // supportingMedia. The References block is removed first because its
196
156
  // URLs already live in cna.references[] as structured data.
197
157
  const original = (advisory.description ?? '').trim() || (advisory.summary ?? '').trim()
198
158
  if (!original) return []
199
- const raw = stripReferencesSection(original).trim()
200
- if (!raw) return []
201
- const plain = stripMarkdown(raw) || raw
202
- const description = {
203
- lang: 'en',
204
- value: plain
205
- }
206
- // Only attach supportingMedia when the raw Markdown still carries
207
- // formatting after the References strip. If plain === raw the markdown
208
- // form has nothing extra to offer.
209
- if (raw !== plain) {
210
- description.supportingMedia = [{ type: 'text/markdown', base64: false, value: raw }]
211
- }
212
- return [description]
159
+ const description = buildDescription(stripReferencesSection(original))
160
+ return description ? [description] : []
213
161
  }
214
162
 
215
163
  function buildAffected (advisory) {
@@ -234,26 +182,9 @@ function buildAffected (advisory) {
234
182
  function buildProblemTypes (advisory) {
235
183
  const cwes = advisory.cwes ?? []
236
184
  if (cwes.length === 0) return undefined
237
- return [{
238
- descriptions: cwes.map((cwe) => ({
239
- lang: 'en',
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'
185
+ // GHSA lists every CWE of the advisory; MITRE groups them as the
186
+ // descriptions of one problemType entry.
187
+ return [buildProblemType(cwes.map((cwe) => ({ cweId: cwe.cwe_id, name: cwe.name })))]
257
188
  }
258
189
 
259
190
  function buildMetrics (advisory) {
@@ -269,7 +200,9 @@ function buildMetrics (advisory) {
269
200
  // Emit a `cvssV3_1` block whenever a v3.1 vector exists, and a
270
201
  // `cvssV4_0` block whenever a v4 vector exists. Both can appear; we
271
202
  // emit each in its own metric entry so the MITRE schema (which gates
272
- // additional properties on cvssVx blocks) is happy.
203
+ // additional properties on cvssVx blocks) is happy. GHSA only issues
204
+ // 3.1 and 4.0 vectors; `buildCvssMetric` computes a missing v3.1 score
205
+ // and passes through a vector it cannot parse for the reviewer to flag.
273
206
  const metrics = []
274
207
  const v3Vector = advisory.cvss?.vector_string ??
275
208
  advisory.cvss_v3?.vector_string ??
@@ -278,32 +211,14 @@ function buildMetrics (advisory) {
278
211
  advisory.cvss_v3?.score ??
279
212
  advisory.cvss_severities?.cvss_v3?.score
280
213
  if (v3Vector) {
281
- const v3Severity = severityForScore(v3Score)
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
- })
214
+ metrics.push(buildCvssMetric(v3Vector, { baseScore: v3Score }))
291
215
  }
292
216
  const v4Vector = advisory.cvss_v4?.vector_string ??
293
217
  advisory.cvss_severities?.cvss_v4?.vector_string
294
218
  const v4Score = advisory.cvss_v4?.score ??
295
219
  advisory.cvss_severities?.cvss_v4?.score
296
220
  if (v4Vector) {
297
- const v4Severity = severityForScore(v4Score)
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
- })
221
+ metrics.push(buildCvssMetric(v4Vector, { baseScore: v4Score }))
307
222
  }
308
223
  return metrics.length > 0 ? metrics : undefined
309
224
  }
@@ -458,28 +373,22 @@ export function mapAdvisoryToRecord (advisory, {
458
373
  }
459
374
  const resolvedCveId = cveId ?? advisoryCveId
460
375
 
461
- const cna = {
462
- providerMetadata: { orgId: assignerOrgId },
463
- title: advisory.summary,
464
- descriptions: buildDescriptions(advisory),
465
- affected: buildAffected(advisory),
466
- problemTypes: buildProblemTypes(advisory),
467
- metrics: buildMetrics(advisory),
468
- credits: buildCredits(advisory),
469
- references: buildReferences(advisory),
470
- // x_ prefixes are MITRE's extension namespace. x_generator is the
471
- // canonical place to surface "this container was produced by …".
472
- x_generator: { engine: engineId }
473
- }
474
-
475
- return {
476
- dataType: 'CVE_RECORD',
477
- dataVersion: DATA_VERSION,
478
- ...(resolvedCveId
479
- ? { cveMetadata: { cveId: resolvedCveId, assignerOrgId, state: 'PUBLISHED' } }
480
- : {}),
481
- containers: { cna }
482
- }
376
+ // The envelope, providerMetadata and x_generator come from
377
+ // buildRecordEnvelope so every converter emits them the same way.
378
+ return buildRecordEnvelope({
379
+ cveId: resolvedCveId,
380
+ assignerOrgId,
381
+ engineId,
382
+ cna: {
383
+ title: advisory.summary,
384
+ descriptions: buildDescriptions(advisory),
385
+ affected: buildAffected(advisory),
386
+ problemTypes: buildProblemTypes(advisory),
387
+ metrics: buildMetrics(advisory),
388
+ credits: buildCredits(advisory),
389
+ references: buildReferences(advisory)
390
+ }
391
+ })
483
392
  }
484
393
 
485
394
  // 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
+ }
@@ -0,0 +1,42 @@
1
+ // HackerOne weakness ids -> CWE.
2
+ //
3
+ // HackerOne identifies a report's weakness by its own numeric id, which is
4
+ // what its REST API exposes as `weakness_id` and what projects end up
5
+ // storing. It is not a CWE number: weakness 26 is "Improper Access Control"
6
+ // (CWE-284), 48 is "Uncontrolled Resource Consumption" (CWE-400). The CWE
7
+ // is a separate attribute of the weakness, and HackerOne's taxonomy is the
8
+ // same for every program, so the mapping ships here as data
9
+ // (./weaknesses.json, regenerated with scripts/build-hackerone-weaknesses.js).
10
+
11
+ import { createRequire } from 'node:module'
12
+ import { buildProblemType } from '../record.js'
13
+
14
+ const require = createRequire(import.meta.url)
15
+ const { weaknesses, _generated: generated } = require('./weaknesses.json')
16
+
17
+ export const HACKERONE_WEAKNESSES_GENERATED = generated
18
+
19
+ /**
20
+ * Look a HackerOne weakness id up. Accepts a number or a string.
21
+ *
22
+ * Returns `{ cwe, name }` where `cwe` is `CWE-<number>` or `null` for a
23
+ * weakness HackerOne maps to no CWE (CAPEC entries), or `null` when the id
24
+ * is unknown.
25
+ */
26
+ export function hackerOneWeakness (id) {
27
+ const key = String(id ?? '').trim()
28
+ if (!/^\d+$/.test(key)) return null
29
+ const entry = weaknesses[key]
30
+ return entry ? { cwe: entry.cwe, name: entry.name } : null
31
+ }
32
+
33
+ /**
34
+ * The `problemTypes[]` entry for a HackerOne weakness id, or `null` when
35
+ * the id is unknown or has no CWE. The CWE name on the record is
36
+ * HackerOne's weakness name.
37
+ */
38
+ export function hackerOneWeaknessProblemType (id) {
39
+ const entry = hackerOneWeakness(id)
40
+ if (!entry?.cwe) return null
41
+ return buildProblemType({ cweId: entry.cwe, name: entry.name })
42
+ }