@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.
@@ -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
@@ -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,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
+ }