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