@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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +312 -2
  3. package/bin/openjs-cna.js +128 -0
  4. package/components/config.js +92 -0
  5. package/components/convert.js +81 -0
  6. package/components/get.js +35 -0
  7. package/components/health.js +30 -0
  8. package/components/list.js +36 -0
  9. package/components/llm-review-prompt.js +84 -0
  10. package/components/preview.js +42 -0
  11. package/components/publish.js +238 -0
  12. package/components/reserve.js +48 -0
  13. package/components/review.js +170 -0
  14. package/components/update.js +104 -0
  15. package/lib/auth.js +20 -0
  16. package/lib/batch.js +141 -0
  17. package/lib/cli.js +81 -0
  18. package/lib/client.js +244 -0
  19. package/lib/component-utils.js +74 -0
  20. package/lib/config.js +121 -0
  21. package/lib/convert/ghsa.js +450 -0
  22. package/lib/convert/version-range.js +251 -0
  23. package/lib/cvss.js +113 -0
  24. package/lib/errors.js +44 -0
  25. package/lib/index.js +9 -0
  26. package/lib/preview.js +145 -0
  27. package/lib/prompt.js +170 -0
  28. package/lib/record.js +123 -0
  29. package/lib/review/index.js +78 -0
  30. package/lib/review/rules.js +433 -0
  31. package/lib/review/schema.js +99 -0
  32. package/lib/review/schemas/cve-record-format.json +1362 -0
  33. package/lib/review/schemas/imports/cvss/cvss-v2.0.json +195 -0
  34. package/lib/review/schemas/imports/cvss/cvss-v3.0.json +577 -0
  35. package/lib/review/schemas/imports/cvss/cvss-v3.1.json +577 -0
  36. package/lib/review/schemas/imports/cvss/cvss-v4.0.json +784 -0
  37. package/lib/review/schemas/tags/adp-tags.json +7 -0
  38. package/lib/review/schemas/tags/cna-tags.json +7 -0
  39. package/lib/review/schemas/tags/reference-tags.json +27 -0
  40. 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
@@ -0,0 +1,9 @@
1
+ // Public package surface.
2
+
3
+ export { CnaClient } from './client.js'
4
+ export {
5
+ CnaAuthError,
6
+ CnaTimeoutError,
7
+ CnaOperationError,
8
+ CnaReviewBlockedError
9
+ } from './errors.js'
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
+ }