@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/README.md +137 -1
- package/components/publish.js +135 -30
- package/components/review.js +67 -20
- package/lib/batch.js +141 -0
- package/lib/convert/ghsa.js +30 -121
- package/lib/cvss.js +113 -0
- package/lib/hackerone/index.js +42 -0
- package/lib/hackerone/weaknesses.json +6550 -0
- package/lib/preview.js +15 -14
- package/lib/prompt.js +12 -7
- package/lib/record.js +173 -0
- package/lib/review/rules.js +72 -21
- package/package.json +7 -3
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
|
+
}
|
package/lib/convert/ghsa.js
CHANGED
|
@@ -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
|
|
192
|
-
//
|
|
193
|
-
//
|
|
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
|
|
200
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
+
}
|