@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,251 @@
|
|
|
1
|
+
// Parse GHSA-style `vulnerable_version_range` strings into MITRE v5.2
|
|
2
|
+
// `affected[].versions[]` entries.
|
|
3
|
+
//
|
|
4
|
+
// GHSA is permissive: maintainers type freeform range expressions and we
|
|
5
|
+
// see ~60 distinct shapes across the vulnerabilities tracked
|
|
6
|
+
// (see test/fixtures/version-range-matrix.js for the canonical list).
|
|
7
|
+
// MITRE is strict: every entry needs { version, status, versionType } plus
|
|
8
|
+
// at most one of { lessThan, lessThanOrEqual, changes }.
|
|
9
|
+
//
|
|
10
|
+
// Strategy:
|
|
11
|
+
//
|
|
12
|
+
// 1. Split multi-line inputs on `; ` or `&&` first — each segment becomes
|
|
13
|
+
// its own entry in the output array.
|
|
14
|
+
// 2. For each segment, strip incidental noise (package-name prefix,
|
|
15
|
+
// parenthetical annotations, leading/trailing whitespace, leading
|
|
16
|
+
// `v` on tokens like `v5.2.1`).
|
|
17
|
+
// 3. Try a sequence of structural matchers most-specific first: hyphen
|
|
18
|
+
// range, caret range, bounded (lower + upper), single operator,
|
|
19
|
+
// implicit-lower-bound shape, bare semver, commit hash.
|
|
20
|
+
// 4. Unparseable segments fall back to `versionType: 'custom'` carrying
|
|
21
|
+
// the original text, unless we extracted an embedded commit hash —
|
|
22
|
+
// in which case use that.
|
|
23
|
+
//
|
|
24
|
+
// Always emits at least one entry per non-empty input, or [] for empty
|
|
25
|
+
// strings. The function never throws on input.
|
|
26
|
+
|
|
27
|
+
const SEMVER_TOKEN = String.raw`v?\d+(?:\.\d+){0,2}(?:-[\w.]+)?`
|
|
28
|
+
const HASH_TOKEN = '[0-9a-f]{7,40}'
|
|
29
|
+
const COMMIT_HASH = new RegExp(`^${HASH_TOKEN}$`, 'i')
|
|
30
|
+
|
|
31
|
+
const RX = {
|
|
32
|
+
bareSemver: new RegExp(`^${SEMVER_TOKEN}$`),
|
|
33
|
+
rangeHyphen: new RegExp(`^(${SEMVER_TOKEN})\\s*-\\s*(${SEMVER_TOKEN})$`),
|
|
34
|
+
caret: new RegExp(`^\\^(${SEMVER_TOKEN})$`),
|
|
35
|
+
|
|
36
|
+
// Single-operator forms. The optional whitespace lets `<4.0.0` and
|
|
37
|
+
// `< 4.0.0` both match.
|
|
38
|
+
opUpperEx: new RegExp(`^<\\s*(${SEMVER_TOKEN}|${HASH_TOKEN})$`, 'i'),
|
|
39
|
+
opUpperInc: new RegExp(`^(?:<=|=<)\\s*(${SEMVER_TOKEN})$`),
|
|
40
|
+
opLowerEx: new RegExp(`^>\\s*(${SEMVER_TOKEN})$`),
|
|
41
|
+
opLowerInc: new RegExp(`^>=\\s*(${SEMVER_TOKEN})$`),
|
|
42
|
+
opEquals: new RegExp(`^=\\s*(${SEMVER_TOKEN})$`),
|
|
43
|
+
|
|
44
|
+
// Bounded ranges: lower + upper, glued together with comma, whitespace,
|
|
45
|
+
// or nothing. `>=4.0.0, <4.18.0` / `>=4.0.0 <4.18.0` / `>=4.0.0,<4.18.0`
|
|
46
|
+
// / `>=4.0.0<4.18.0` all match.
|
|
47
|
+
bounded: new RegExp(
|
|
48
|
+
`^>=?\\s*(${SEMVER_TOKEN})\\s*(?:,\\s*|\\s+|)\\s*(<=?|=<)\\s*(${SEMVER_TOKEN})$`
|
|
49
|
+
),
|
|
50
|
+
|
|
51
|
+
// Implicit lower bound: "7.0.0 < 7.24.0" — no leading operator, the
|
|
52
|
+
// bare version IS the lower bound.
|
|
53
|
+
implicitBounded: new RegExp(
|
|
54
|
+
`^(${SEMVER_TOKEN})\\s*(<=?|=<)\\s*(${SEMVER_TOKEN})$`
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function stripV (s) {
|
|
59
|
+
return String(s).replace(/^v/, '')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Remove incidental noise from a segment before structural matching:
|
|
63
|
+
// parenthetical annotations, leading package-name prefixes, surrounding
|
|
64
|
+
// whitespace.
|
|
65
|
+
function clean (segment) {
|
|
66
|
+
let s = segment.trim()
|
|
67
|
+
// Strip parenthetical clarifications: "<=5.2.1 (includes all 4.x)".
|
|
68
|
+
s = s.replace(/\s*\([^)]*\)\s*/g, '').trim()
|
|
69
|
+
// Strip package-name prefix: "lodash <= 4.18.1" or "lodash<=4.18.1".
|
|
70
|
+
// We only fire this when the prefix is alphabetic AND followed by an
|
|
71
|
+
// OPERATOR (`<`, `>`, `=`). If we widened to digits we'd shred
|
|
72
|
+
// freeform inputs like "Leangheng2004" into the bare "4".
|
|
73
|
+
s = s.replace(/^[a-z@][\w@/.-]*\s*(?=[<>=])/i, '').trim()
|
|
74
|
+
return s
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Split multi-line inputs on `;` or `&&`. Each segment becomes its own
|
|
78
|
+
// entry in the output.
|
|
79
|
+
function splitMultiline (input) {
|
|
80
|
+
return input
|
|
81
|
+
.split(/\s*(?:;|&&)\s*/)
|
|
82
|
+
.map(s => s.trim())
|
|
83
|
+
.filter(Boolean)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Try to parse a single segment. Returns the MITRE entry if a structural
|
|
87
|
+
// matcher hits, or null if the segment cannot be understood.
|
|
88
|
+
function parseSegment (raw) {
|
|
89
|
+
const segment = clean(raw)
|
|
90
|
+
if (!segment) return null
|
|
91
|
+
|
|
92
|
+
// Wildcards
|
|
93
|
+
if (segment === '*' || /^all$/i.test(segment)) {
|
|
94
|
+
return { version: '0', status: 'affected', versionType: 'semver' }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Hyphen range: "4.0.0 - 4.17.23"
|
|
98
|
+
const hyphenMatch = segment.match(RX.rangeHyphen)
|
|
99
|
+
if (hyphenMatch) {
|
|
100
|
+
return {
|
|
101
|
+
version: stripV(hyphenMatch[1]),
|
|
102
|
+
lessThanOrEqual: stripV(hyphenMatch[2]),
|
|
103
|
+
status: 'affected',
|
|
104
|
+
versionType: 'semver'
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Caret range: "^4.19.2" → [4.19.2, 5.0.0)
|
|
109
|
+
const caretMatch = segment.match(RX.caret)
|
|
110
|
+
if (caretMatch) {
|
|
111
|
+
const base = stripV(caretMatch[1])
|
|
112
|
+
const major = base.split('.')[0]
|
|
113
|
+
return {
|
|
114
|
+
version: base,
|
|
115
|
+
lessThan: `${Number(major) + 1}.0.0`,
|
|
116
|
+
status: 'affected',
|
|
117
|
+
versionType: 'semver'
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Bounded: ">=4.0.0, <4.18.0" / ">= 4.0.0 <= 4.18.1" / ">=4.0.0<4.18.0"
|
|
122
|
+
const bounded = segment.match(RX.bounded)
|
|
123
|
+
if (bounded) {
|
|
124
|
+
const lower = stripV(bounded[1])
|
|
125
|
+
const upperOp = bounded[2]
|
|
126
|
+
const upper = stripV(bounded[3])
|
|
127
|
+
const entry = { version: lower, status: 'affected', versionType: 'semver' }
|
|
128
|
+
if (upperOp === '<') entry.lessThan = upper
|
|
129
|
+
else entry.lessThanOrEqual = upper
|
|
130
|
+
return entry
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Implicit lower: "7.0.0 < 7.24.0" — bare version followed by operator.
|
|
134
|
+
const implicit = segment.match(RX.implicitBounded)
|
|
135
|
+
if (implicit) {
|
|
136
|
+
const lower = stripV(implicit[1])
|
|
137
|
+
const upperOp = implicit[2]
|
|
138
|
+
const upper = stripV(implicit[3])
|
|
139
|
+
const entry = { version: lower, status: 'affected', versionType: 'semver' }
|
|
140
|
+
if (upperOp === '<') entry.lessThan = upper
|
|
141
|
+
else entry.lessThanOrEqual = upper
|
|
142
|
+
return entry
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Single-operator forms — try most specific (two-char ops) first.
|
|
146
|
+
const opUpperInc = segment.match(RX.opUpperInc)
|
|
147
|
+
if (opUpperInc) {
|
|
148
|
+
return {
|
|
149
|
+
version: '0',
|
|
150
|
+
lessThanOrEqual: stripV(opUpperInc[1]),
|
|
151
|
+
status: 'affected',
|
|
152
|
+
versionType: 'semver'
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const opLowerInc = segment.match(RX.opLowerInc)
|
|
156
|
+
if (opLowerInc) {
|
|
157
|
+
return { version: stripV(opLowerInc[1]), status: 'affected', versionType: 'semver' }
|
|
158
|
+
}
|
|
159
|
+
const opUpperEx = segment.match(RX.opUpperEx)
|
|
160
|
+
if (opUpperEx) {
|
|
161
|
+
const value = opUpperEx[1]
|
|
162
|
+
if (COMMIT_HASH.test(value)) {
|
|
163
|
+
return { version: '0', lessThan: value, status: 'affected', versionType: 'git' }
|
|
164
|
+
}
|
|
165
|
+
return { version: '0', lessThan: stripV(value), status: 'affected', versionType: 'semver' }
|
|
166
|
+
}
|
|
167
|
+
const opLowerEx = segment.match(RX.opLowerEx)
|
|
168
|
+
if (opLowerEx) {
|
|
169
|
+
return { version: stripV(opLowerEx[1]), status: 'affected', versionType: 'semver' }
|
|
170
|
+
}
|
|
171
|
+
const opEquals = segment.match(RX.opEquals)
|
|
172
|
+
if (opEquals) {
|
|
173
|
+
return { version: stripV(opEquals[1]), status: 'affected', versionType: 'semver' }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Bare semver: "4.6.2"
|
|
177
|
+
if (RX.bareSemver.test(segment)) {
|
|
178
|
+
return { version: stripV(segment), status: 'affected', versionType: 'semver' }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Bare commit hash: "39fd9827942..."
|
|
182
|
+
if (COMMIT_HASH.test(segment)) {
|
|
183
|
+
return { version: segment, status: 'affected', versionType: 'git' }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return null
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Public entry. See module header.
|
|
190
|
+
export function parseVersionRange (input) {
|
|
191
|
+
if (typeof input !== 'string') return []
|
|
192
|
+
const trimmed = input.trim()
|
|
193
|
+
if (!trimmed) return []
|
|
194
|
+
|
|
195
|
+
const segments = splitMultiline(trimmed)
|
|
196
|
+
const out = []
|
|
197
|
+
const fallbacks = [] // { kind: 'hash'|'custom', entry, hadDigits }
|
|
198
|
+
|
|
199
|
+
for (const seg of segments) {
|
|
200
|
+
const parsed = parseSegment(seg)
|
|
201
|
+
if (parsed) {
|
|
202
|
+
out.push(parsed)
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
// Fall back: extract an embedded commit hash if present, otherwise
|
|
206
|
+
// pass the segment through verbatim under `versionType: 'custom'`.
|
|
207
|
+
// We stage fallbacks separately so that, in multi-segment inputs,
|
|
208
|
+
// pure-prose segments can be dropped when at least one other segment
|
|
209
|
+
// yielded something useful.
|
|
210
|
+
const hashMatch = seg.match(new RegExp(`\\b(${HASH_TOKEN})\\b`, 'i'))
|
|
211
|
+
if (hashMatch) {
|
|
212
|
+
fallbacks.push({
|
|
213
|
+
kind: 'hash',
|
|
214
|
+
entry: { version: hashMatch[1], status: 'affected', versionType: 'custom' },
|
|
215
|
+
hadDigits: true
|
|
216
|
+
})
|
|
217
|
+
} else {
|
|
218
|
+
const hadDigits = /\d/.test(seg)
|
|
219
|
+
fallbacks.push({
|
|
220
|
+
kind: 'custom',
|
|
221
|
+
entry: { version: seg, status: 'affected', versionType: 'custom' },
|
|
222
|
+
hadDigits
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (segments.length > 1 && out.length > 0) {
|
|
228
|
+
// Multi-segment: only keep fallbacks that surfaced a hash or had
|
|
229
|
+
// digits. Prose-only fallbacks are dropped because the structured
|
|
230
|
+
// segments already carry the load-bearing information.
|
|
231
|
+
for (const f of fallbacks) {
|
|
232
|
+
if (f.kind === 'hash' || f.hadDigits) out.push(f.entry)
|
|
233
|
+
}
|
|
234
|
+
} else if (segments.length > 1) {
|
|
235
|
+
// Multi-segment with zero structured hits: surface hashes if any,
|
|
236
|
+
// otherwise nothing — the input is pure prose.
|
|
237
|
+
for (const f of fallbacks) {
|
|
238
|
+
if (f.kind === 'hash') out.push(f.entry)
|
|
239
|
+
}
|
|
240
|
+
if (out.length === 0 && fallbacks.length > 0) {
|
|
241
|
+
// Truly nothing — surface the first segment so the operator sees
|
|
242
|
+
// what was there.
|
|
243
|
+
out.push(fallbacks[0].entry)
|
|
244
|
+
}
|
|
245
|
+
} else {
|
|
246
|
+
// Single segment: pass the fallback through verbatim.
|
|
247
|
+
for (const f of fallbacks) out.push(f.entry)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return out
|
|
251
|
+
}
|
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
|
+
}
|
package/lib/errors.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Error classes for openjs-cna-tools.
|
|
2
|
+
//
|
|
3
|
+
// Each error carries enough structured context that a recovery flow (or the
|
|
4
|
+
// CLI exit-code mapping) can act on it without parsing the message. Messages
|
|
5
|
+
// themselves are written to teach the next operator how to fix the problem
|
|
6
|
+
// (see "Errors that teach" — the NCU pattern this borrows from).
|
|
7
|
+
|
|
8
|
+
export class CnaAuthError extends Error {
|
|
9
|
+
constructor (message, { status } = {}) {
|
|
10
|
+
super(message)
|
|
11
|
+
this.name = 'CnaAuthError'
|
|
12
|
+
this.status = status
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class CnaTimeoutError extends Error {
|
|
17
|
+
constructor (message, { correlationId, runId, timeoutMs } = {}) {
|
|
18
|
+
super(message)
|
|
19
|
+
this.name = 'CnaTimeoutError'
|
|
20
|
+
this.correlationId = correlationId
|
|
21
|
+
this.runId = runId
|
|
22
|
+
this.timeoutMs = timeoutMs
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class CnaOperationError extends Error {
|
|
27
|
+
constructor (message, { correlationId, runId, conclusion, result } = {}) {
|
|
28
|
+
super(message)
|
|
29
|
+
this.name = 'CnaOperationError'
|
|
30
|
+
this.correlationId = correlationId
|
|
31
|
+
this.runId = runId
|
|
32
|
+
this.conclusion = conclusion
|
|
33
|
+
this.result = result
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class CnaReviewBlockedError extends Error {
|
|
38
|
+
constructor (message, { report } = {}) {
|
|
39
|
+
super(message)
|
|
40
|
+
this.name = 'CnaReviewBlockedError'
|
|
41
|
+
this.report = report
|
|
42
|
+
this.blockers = report?.issues?.filter(i => i.severity === 'blocker') ?? []
|
|
43
|
+
}
|
|
44
|
+
}
|
package/lib/index.js
ADDED
package/lib/preview.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Compact preview of a v5.2 CNA Container, designed for terminal output
|
|
2
|
+
// before / instead of editing the JSON by hand.
|
|
3
|
+
//
|
|
4
|
+
// Shape goal: key/value rows, pipe-friendly (no boxes, no ANSI). Preview
|
|
5
|
+
// is the operator's last chance to review before publish, so values are
|
|
6
|
+
// rendered in full lenght
|
|
7
|
+
|
|
8
|
+
const KEY_WIDTH = 14
|
|
9
|
+
const SEPARATOR_WIDTH = 56
|
|
10
|
+
const SEPARATOR = '·'.repeat(SEPARATOR_WIDTH)
|
|
11
|
+
|
|
12
|
+
function pad (key) {
|
|
13
|
+
if (!key) return ' '.repeat(KEY_WIDTH)
|
|
14
|
+
return (key + ':').padEnd(KEY_WIDTH)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getDescription (c) {
|
|
18
|
+
return c?.descriptions?.find(d => d?.lang === 'en')?.value ??
|
|
19
|
+
c?.descriptions?.[0]?.value ??
|
|
20
|
+
''
|
|
21
|
+
}
|
|
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
|
+
|
|
30
|
+
function getCvss (c) {
|
|
31
|
+
for (const m of c?.metrics ?? []) {
|
|
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
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getCwes (c) {
|
|
47
|
+
const out = new Set()
|
|
48
|
+
for (const pt of c?.problemTypes ?? []) {
|
|
49
|
+
for (const d of pt?.descriptions ?? []) {
|
|
50
|
+
if (d?.cweId) out.add(d.cweId)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return [...out]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function summarizeAffected (entry) {
|
|
57
|
+
const product = `${entry.vendor ?? '?'}:${entry.product ?? '?'}`
|
|
58
|
+
const ranges = (entry.versions ?? []).map(v => {
|
|
59
|
+
if (v.lessThan) return `${v.version} → < ${v.lessThan}`
|
|
60
|
+
if (v.lessThanOrEqual) return `${v.version} → ≤ ${v.lessThanOrEqual}`
|
|
61
|
+
return v.version
|
|
62
|
+
})
|
|
63
|
+
return ranges.length === 0 ? product : `${product} (${ranges.join(', ')})`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function summarizeCredit (cr) {
|
|
67
|
+
const identity = cr.value ?? cr.user ?? '?'
|
|
68
|
+
return `${cr.type}: ${identity}`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Render the preview into the given CLI. Stays sync and side-effect-free
|
|
72
|
+
// apart from the cli writes. Callers can null-out cli to get a string
|
|
73
|
+
// instead (useful for tests).
|
|
74
|
+
export function renderPreview (record, cli) {
|
|
75
|
+
const lines = []
|
|
76
|
+
const out = (line = '') => {
|
|
77
|
+
if (cli) cli.log(line)
|
|
78
|
+
lines.push(line)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!record || typeof record !== 'object') {
|
|
82
|
+
out('Record preview: (no record provided)')
|
|
83
|
+
return lines.join('\n')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const container = record?.containers?.cna ?? {}
|
|
87
|
+
const cveId = record?.cveMetadata?.cveId
|
|
88
|
+
|
|
89
|
+
out('')
|
|
90
|
+
out('Record preview: (edit visually at https://vulnogram.org/#source)')
|
|
91
|
+
|
|
92
|
+
// Each field becomes a "row" via `row(key, value, ...extras)`. The
|
|
93
|
+
// helper emits a dotted separator before every row except the first
|
|
94
|
+
// so the operator can scan the preview as a stack of distinct fields.
|
|
95
|
+
let rowsEmitted = 0
|
|
96
|
+
const row = (key, value, ...extras) => {
|
|
97
|
+
if (rowsEmitted > 0) out(` ${SEPARATOR}`)
|
|
98
|
+
out(` ${pad(key)} ${value}`)
|
|
99
|
+
const continuation = ' '.repeat(2 + KEY_WIDTH + 1)
|
|
100
|
+
for (const line of extras) out(`${continuation}${line}`)
|
|
101
|
+
rowsEmitted += 1
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (cveId) row('CVE', cveId)
|
|
105
|
+
if (container.title) row('Title', container.title)
|
|
106
|
+
|
|
107
|
+
const description = getDescription(container)
|
|
108
|
+
if (description) {
|
|
109
|
+
const [firstLine, ...rest] = String(description).split('\n')
|
|
110
|
+
row('Description', firstLine, ...rest)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const affected = container.affected ?? []
|
|
114
|
+
if (affected.length > 0) {
|
|
115
|
+
const [first, ...rest] = affected
|
|
116
|
+
row('Affected', summarizeAffected(first), ...rest.map(summarizeAffected))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const cvss = getCvss(container)
|
|
120
|
+
if (cvss) {
|
|
121
|
+
const score = typeof cvss.score === 'number' ? cvss.score.toFixed(1) : '?'
|
|
122
|
+
row(`CVSS ${cvss.version}`, `${score} ${cvss.severity ?? ''} (${cvss.vector})`.trimEnd().replace(/^\s+/, ''))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const cwes = getCwes(container)
|
|
126
|
+
if (cwes.length > 0) row('CWE', cwes.join(', '))
|
|
127
|
+
|
|
128
|
+
const credits = container.credits ?? []
|
|
129
|
+
if (credits.length > 0) {
|
|
130
|
+
row('Credits', credits.map(summarizeCredit).join(', '))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const refs = container.references ?? []
|
|
134
|
+
if (refs.length > 0) {
|
|
135
|
+
const [first, ...rest] = refs.map(r => r?.url ?? '?')
|
|
136
|
+
row('References', first, ...rest)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (container.x_generator?.engine) {
|
|
140
|
+
row('Generator', container.x_generator.engine)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
out('')
|
|
144
|
+
return lines.join('\n')
|
|
145
|
+
}
|