@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
package/lib/prompt.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
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
|
+
// Metric blocks the record format accepts, in display preference order.
|
|
47
|
+
const CVSS_BLOCKS = [
|
|
48
|
+
{ key: 'cvssV3_1', label: 'CVSS 3.1' },
|
|
49
|
+
{ key: 'cvssV4_0', label: 'CVSS 4.0' },
|
|
50
|
+
{ key: 'cvssV3_0', label: 'CVSS 3.0' }
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
function summarizeCvss (cna) {
|
|
54
|
+
for (const m of cna?.metrics ?? []) {
|
|
55
|
+
for (const { key, label } of CVSS_BLOCKS) {
|
|
56
|
+
if (m?.[key]?.vectorString) {
|
|
57
|
+
const { vectorString, baseScore, baseSeverity } = m[key]
|
|
58
|
+
return { label, body: `${baseScore ?? '?'} ${baseSeverity ?? ''} (${vectorString})`.replace(/\s+/g, ' ').trim() }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function summarizeCwes (cna) {
|
|
66
|
+
const out = new Set()
|
|
67
|
+
for (const pt of cna?.problemTypes ?? []) {
|
|
68
|
+
for (const d of pt?.descriptions ?? []) {
|
|
69
|
+
if (d?.cweId) out.add(d.cweId)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return [...out]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Generate the full LLM prompt as a string. Caller is responsible for
|
|
76
|
+
// printing it; this stays pure so it's trivial to test.
|
|
77
|
+
export function buildAuditPrompt (record) {
|
|
78
|
+
const cna = getCna(record)
|
|
79
|
+
const description = getDescription(cna)
|
|
80
|
+
const title = cna?.title ?? '(no title)'
|
|
81
|
+
const value = description?.value ?? '(no description)'
|
|
82
|
+
const markdown = getMarkdown(description)
|
|
83
|
+
const affected = summarizeAffected(cna)
|
|
84
|
+
const cvss = summarizeCvss(cna)
|
|
85
|
+
const cwes = summarizeCwes(cna)
|
|
86
|
+
const cveId = record?.cveMetadata?.cveId
|
|
87
|
+
|
|
88
|
+
const lines = []
|
|
89
|
+
lines.push(
|
|
90
|
+
'You are reviewing the natural-language fields of a CVE record before it ships to MITRE.',
|
|
91
|
+
'The fields are read by application developers and security tooling worldwide.',
|
|
92
|
+
'Be concise, factual, and editorial. Flag what should change and why.',
|
|
93
|
+
'',
|
|
94
|
+
'## Editorial guidelines',
|
|
95
|
+
'',
|
|
96
|
+
'**Title** (`cna.title`):',
|
|
97
|
+
'- Format: `<package> vulnerable to <Vulnerability Type>` or `<package> vulnerable to <Type> via <Vector>`.',
|
|
98
|
+
'- 6 to 15 words. Package name lowercase as it appears on npm.',
|
|
99
|
+
'- Spell out vulnerability classes (e.g. "Denial of Service", not "DoS"; "Cross-Site Scripting", not "XSS").',
|
|
100
|
+
'',
|
|
101
|
+
'**Description** (`cna.descriptions[0].value`):',
|
|
102
|
+
'- Plain English. No raw PoCs, exploit code, or step-by-step attack walkthroughs. Those belong in the advisory body, not the CVE record.',
|
|
103
|
+
'- No em-dashes (—) or en-dashes (–). Use periods or commas instead.',
|
|
104
|
+
'- No references to other private advisories (consumers cannot see them).',
|
|
105
|
+
'- URLs do **not** belong in the description prose. They live in `cna.references[]`. Flag any that leaked in.',
|
|
106
|
+
'- Match the tone of prior published advisories: short, neutral, factual. No alarmist language ("catastrophic", "blocks the entire event loop"). State facts.',
|
|
107
|
+
'- Sections (Impact / Patches / Workarounds) should be labelled clearly and read as standalone paragraphs.',
|
|
108
|
+
'',
|
|
109
|
+
'**General**:',
|
|
110
|
+
'- Confusing or ambiguous wording is a defect. Flag it.',
|
|
111
|
+
'- Inconsistencies between the title, description, and the facts block (affected, CWE, CVSS) are also defects.',
|
|
112
|
+
'- Do not invent facts. If a guideline cannot be evaluated from the text alone, say so.',
|
|
113
|
+
'',
|
|
114
|
+
'## Record context',
|
|
115
|
+
''
|
|
116
|
+
)
|
|
117
|
+
if (cveId) lines.push(`- **CVE**: ${cveId}`)
|
|
118
|
+
if (affected.length > 0) lines.push(`- **Affected**: ${affected.join('; ')}`)
|
|
119
|
+
if (cvss) lines.push(`- **${cvss.label}**: ${cvss.body}`)
|
|
120
|
+
if (cwes.length > 0) lines.push(`- **CWE**: ${cwes.join(', ')}`)
|
|
121
|
+
lines.push(
|
|
122
|
+
'',
|
|
123
|
+
'## Title to audit',
|
|
124
|
+
'',
|
|
125
|
+
'```',
|
|
126
|
+
title,
|
|
127
|
+
'```',
|
|
128
|
+
'',
|
|
129
|
+
'## Description (plain-text value)',
|
|
130
|
+
'',
|
|
131
|
+
'```',
|
|
132
|
+
value,
|
|
133
|
+
'```'
|
|
134
|
+
)
|
|
135
|
+
if (markdown) {
|
|
136
|
+
lines.push(
|
|
137
|
+
'',
|
|
138
|
+
'## Description (original Markdown, from supportingMedia)',
|
|
139
|
+
'',
|
|
140
|
+
'```markdown',
|
|
141
|
+
markdown,
|
|
142
|
+
'```'
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
lines.push(
|
|
146
|
+
'',
|
|
147
|
+
'## Respond in this exact Markdown shape',
|
|
148
|
+
'',
|
|
149
|
+
'```markdown',
|
|
150
|
+
'**Rating**: ready | needs-work | blocked',
|
|
151
|
+
'',
|
|
152
|
+
'### Issues',
|
|
153
|
+
'',
|
|
154
|
+
'- **[field]** *(severity: blocker | warning | suggestion)*: what is wrong, in one sentence.',
|
|
155
|
+
' - **Suggested rewrite**: a concrete replacement, OR "n/a" if the issue is about deletion / structure rather than wording.',
|
|
156
|
+
'',
|
|
157
|
+
'(Omit the Issues section entirely if Rating is "ready".)',
|
|
158
|
+
'',
|
|
159
|
+
'### Rewritten title (only if the title needs work)',
|
|
160
|
+
'',
|
|
161
|
+
'Single-line suggested replacement.',
|
|
162
|
+
'',
|
|
163
|
+
'### Rewritten description (only if the description needs material edits)',
|
|
164
|
+
'',
|
|
165
|
+
'A full replacement, in the same plain-text style as the original.',
|
|
166
|
+
'```',
|
|
167
|
+
''
|
|
168
|
+
)
|
|
169
|
+
return lines.join('\n')
|
|
170
|
+
}
|
package/lib/record.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Helpers for the parts of a v5.2 CVE Record that are about the record
|
|
2
|
+
// format rather than about where the data came from: the CVSS metric block
|
|
3
|
+
// and the record envelope. Converters (GHSA here, release trackers in
|
|
4
|
+
// downstream projects) build the container fields they know about and use
|
|
5
|
+
// these so the format-level details are written once.
|
|
6
|
+
|
|
7
|
+
import { USER_AGENT } from './client.js'
|
|
8
|
+
import { calculateBaseScore, severityFromScore } from './cvss.js'
|
|
9
|
+
import { OPENJS_CNA_ORG_ID } from './review/schema.js'
|
|
10
|
+
|
|
11
|
+
// MITRE schema version we emit on every Record. Matches what every CVE
|
|
12
|
+
// published by the OpenJS CNA carries today.
|
|
13
|
+
export const DATA_VERSION = '5.2'
|
|
14
|
+
|
|
15
|
+
// Metric block key per CVSS version prefix. v2 is not emitted by anything
|
|
16
|
+
// the foundation publishes, so it is left out on purpose.
|
|
17
|
+
const METRIC_KEYS = new Map([
|
|
18
|
+
['3.0', 'cvssV3_0'],
|
|
19
|
+
['3.1', 'cvssV3_1'],
|
|
20
|
+
['4.0', 'cvssV4_0']
|
|
21
|
+
])
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build one `metrics[]` entry from a CVSS vector string.
|
|
25
|
+
*
|
|
26
|
+
* The block key (`cvssV3_0`, `cvssV3_1`, `cvssV4_0`) follows the vector's
|
|
27
|
+
* `CVSS:<version>/` prefix, which is what MITRE validates each block
|
|
28
|
+
* against. `baseScore` is taken from `options.baseScore` when given and
|
|
29
|
+
* computed from the vector otherwise (v3.x only; v4.0 has no bundled
|
|
30
|
+
* calculator). `baseSeverity` is always derived from the final score.
|
|
31
|
+
*
|
|
32
|
+
* A vector with an unknown prefix or one the calculator rejects is passed
|
|
33
|
+
* through under the closest key with no score, so the reviewer can report
|
|
34
|
+
* it instead of this helper guessing.
|
|
35
|
+
*/
|
|
36
|
+
export function buildCvssMetric (vectorString, { baseScore } = {}) {
|
|
37
|
+
const vector = String(vectorString ?? '').trim()
|
|
38
|
+
const version = vector.match(/^CVSS:(\d\.\d)\//)?.[1]
|
|
39
|
+
const key = METRIC_KEYS.get(version) ?? 'cvssV3_1'
|
|
40
|
+
|
|
41
|
+
let score = typeof baseScore === 'number' ? baseScore : undefined
|
|
42
|
+
if (score === undefined && (version === '3.0' || version === '3.1')) {
|
|
43
|
+
try {
|
|
44
|
+
score = calculateBaseScore(vector).baseScore
|
|
45
|
+
} catch {}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
format: 'CVSS',
|
|
50
|
+
[key]: {
|
|
51
|
+
version: version ?? '3.1',
|
|
52
|
+
vectorString: vector,
|
|
53
|
+
...(score !== undefined ? { baseScore: score, baseSeverity: severityFromScore(score) } : {})
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Build one `problemTypes[]` entry from one or more CWE references.
|
|
60
|
+
*
|
|
61
|
+
* Accepts a single `{ cweId, name? }` or an array of them. `cweId` may be
|
|
62
|
+
* given as `400`, `CWE-400` or `cwe-400` and is normalised to the
|
|
63
|
+
* `CWE-<number>` form MITRE's pattern requires; anything else throws so
|
|
64
|
+
* the mistake surfaces at build time. `description` is required by MITRE,
|
|
65
|
+
* so it is the CWE name when known and the id otherwise.
|
|
66
|
+
*/
|
|
67
|
+
export function buildProblemType (cwes) {
|
|
68
|
+
const list = Array.isArray(cwes) ? cwes : [cwes]
|
|
69
|
+
if (list.length === 0) {
|
|
70
|
+
throw new TypeError('buildProblemType: at least one CWE reference is required')
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
descriptions: list.map(({ cweId, name } = {}) => {
|
|
74
|
+
const id = String(cweId ?? '').trim().match(/^(?:CWE-)?([1-9]\d*)$/i)?.[1]
|
|
75
|
+
if (!id) {
|
|
76
|
+
throw new TypeError(`buildProblemType: '${cweId}' is not a CWE id (expected CWE-<number>)`)
|
|
77
|
+
}
|
|
78
|
+
const text = String(name ?? '').trim()
|
|
79
|
+
return {
|
|
80
|
+
lang: 'en',
|
|
81
|
+
type: 'CWE',
|
|
82
|
+
cweId: `CWE-${id}`,
|
|
83
|
+
description: text || `CWE-${id}`
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Wrap a CNA container in the v5.2 Record envelope.
|
|
91
|
+
*
|
|
92
|
+
* Sets `providerMetadata.orgId` and `x_generator.engine` on the container
|
|
93
|
+
* when the caller has not, so no consumer needs to know the OpenJS CNA org
|
|
94
|
+
* id or the generator string. `cveMetadata` is emitted only when `cveId` is
|
|
95
|
+
* given; without it the publisher's CLI argument is the sole source of
|
|
96
|
+
* truth for the id.
|
|
97
|
+
*/
|
|
98
|
+
export function buildRecordEnvelope ({
|
|
99
|
+
cna,
|
|
100
|
+
cveId,
|
|
101
|
+
assignerOrgId = OPENJS_CNA_ORG_ID,
|
|
102
|
+
engineId = USER_AGENT,
|
|
103
|
+
dataVersion = DATA_VERSION
|
|
104
|
+
} = {}) {
|
|
105
|
+
if (!cna || typeof cna !== 'object') {
|
|
106
|
+
throw new TypeError('buildRecordEnvelope: `cna` must be the CNA container object')
|
|
107
|
+
}
|
|
108
|
+
const container = {
|
|
109
|
+
providerMetadata: { orgId: assignerOrgId },
|
|
110
|
+
...cna,
|
|
111
|
+
// x_ prefixes are MITRE's extension namespace. x_generator is the
|
|
112
|
+
// canonical place to surface "this container was produced by …".
|
|
113
|
+
x_generator: cna.x_generator ?? { engine: engineId }
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
dataType: 'CVE_RECORD',
|
|
117
|
+
dataVersion,
|
|
118
|
+
...(cveId
|
|
119
|
+
? { cveMetadata: { cveId, assignerOrgId, state: 'PUBLISHED' } }
|
|
120
|
+
: {}),
|
|
121
|
+
containers: { cna: container }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -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
|
+
}
|