@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.
- package/LICENSE +21 -0
- package/README.md +203 -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 +133 -0
- package/components/reserve.js +48 -0
- package/components/review.js +123 -0
- package/components/update.js +104 -0
- package/lib/auth.js +20 -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 +493 -0
- package/lib/convert/version-range.js +251 -0
- package/lib/errors.js +44 -0
- package/lib/index.js +9 -0
- package/lib/preview.js +144 -0
- package/lib/prompt.js +165 -0
- package/lib/review/index.js +78 -0
- package/lib/review/rules.js +382 -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 +52 -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/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,144 @@
|
|
|
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
|
+
function getCvss (c) {
|
|
24
|
+
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
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getCwes (c) {
|
|
46
|
+
const out = new Set()
|
|
47
|
+
for (const pt of c?.problemTypes ?? []) {
|
|
48
|
+
for (const d of pt?.descriptions ?? []) {
|
|
49
|
+
if (d?.cweId) out.add(d.cweId)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return [...out]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function summarizeAffected (entry) {
|
|
56
|
+
const product = `${entry.vendor ?? '?'}:${entry.product ?? '?'}`
|
|
57
|
+
const ranges = (entry.versions ?? []).map(v => {
|
|
58
|
+
if (v.lessThan) return `${v.version} → < ${v.lessThan}`
|
|
59
|
+
if (v.lessThanOrEqual) return `${v.version} → ≤ ${v.lessThanOrEqual}`
|
|
60
|
+
return v.version
|
|
61
|
+
})
|
|
62
|
+
return ranges.length === 0 ? product : `${product} (${ranges.join(', ')})`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function summarizeCredit (cr) {
|
|
66
|
+
const identity = cr.value ?? cr.user ?? '?'
|
|
67
|
+
return `${cr.type}: ${identity}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Render the preview into the given CLI. Stays sync and side-effect-free
|
|
71
|
+
// apart from the cli writes. Callers can null-out cli to get a string
|
|
72
|
+
// instead (useful for tests).
|
|
73
|
+
export function renderPreview (record, cli) {
|
|
74
|
+
const lines = []
|
|
75
|
+
const out = (line = '') => {
|
|
76
|
+
if (cli) cli.log(line)
|
|
77
|
+
lines.push(line)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!record || typeof record !== 'object') {
|
|
81
|
+
out('Record preview: (no record provided)')
|
|
82
|
+
return lines.join('\n')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const container = record?.containers?.cna ?? {}
|
|
86
|
+
const cveId = record?.cveMetadata?.cveId
|
|
87
|
+
|
|
88
|
+
out('')
|
|
89
|
+
out('Record preview: (edit visually at https://vulnogram.org/#source)')
|
|
90
|
+
|
|
91
|
+
// Each field becomes a "row" via `row(key, value, ...extras)`. The
|
|
92
|
+
// helper emits a dotted separator before every row except the first
|
|
93
|
+
// so the operator can scan the preview as a stack of distinct fields.
|
|
94
|
+
let rowsEmitted = 0
|
|
95
|
+
const row = (key, value, ...extras) => {
|
|
96
|
+
if (rowsEmitted > 0) out(` ${SEPARATOR}`)
|
|
97
|
+
out(` ${pad(key)} ${value}`)
|
|
98
|
+
const continuation = ' '.repeat(2 + KEY_WIDTH + 1)
|
|
99
|
+
for (const line of extras) out(`${continuation}${line}`)
|
|
100
|
+
rowsEmitted += 1
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (cveId) row('CVE', cveId)
|
|
104
|
+
if (container.title) row('Title', container.title)
|
|
105
|
+
|
|
106
|
+
const description = getDescription(container)
|
|
107
|
+
if (description) {
|
|
108
|
+
const [firstLine, ...rest] = String(description).split('\n')
|
|
109
|
+
row('Description', firstLine, ...rest)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const affected = container.affected ?? []
|
|
113
|
+
if (affected.length > 0) {
|
|
114
|
+
const [first, ...rest] = affected
|
|
115
|
+
row('Affected', summarizeAffected(first), ...rest.map(summarizeAffected))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const cvss = getCvss(container)
|
|
119
|
+
if (cvss) {
|
|
120
|
+
const score = typeof cvss.score === 'number' ? cvss.score.toFixed(1) : '?'
|
|
121
|
+
row(`CVSS ${cvss.version}`, `${score} ${cvss.severity ?? ''} (${cvss.vector})`.trimEnd().replace(/^\s+/, ''))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const cwes = getCwes(container)
|
|
125
|
+
if (cwes.length > 0) row('CWE', cwes.join(', '))
|
|
126
|
+
|
|
127
|
+
const credits = container.credits ?? []
|
|
128
|
+
if (credits.length > 0) {
|
|
129
|
+
row('Credits', credits.map(summarizeCredit).join(', '))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const refs = container.references ?? []
|
|
133
|
+
if (refs.length > 0) {
|
|
134
|
+
const [first, ...rest] = refs.map(r => r?.url ?? '?')
|
|
135
|
+
row('References', first, ...rest)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (container.x_generator?.engine) {
|
|
139
|
+
row('Generator', container.x_generator.engine)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
out('')
|
|
143
|
+
return lines.join('\n')
|
|
144
|
+
}
|
package/lib/prompt.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Build an LLM audit prompt for a v5.2 CVE Record's natural-language
|
|
2
|
+
// fields. The CLI side just prints the result; the user pastes it into
|
|
3
|
+
// the LLM of their choice (no network call from this tool).
|
|
4
|
+
//
|
|
5
|
+
// What we ask the LLM to do:
|
|
6
|
+
// * audit the title against the OpenJS naming convention
|
|
7
|
+
// * audit the description for clarity, tone, and editorial rules
|
|
8
|
+
// (no PoCs, no em/en-dashes, no cross-references to other private
|
|
9
|
+
// advisories, references belong in cna.references[] not in prose)
|
|
10
|
+
// * return Markdown with a rating + a per-issue list including a
|
|
11
|
+
// suggested rewrite where applicable.
|
|
12
|
+
//
|
|
13
|
+
// The prompt embeds:
|
|
14
|
+
// * the title
|
|
15
|
+
// * the plain-text description (descriptions[0].value)
|
|
16
|
+
// * the raw Markdown if supportingMedia[0].type === text/markdown
|
|
17
|
+
// * a short facts block (affected, CVSS, CWE) so the LLM grounds its
|
|
18
|
+
// suggestions in what the vulnerability actually is.
|
|
19
|
+
|
|
20
|
+
function getCna (record) {
|
|
21
|
+
return record?.containers?.cna ?? {}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getDescription (cna) {
|
|
25
|
+
return cna?.descriptions?.find(d => d?.lang === 'en') ?? cna?.descriptions?.[0] ?? null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getMarkdown (description) {
|
|
29
|
+
if (!description?.supportingMedia) return null
|
|
30
|
+
const media = description.supportingMedia.find(m => m?.type === 'text/markdown')
|
|
31
|
+
return media?.value ?? null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function summarizeAffected (cna) {
|
|
35
|
+
return (cna?.affected ?? []).map((a) => {
|
|
36
|
+
const product = `${a.vendor ?? '?'}:${a.product ?? '?'}`
|
|
37
|
+
const ranges = (a.versions ?? []).map((v) => {
|
|
38
|
+
if (v.lessThan) return `${v.version} → < ${v.lessThan}`
|
|
39
|
+
if (v.lessThanOrEqual) return `${v.version} → ≤ ${v.lessThanOrEqual}`
|
|
40
|
+
return v.version
|
|
41
|
+
})
|
|
42
|
+
return ranges.length === 0 ? product : `${product} (${ranges.join(', ')})`
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function summarizeCvss (cna) {
|
|
47
|
+
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
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function summarizeCwes (cna) {
|
|
61
|
+
const out = new Set()
|
|
62
|
+
for (const pt of cna?.problemTypes ?? []) {
|
|
63
|
+
for (const d of pt?.descriptions ?? []) {
|
|
64
|
+
if (d?.cweId) out.add(d.cweId)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return [...out]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Generate the full LLM prompt as a string. Caller is responsible for
|
|
71
|
+
// printing it; this stays pure so it's trivial to test.
|
|
72
|
+
export function buildAuditPrompt (record) {
|
|
73
|
+
const cna = getCna(record)
|
|
74
|
+
const description = getDescription(cna)
|
|
75
|
+
const title = cna?.title ?? '(no title)'
|
|
76
|
+
const value = description?.value ?? '(no description)'
|
|
77
|
+
const markdown = getMarkdown(description)
|
|
78
|
+
const affected = summarizeAffected(cna)
|
|
79
|
+
const cvss = summarizeCvss(cna)
|
|
80
|
+
const cwes = summarizeCwes(cna)
|
|
81
|
+
const cveId = record?.cveMetadata?.cveId
|
|
82
|
+
|
|
83
|
+
const lines = []
|
|
84
|
+
lines.push(
|
|
85
|
+
'You are reviewing the natural-language fields of a CVE record before it ships to MITRE.',
|
|
86
|
+
'The fields are read by application developers and security tooling worldwide.',
|
|
87
|
+
'Be concise, factual, and editorial. Flag what should change and why.',
|
|
88
|
+
'',
|
|
89
|
+
'## Editorial guidelines',
|
|
90
|
+
'',
|
|
91
|
+
'**Title** (`cna.title`):',
|
|
92
|
+
'- Format: `<package> vulnerable to <Vulnerability Type>` or `<package> vulnerable to <Type> via <Vector>`.',
|
|
93
|
+
'- 6 to 15 words. Package name lowercase as it appears on npm.',
|
|
94
|
+
'- Spell out vulnerability classes (e.g. "Denial of Service", not "DoS"; "Cross-Site Scripting", not "XSS").',
|
|
95
|
+
'',
|
|
96
|
+
'**Description** (`cna.descriptions[0].value`):',
|
|
97
|
+
'- Plain English. No raw PoCs, exploit code, or step-by-step attack walkthroughs. Those belong in the advisory body, not the CVE record.',
|
|
98
|
+
'- No em-dashes (—) or en-dashes (–). Use periods or commas instead.',
|
|
99
|
+
'- No references to other private advisories (consumers cannot see them).',
|
|
100
|
+
'- URLs do **not** belong in the description prose. They live in `cna.references[]`. Flag any that leaked in.',
|
|
101
|
+
'- Match the tone of prior published advisories: short, neutral, factual. No alarmist language ("catastrophic", "blocks the entire event loop"). State facts.',
|
|
102
|
+
'- Sections (Impact / Patches / Workarounds) should be labelled clearly and read as standalone paragraphs.',
|
|
103
|
+
'',
|
|
104
|
+
'**General**:',
|
|
105
|
+
'- Confusing or ambiguous wording is a defect. Flag it.',
|
|
106
|
+
'- Inconsistencies between the title, description, and the facts block (affected, CWE, CVSS) are also defects.',
|
|
107
|
+
'- Do not invent facts. If a guideline cannot be evaluated from the text alone, say so.',
|
|
108
|
+
'',
|
|
109
|
+
'## Record context',
|
|
110
|
+
''
|
|
111
|
+
)
|
|
112
|
+
if (cveId) lines.push(`- **CVE**: ${cveId}`)
|
|
113
|
+
if (affected.length > 0) lines.push(`- **Affected**: ${affected.join('; ')}`)
|
|
114
|
+
if (cvss) lines.push(`- **${cvss.label}**: ${cvss.body}`)
|
|
115
|
+
if (cwes.length > 0) lines.push(`- **CWE**: ${cwes.join(', ')}`)
|
|
116
|
+
lines.push(
|
|
117
|
+
'',
|
|
118
|
+
'## Title to audit',
|
|
119
|
+
'',
|
|
120
|
+
'```',
|
|
121
|
+
title,
|
|
122
|
+
'```',
|
|
123
|
+
'',
|
|
124
|
+
'## Description (plain-text value)',
|
|
125
|
+
'',
|
|
126
|
+
'```',
|
|
127
|
+
value,
|
|
128
|
+
'```'
|
|
129
|
+
)
|
|
130
|
+
if (markdown) {
|
|
131
|
+
lines.push(
|
|
132
|
+
'',
|
|
133
|
+
'## Description (original Markdown, from supportingMedia)',
|
|
134
|
+
'',
|
|
135
|
+
'```markdown',
|
|
136
|
+
markdown,
|
|
137
|
+
'```'
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
lines.push(
|
|
141
|
+
'',
|
|
142
|
+
'## Respond in this exact Markdown shape',
|
|
143
|
+
'',
|
|
144
|
+
'```markdown',
|
|
145
|
+
'**Rating**: ready | needs-work | blocked',
|
|
146
|
+
'',
|
|
147
|
+
'### Issues',
|
|
148
|
+
'',
|
|
149
|
+
'- **[field]** *(severity: blocker | warning | suggestion)*: what is wrong, in one sentence.',
|
|
150
|
+
' - **Suggested rewrite**: a concrete replacement, OR "n/a" if the issue is about deletion / structure rather than wording.',
|
|
151
|
+
'',
|
|
152
|
+
'(Omit the Issues section entirely if Rating is "ready".)',
|
|
153
|
+
'',
|
|
154
|
+
'### Rewritten title (only if the title needs work)',
|
|
155
|
+
'',
|
|
156
|
+
'Single-line suggested replacement.',
|
|
157
|
+
'',
|
|
158
|
+
'### Rewritten description (only if the description needs material edits)',
|
|
159
|
+
'',
|
|
160
|
+
'A full replacement, in the same plain-text style as the original.',
|
|
161
|
+
'```',
|
|
162
|
+
''
|
|
163
|
+
)
|
|
164
|
+
return lines.join('\n')
|
|
165
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// reviewRecord(record, { strict }) -> review report
|
|
2
|
+
//
|
|
3
|
+
// Sync, offline. Validates a full v5.2 CVE Record:
|
|
4
|
+
// { dataType, dataVersion, cveMetadata, containers: { cna: {...} } }
|
|
5
|
+
//
|
|
6
|
+
// MITRE's official schema check (via ajv) runs against the whole Record;
|
|
7
|
+
// the editorial rules in ./rules.js then walk the inner cna container.
|
|
8
|
+
|
|
9
|
+
import { RULES } from './rules.js'
|
|
10
|
+
import { validateAgainstMitreSchema } from './schema.js'
|
|
11
|
+
|
|
12
|
+
// `strict` defaults to true because publishing to MITRE is irreversible:
|
|
13
|
+
// every warning the reviewer surfaces blocks until the operator opts out
|
|
14
|
+
// explicitly via `--no-strict` (CLI) or `{ strict: false }` (library).
|
|
15
|
+
export function reviewRecord (record, { strict = true } = {}) {
|
|
16
|
+
const issues = []
|
|
17
|
+
|
|
18
|
+
// MITRE schema first. Every schema violation is a blocker because
|
|
19
|
+
// publish will fail downstream — there is no soft version of "doesn't
|
|
20
|
+
// match the schema MITRE uses". One issue per ajv error so operators
|
|
21
|
+
// see exactly what's wrong.
|
|
22
|
+
for (const err of validateAgainstMitreSchema(record)) {
|
|
23
|
+
issues.push({
|
|
24
|
+
rule: 'mitre-schema',
|
|
25
|
+
// Schema violations always fail at MITRE's end too.
|
|
26
|
+
tier: 'mitre-common',
|
|
27
|
+
severity: 'blocker',
|
|
28
|
+
path: err.path,
|
|
29
|
+
message: err.message
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Editorial rules look at just the cna container — drill in once here.
|
|
34
|
+
const cna = record?.containers?.cna ?? {}
|
|
35
|
+
|
|
36
|
+
for (const rule of RULES) {
|
|
37
|
+
let result
|
|
38
|
+
try {
|
|
39
|
+
result = rule.check(cna)
|
|
40
|
+
} catch (e) {
|
|
41
|
+
// A buggy rule shouldn't fail the review. Surface as a warning so the
|
|
42
|
+
// operator knows to file an issue.
|
|
43
|
+
issues.push({
|
|
44
|
+
rule: `${rule.id}:internal-error`,
|
|
45
|
+
// Carry the source rule's tier through so the --tier filter still
|
|
46
|
+
// works in the unlikely event the rule itself blew up.
|
|
47
|
+
tier: rule.tier,
|
|
48
|
+
severity: 'warning',
|
|
49
|
+
path: 'reviewer',
|
|
50
|
+
message: `Rule '${rule.id}' threw: ${e.message}`
|
|
51
|
+
})
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
if (result) {
|
|
55
|
+
issues.push({
|
|
56
|
+
rule: rule.id,
|
|
57
|
+
tier: rule.tier,
|
|
58
|
+
severity: strict && rule.severity === 'warning' ? 'blocker' : rule.severity,
|
|
59
|
+
path: result.path,
|
|
60
|
+
message: result.message
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const blockers = issues.filter(i => i.severity === 'blocker').length
|
|
66
|
+
const warnings = issues.filter(i => i.severity === 'warning').length
|
|
67
|
+
const suggestions = issues.filter(i => i.severity === 'suggestion').length
|
|
68
|
+
|
|
69
|
+
let rating
|
|
70
|
+
if (blockers > 0) rating = 'blocked'
|
|
71
|
+
else if (warnings > 0) rating = 'needs-work'
|
|
72
|
+
else rating = 'ready'
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
summary: { rating, blockers, warnings, suggestions, total: issues.length },
|
|
76
|
+
issues
|
|
77
|
+
}
|
|
78
|
+
}
|