@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/config.js ADDED
@@ -0,0 +1,121 @@
1
+ // Config layering for openjs-cna-tools.
2
+ //
3
+ // Precedence (highest → lowest):
4
+ // 1. CLI flag (resolved by each component; not in scope here)
5
+ // 2. Environment (OPENJS_CNA_TOKEN, OPENJS_CNA_BASE_URL, …)
6
+ // 3. Project config: ./.openjs-cna.json in CWD
7
+ // 4. Global config: ~/.config/openjs-cna/config.json (XDG-friendly)
8
+ //
9
+ // Encryption-at-rest for secrets is on the roadmap (matches NCU's `-x` flag
10
+ // shape via a `gpg` subprocess — no JS dep). Until that lands, secrets are
11
+ // stored as plaintext and `set -x` is a no-op with a warning. The merged
12
+ // config still uses property-descriptor merging so the encrypted-getter
13
+ // pattern can drop in later without a breaking change to callers.
14
+
15
+ import fs from 'node:fs'
16
+ import path from 'node:path'
17
+ import os from 'node:os'
18
+
19
+ const ENV_PREFIX = 'OPENJS_CNA_'
20
+
21
+ const ENV_TO_KEY = {
22
+ OPENJS_CNA_TOKEN: 'openjs_cna_token',
23
+ OPENJS_CNA_BASE_URL: 'openjs_cna_base_url'
24
+ }
25
+
26
+ export function globalConfigPath () {
27
+ const xdg = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config')
28
+ return path.join(xdg, 'openjs-cna-experimental', 'config.json')
29
+ }
30
+
31
+ export function projectConfigPath (cwd = process.cwd()) {
32
+ return path.join(cwd, '.openjs-cna.json')
33
+ }
34
+
35
+ function safeReadJson (filePath) {
36
+ try {
37
+ const raw = fs.readFileSync(filePath, 'utf8')
38
+ return JSON.parse(raw)
39
+ } catch {
40
+ return {}
41
+ }
42
+ }
43
+
44
+ function envOverrides () {
45
+ const out = {}
46
+ for (const [envName, key] of Object.entries(ENV_TO_KEY)) {
47
+ if (process.env[envName] != null) out[key] = process.env[envName]
48
+ }
49
+ // Pass-through for any future OPENJS_CNA_* env vars we haven't mapped yet.
50
+ for (const [name, value] of Object.entries(process.env)) {
51
+ if (!name.startsWith(ENV_PREFIX)) continue
52
+ if (ENV_TO_KEY[name]) continue
53
+ const key = name.slice(ENV_PREFIX.length).toLowerCase()
54
+ out[key] = value
55
+ }
56
+ return out
57
+ }
58
+
59
+ // Merge configs using property descriptors so future encrypted-getter
60
+ // properties survive the merge without being prematurely evaluated. Today
61
+ // every value is a plain string; this is a no-op cost paid for future-
62
+ // proofing.
63
+ function mergeWithDescriptors (...sources) {
64
+ const out = Object.create(null)
65
+ for (const source of sources) {
66
+ if (!source) continue
67
+ Object.defineProperties(out, Object.getOwnPropertyDescriptors(source))
68
+ }
69
+ return out
70
+ }
71
+
72
+ export function getMergedConfig ({ cwd } = {}) {
73
+ const global = safeReadJson(globalConfigPath())
74
+ const project = safeReadJson(projectConfigPath(cwd))
75
+ const env = envOverrides()
76
+ return mergeWithDescriptors(global, project, env)
77
+ }
78
+
79
+ function ensureDir (filePath) {
80
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
81
+ }
82
+
83
+ export function setConfigValue (key, value, { scope = 'global', cwd } = {}) {
84
+ const target = scope === 'project' ? projectConfigPath(cwd) : globalConfigPath()
85
+ const current = safeReadJson(target)
86
+ current[key] = value
87
+ ensureDir(target)
88
+ fs.writeFileSync(target, JSON.stringify(current, null, 2) + '\n', { mode: 0o600 })
89
+ return target
90
+ }
91
+
92
+ export function getConfigValue (key, opts = {}) {
93
+ const merged = getMergedConfig(opts)
94
+ return merged[key]
95
+ }
96
+
97
+ export function deleteConfigValue (key, { scope = 'global', cwd } = {}) {
98
+ const target = scope === 'project' ? projectConfigPath(cwd) : globalConfigPath()
99
+ const current = safeReadJson(target)
100
+ if (!(key in current)) return false
101
+ delete current[key]
102
+ ensureDir(target)
103
+ fs.writeFileSync(target, JSON.stringify(current, null, 2) + '\n', { mode: 0o600 })
104
+ return true
105
+ }
106
+
107
+ // Returns a redacted view of the merged config: secret-looking keys
108
+ // (anything containing 'token' or 'secret') are replaced with a fixed
109
+ // placeholder so `openjs-cna-experimental config list` can be piped without leaking.
110
+ export function listConfig (opts = {}) {
111
+ const merged = getMergedConfig(opts)
112
+ const out = {}
113
+ for (const [key, value] of Object.entries(merged)) {
114
+ if (/token|secret|password/i.test(key)) {
115
+ out[key] = '<redacted>'
116
+ } else {
117
+ out[key] = value
118
+ }
119
+ }
120
+ return out
121
+ }
@@ -0,0 +1,450 @@
1
+ // Convert a GitHub Security Advisory into a v5.2 CNA Container draft.
2
+ //
3
+ // Input shapes:
4
+ //
5
+ // 1. **Per-repository advisory URL** —
6
+ // `https://github.com/<owner>/<repo>/security/advisories/<GHSA>`.
7
+ // Routes to `GET https://api.github.com/repos/<owner>/<repo>/security-advisories/<GHSA>`.
8
+ // Public, no auth required for published advisories.
9
+ //
10
+ // 2. **Global database advisory URL** —
11
+ // `https://github.com/advisories/<GHSA>`.
12
+ // Routes to `GET https://api.github.com/advisories/<GHSA>`.
13
+ // Public, no auth required.
14
+ //
15
+ // 3. **Pre-fetched advisory object** — for callers that already have
16
+ // the advisory JSON in hand (webhook payloads, cached responses,
17
+ // tests, or `openjs-cna-experimental convert -` reading from stdin).
18
+ //
19
+ // This tool only reads PUBLIC published advisories. Private drafts are
20
+ // out of scope — operators with access can `gh api … > advisory.json &&
21
+ // openjs-cna-experimental convert -` to convert from stdin instead.
22
+ //
23
+ // The mapper (`mapAdvisoryToRecord`) is exported separately so the pure
24
+ // shape-translation logic is testable without touching the network.
25
+
26
+ import { USER_AGENT } from '../client.js'
27
+ import { buildCvssMetric, buildProblemType, buildRecordEnvelope } from '../record.js'
28
+ import { OPENJS_CNA_ORG_ID } from '../review/schema.js'
29
+ import { parseVersionRange } from './version-range.js'
30
+
31
+ // `ENGINE_ID` is the same string we send as `User-Agent` on every HTTP
32
+ // call (see lib/client.js). Re-exported here so callers that only need
33
+ // the converter don't have to reach into the transport module.
34
+ export const ENGINE_ID = USER_AGENT
35
+
36
+ // GHSA credit `type` values use underscores. MITRE's CVE Record Format
37
+ // schema (cnaPublishedContainer → credits → type) requires the
38
+ // space-separated forms. Map each known GHSA role to its MITRE spelling.
39
+ const CREDIT_TYPE_MAP = {
40
+ finder: 'finder',
41
+ reporter: 'reporter',
42
+ analyst: 'analyst',
43
+ coordinator: 'coordinator',
44
+ remediation_developer: 'remediation developer',
45
+ remediation_reviewer: 'remediation reviewer',
46
+ remediation_verifier: 'remediation verifier',
47
+ tool: 'tool',
48
+ sponsor: 'sponsor',
49
+ other: 'other'
50
+ }
51
+
52
+ const URL_REPO_RE = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/security\/advisories\/(GHSA-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4})/i
53
+ const URL_GLOBAL_RE = /^https:\/\/github\.com\/advisories\/(GHSA-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4})/i
54
+
55
+ // Tag the canonical GitHub advisory URL (the GHSA we are converting) as
56
+ // `vendor-advisory`. Everything else is left untagged — the operator
57
+ // can hand-tag in Vulnogram or by editing the JSON before publish.
58
+ //
59
+ // Why: heuristics for CVE / commit / blog URLs accumulate edge cases
60
+ // fast (different hosts, different URL shapes, redirects) and the cost
61
+ // of a wrong tag at publish time is real. Better to ship one tag we can
62
+ // be sure of than several that drift.
63
+ function tagForUrl (url, { selfGhsaId } = {}) {
64
+ if (!selfGhsaId) return null
65
+ const selfRe = new RegExp(
66
+ `(github\\.com/.*/security/advisories/${selfGhsaId}\\b|github\\.com/advisories/${selfGhsaId}\\b)`,
67
+ 'i'
68
+ )
69
+ return selfRe.test(url) ? 'vendor-advisory' : null
70
+ }
71
+
72
+ // Parse a github.com advisory URL into a structured descriptor or return
73
+ // null if the input doesn't look like one. The `kind` field tells the
74
+ // fetcher which GitHub REST endpoint to hit.
75
+ //
76
+ // { kind: 'repo', owner, repo, ghsaId } → /repos/{owner}/{repo}/security-advisories/{ghsaId}
77
+ // { kind: 'global', ghsaId } → /advisories/{ghsaId}
78
+ //
79
+ // Exported so tests and other callers can use the same parser.
80
+ export function parseAdvisoryUrl (url) {
81
+ if (typeof url !== 'string') return null
82
+ const repoMatch = url.match(URL_REPO_RE)
83
+ if (repoMatch) {
84
+ return { kind: 'repo', owner: repoMatch[1], repo: repoMatch[2], ghsaId: repoMatch[3] }
85
+ }
86
+ const globalMatch = url.match(URL_GLOBAL_RE)
87
+ if (globalMatch) {
88
+ return { kind: 'global', ghsaId: globalMatch[1] }
89
+ }
90
+ return null
91
+ }
92
+
93
+ // GitHub's REST API for published advisories is public. We pass
94
+ // `User-Agent` and `Accept` headers per GitHub's docs, but no auth.
95
+ // Injectable `fetchImpl` for tests.
96
+ async function ghFetch (url, { fetchImpl = globalThis.fetch } = {}) {
97
+ const res = await fetchImpl(url, {
98
+ headers: {
99
+ Accept: 'application/vnd.github+json',
100
+ 'User-Agent': USER_AGENT,
101
+ 'X-GitHub-Api-Version': '2022-11-28'
102
+ }
103
+ })
104
+ if (!res.ok) {
105
+ throw new Error(
106
+ `GitHub returned ${res.status} for ${url}. ` +
107
+ 'The advisory may be private or unpublished — this tool only supports public advisories.'
108
+ )
109
+ }
110
+ return res.json()
111
+ }
112
+
113
+ async function fetchAdvisory (source, opts = {}) {
114
+ if (source && typeof source === 'object') return source
115
+ if (typeof source !== 'string') {
116
+ throw new TypeError(`source must be a string or object, got ${typeof source}`)
117
+ }
118
+ const parsed = parseAdvisoryUrl(source)
119
+ if (parsed?.kind === 'repo') {
120
+ return ghFetch(`https://api.github.com/repos/${parsed.owner}/${parsed.repo}/security-advisories/${parsed.ghsaId}`, opts)
121
+ }
122
+ if (parsed?.kind === 'global') {
123
+ return ghFetch(`https://api.github.com/advisories/${parsed.ghsaId}`, opts)
124
+ }
125
+ throw new Error(
126
+ `Cannot parse advisory source: '${source}'. Pass a public advisory URL:\n` +
127
+ ' - per-repository: https://github.com/<owner>/<repo>/security/advisories/<GHSA>\n' +
128
+ ' - global database: https://github.com/advisories/<GHSA>'
129
+ )
130
+ }
131
+
132
+ // ----- Mappers --------------------------------------------------------------
133
+
134
+ // Drop the References section (and everything after it) from a Markdown
135
+ // description. The URLs are already structured in `cna.references[]`,
136
+ // so leaving them in the description would duplicate the data. We strip
137
+ // this from BOTH the plain-text value and the raw Markdown that travels
138
+ // in supportingMedia, so neither representation carries the duplicate.
139
+ function stripReferencesSection (md) {
140
+ if (typeof md !== 'string') return ''
141
+ const normalized = md.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
142
+ // Case-insensitive (trailing /i flag), so "References", "references",
143
+ // and "REFERENCES" all match across the three shapes we accept:
144
+ // ATX heading (`### References`), bold (`**References**`), and
145
+ // plain label (`References:`).
146
+ const refIdx = normalized.search(
147
+ /(?:^|\n)\s*(?:#{1,6}\s+References\b|\*\*References\*\*|References\s*:)\s*\n/i
148
+ )
149
+ return refIdx >= 0 ? normalized.slice(0, refIdx).trimEnd() : normalized
150
+ }
151
+
152
+ // Strip Markdown formatting down to readable plain text. Generic — no
153
+ // hard-coded section names beyond the References block (which is dropped
154
+ // upstream by stripReferencesSection because cna.references[] is its
155
+ // structured home). Headers become "Label:" lines (matches the published
156
+ // OpenJS CVE convention), emphasis/code/links collapse to their text
157
+ // content, list bullets drop the bullet marker, blockquote markers drop.
158
+ function stripMarkdown (md) {
159
+ if (typeof md !== 'string') return ''
160
+ // Normalize line endings — GitHub serves CRLF, every regex below uses
161
+ // `\n` as the line boundary.
162
+ let text = md.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
163
+ // Headers → "Label:" (any ATX heading, level 1–6, generic). We use
164
+ // [ \t]* (not \s*) for leading/trailing whitespace so we don't eat the
165
+ // preceding/following blank lines — those are what visually separate
166
+ // the heading label from neighboring paragraphs.
167
+ text = text.replace(/^[ \t]*#{1,6}[ \t]+(.+?)[ \t]*$/gm, '$1:')
168
+ // **bold** / __bold__ / *italic* / _italic_ → inner text
169
+ text = text.replace(/\*\*(.+?)\*\*/g, '$1')
170
+ text = text.replace(/__(.+?)__/g, '$1')
171
+ text = text.replace(/(?<![*\w])\*(?!\s)(.+?)(?<!\s)\*(?![*\w])/g, '$1')
172
+ text = text.replace(/(?<![_\w])_(?!\s)(.+?)(?<!\s)_(?![_\w])/g, '$1')
173
+ // `inline code` → inner text
174
+ text = text.replace(/`(.+?)`/g, '$1')
175
+ // [link](url) → link
176
+ text = text.replace(/\[(.+?)\]\([^)]+\)/g, '$1')
177
+ // Bullet / ordered-list markers at line start → strip the marker
178
+ text = text.replace(/^\s*(?:[-*+]|\d+\.)\s+/gm, '')
179
+ // Blockquote markers
180
+ text = text.replace(/^\s*>\s?/gm, '')
181
+ // Collapse 3+ blank lines to a single blank line.
182
+ text = text.replace(/\n{3,}/g, '\n\n')
183
+ return text.trim()
184
+ }
185
+
186
+ function buildDescriptions (advisory) {
187
+ // GHSA advisory descriptions are authored in Markdown. MITRE's
188
+ // descriptions[0].value field is plain-text in every published OpenJS
189
+ // CVE on record, so we strip the Markdown for the value and put the
190
+ // (de-duplicated) original Markdown into supportingMedia[0]. The
191
+ // References block is removed from both representations because its
192
+ // URLs already live in cna.references[] as structured data.
193
+ const original = (advisory.description ?? '').trim() || (advisory.summary ?? '').trim()
194
+ if (!original) return []
195
+ const raw = stripReferencesSection(original).trim()
196
+ if (!raw) return []
197
+ const plain = stripMarkdown(raw) || raw
198
+ const description = {
199
+ lang: 'en',
200
+ value: plain
201
+ }
202
+ // Only attach supportingMedia when the raw Markdown still carries
203
+ // formatting after the References strip. If plain === raw the markdown
204
+ // form has nothing extra to offer.
205
+ if (raw !== plain) {
206
+ description.supportingMedia = [{ type: 'text/markdown', base64: false, value: raw }]
207
+ }
208
+ return [description]
209
+ }
210
+
211
+ function buildAffected (advisory) {
212
+ const vulnerabilities = advisory.vulnerabilities ?? []
213
+ return vulnerabilities.map((v) => {
214
+ // parseVersionRange expands a GHSA range string into MITRE-shaped
215
+ // entries (version + lessThan/lessThanOrEqual + versionType). Multi-
216
+ // segment inputs (e.g. "< 6.24.0; 7.0.0 < 7.24.0") become multiple
217
+ // entries; empty input becomes []. See lib/convert/version-range.js
218
+ // and the matrix at test/fixtures/version-range-matrix.js for the
219
+ // full shape coverage.
220
+ const versions = parseVersionRange(v.vulnerable_version_range ?? '')
221
+ return {
222
+ vendor: v.package?.ecosystem === 'npm' ? 'npm' : 'unknown',
223
+ product: v.package?.name ?? 'unknown',
224
+ defaultStatus: 'unaffected',
225
+ versions
226
+ }
227
+ })
228
+ }
229
+
230
+ function buildProblemTypes (advisory) {
231
+ const cwes = advisory.cwes ?? []
232
+ if (cwes.length === 0) return undefined
233
+ // GHSA lists every CWE of the advisory; MITRE groups them as the
234
+ // descriptions of one problemType entry.
235
+ return [buildProblemType(cwes.map((cwe) => ({ cweId: cwe.cwe_id, name: cwe.name })))]
236
+ }
237
+
238
+ function buildMetrics (advisory) {
239
+ // GHSA serves CVSS in two parallel places, depending on endpoint and
240
+ // age of the advisory:
241
+ //
242
+ // * Top-level `cvss` / `cvss_v3` — older v3.1-only shape, may be
243
+ // null when the reporter only filed a v4 vector.
244
+ // * `cvss_severities.cvss_v3` / `cvss_severities.cvss_v4` — newer
245
+ // shape; v4 can be the only populated entry when the reporter
246
+ // used the v4 calculator.
247
+ //
248
+ // Emit a `cvssV3_1` block whenever a v3.1 vector exists, and a
249
+ // `cvssV4_0` block whenever a v4 vector exists. Both can appear; we
250
+ // emit each in its own metric entry so the MITRE schema (which gates
251
+ // additional properties on cvssVx blocks) is happy. GHSA only issues
252
+ // 3.1 and 4.0 vectors; `buildCvssMetric` computes a missing v3.1 score
253
+ // and passes through a vector it cannot parse for the reviewer to flag.
254
+ const metrics = []
255
+ const v3Vector = advisory.cvss?.vector_string ??
256
+ advisory.cvss_v3?.vector_string ??
257
+ advisory.cvss_severities?.cvss_v3?.vector_string
258
+ const v3Score = advisory.cvss?.score ??
259
+ advisory.cvss_v3?.score ??
260
+ advisory.cvss_severities?.cvss_v3?.score
261
+ if (v3Vector) {
262
+ metrics.push(buildCvssMetric(v3Vector, { baseScore: v3Score }))
263
+ }
264
+ const v4Vector = advisory.cvss_v4?.vector_string ??
265
+ advisory.cvss_severities?.cvss_v4?.vector_string
266
+ const v4Score = advisory.cvss_v4?.score ??
267
+ advisory.cvss_severities?.cvss_v4?.score
268
+ if (v4Vector) {
269
+ metrics.push(buildCvssMetric(v4Vector, { baseScore: v4Score }))
270
+ }
271
+ return metrics.length > 0 ? metrics : undefined
272
+ }
273
+
274
+ function buildCredits (advisory) {
275
+ // GitHub's two advisory endpoints return credits in different shapes:
276
+ //
277
+ // * Global `/advisories/{id}` returns `credits: [{ user: { login }, type }]`
278
+ // * Per-repo `/repos/.../security-advisories/{id}` returns
279
+ // `credits: [{ login, type }]` (login at the top level, no `user`
280
+ // wrapper) AND `credits_detailed: [{ user: { login }, ... }]`.
281
+ //
282
+ // Prefer `credits_detailed` when it has entries (richer per-repo shape);
283
+ // otherwise fall back to `credits`. The login extractor below tolerates
284
+ // both shapes so a hand-fed advisory object works either way.
285
+ const sourceCredits = advisory.credits_detailed?.length
286
+ ? advisory.credits_detailed
287
+ : (advisory.credits ?? [])
288
+ if (sourceCredits.length === 0) return undefined
289
+ return sourceCredits.map((c) => {
290
+ // Try every shape we know about: { login }, { user: 'string' },
291
+ // { user: { login } }, falling through to 'unknown'.
292
+ const login = c.login ??
293
+ (typeof c.user === 'string' ? c.user : c.user?.login) ??
294
+ 'unknown'
295
+ const ghsaType = c.type ?? 'finder'
296
+ // MITRE's schema uses space-separated values for multi-word types
297
+ // (e.g. 'remediation developer'). GHSA uses underscores. Map across.
298
+ const mitreType = CREDIT_TYPE_MAP[ghsaType] ?? 'other'
299
+ // We do NOT set `user` on the MITRE credit (the schema requires a
300
+ // UUID there and we don't have one). `value` carries the GitHub login
301
+ // verbatim, which is the human-readable identity MITRE expects on
302
+ // free-form attribution.
303
+ return {
304
+ lang: 'en',
305
+ type: mitreType,
306
+ value: login
307
+ }
308
+ })
309
+ }
310
+
311
+ // Pull URLs out of the description's "References" section. Per-repo
312
+ // advisories return `references: null` from the REST API but operators
313
+ // usually list the URLs in the Markdown body under a heading like
314
+ // `### References` or `**References**`. From the first such header to
315
+ // the end of the description (or the next heading), grab every URL.
316
+ function extractReferencesFromDescription (text) {
317
+ if (typeof text !== 'string') return []
318
+ // Match the "References" header. We accept Markdown headings (`#+`),
319
+ // bold (`**References**`), and bare `References:` followed by colon
320
+ // or newline. Case-insensitive.
321
+ const headerRe = /(?:^|\n)\s*(?:#{1,6}\s+References\b|\*\*References\*\*|References\s*:)\s*\n/i
322
+ const headerMatch = text.match(headerRe)
323
+ if (!headerMatch) return []
324
+ const start = headerMatch.index + headerMatch[0].length
325
+ // Cut off at the next Markdown heading so we don't pull URLs from a
326
+ // following section. If there is none, take the rest of the text.
327
+ const after = text.slice(start)
328
+ const nextHeading = after.match(/\n\s*#{1,6}\s+\S/)
329
+ const section = nextHeading ? after.slice(0, nextHeading.index) : after
330
+ // Pull every http(s) URL; let the dedupe in buildReferences handle
331
+ // duplicates if the same URL appears twice in the section.
332
+ const urls = section.match(/https?:\/\/\S+/g) ?? []
333
+ // Strip trailing punctuation common in Markdown bullets/sentences.
334
+ return urls.map(u => u.replace(/[).,;]+$/, ''))
335
+ }
336
+
337
+ // Walk GHSA's `references[]` and emit MITRE references. When the API
338
+ // returns an empty/null `references` (per-repo endpoint does this), fall
339
+ // back to scraping the description's "References" section. The
340
+ // `vendor-advisory` tag is heuristic — see tagForUrl. URLs that don't
341
+ // match a known pattern get no tag (MITRE accepts untagged references).
342
+ function buildReferences (advisory) {
343
+ const out = []
344
+ const seen = new Set()
345
+ // Pass the advisory's own GHSA id into tagForUrl so the self-reference
346
+ // (the canonical GitHub advisory page for the same GHSA) doesn't get
347
+ // mislabeled `third-party-advisory`.
348
+ const selfGhsaId = advisory.ghsa_id
349
+ const push = (url) => {
350
+ if (!url || seen.has(url)) return
351
+ seen.add(url)
352
+ const tag = tagForUrl(url, { selfGhsaId })
353
+ out.push(tag ? { url, tags: [tag] } : { url })
354
+ }
355
+ // Primary source: the API's structured `references[]`.
356
+ for (const ref of advisory.references ?? []) {
357
+ const url = typeof ref === 'string' ? ref : ref?.url
358
+ push(url)
359
+ }
360
+ // Fallback: scrape the description's References section. Only kicks
361
+ // in when the structured list was empty.
362
+ if (out.length === 0) {
363
+ for (const url of extractReferencesFromDescription(advisory.description)) {
364
+ push(url)
365
+ }
366
+ }
367
+ return out
368
+ }
369
+
370
+ // ----- Public API -----------------------------------------------------------
371
+
372
+ // Pure mapper: GHSA advisory JSON → full v5.2 **CVE Record**.
373
+ //
374
+ // Output shape (Vulnogram-compatible, matches every published OpenJS CVE
375
+ // on MITRE):
376
+ //
377
+ // {
378
+ // dataType: 'CVE_RECORD',
379
+ // dataVersion: '5.2',
380
+ // cveMetadata: { cveId, assignerOrgId, state: 'PUBLISHED' },
381
+ // containers: { cna: { ...the actual container... } }
382
+ // }
383
+ //
384
+ // CVE id resolution:
385
+ // - If the advisory carries `cve_id`, the mapper stamps it into
386
+ // `cveMetadata.cveId` automatically.
387
+ // - The caller can also pass an explicit `cveId` option as a safety
388
+ // belt. If both are present and they disagree, that's a hard error.
389
+ // - If neither side has a CVE id, the record carries no `cveMetadata`;
390
+ // the publisher's CLI arg becomes the sole source of truth.
391
+ //
392
+ // Inputs:
393
+ // advisory — the GHSA advisory object (REST API shape)
394
+ // cveId — optional explicit CVE id. Cross-checked against
395
+ // `advisory.cve_id` when both are present.
396
+ // engineId — override the x_generator.engine string (default:
397
+ // this package + version)
398
+ // assignerOrgId — override the CNA org UUID (default: OpenJS Foundation)
399
+ //
400
+ // References are derived from `advisory.references[]` with a fallback
401
+ // to scraping the description's "References" section for per-repo
402
+ // advisories where the API returns null.
403
+ //
404
+ // Exposed for tests and for callers that already have the advisory in hand.
405
+ export function mapAdvisoryToRecord (advisory, {
406
+ cveId,
407
+ engineId = ENGINE_ID,
408
+ assignerOrgId = OPENJS_CNA_ORG_ID
409
+ } = {}) {
410
+ if (!advisory || typeof advisory !== 'object') {
411
+ throw new TypeError('mapAdvisoryToRecord: `advisory` must be the GHSA object')
412
+ }
413
+ // Reconcile the two possible CVE-id sources. Both API endpoints
414
+ // (per-repo and global) expose `cve_id` when one's been assigned.
415
+ const advisoryCveId = advisory.cve_id || undefined
416
+ if (cveId && advisoryCveId && cveId !== advisoryCveId) {
417
+ throw new Error(
418
+ `CVE id mismatch: caller passed ${cveId}, advisory reports ${advisoryCveId}. ` +
419
+ 'Refusing to publish — re-check which CVE you meant to associate.'
420
+ )
421
+ }
422
+ const resolvedCveId = cveId ?? advisoryCveId
423
+
424
+ // The envelope, providerMetadata and x_generator come from
425
+ // buildRecordEnvelope so every converter emits them the same way.
426
+ return buildRecordEnvelope({
427
+ cveId: resolvedCveId,
428
+ assignerOrgId,
429
+ engineId,
430
+ cna: {
431
+ title: advisory.summary,
432
+ descriptions: buildDescriptions(advisory),
433
+ affected: buildAffected(advisory),
434
+ problemTypes: buildProblemTypes(advisory),
435
+ metrics: buildMetrics(advisory),
436
+ credits: buildCredits(advisory),
437
+ references: buildReferences(advisory)
438
+ }
439
+ })
440
+ }
441
+
442
+ // Fetch + map convenience. Returns the full CVE Record (Vulnogram-
443
+ // compatible). `fetchImpl` is injectable for tests.
444
+ export async function convertGhsaToRecord ({
445
+ source, cveId, engineId, assignerOrgId, fetchImpl
446
+ } = {}) {
447
+ if (!source) throw new Error('convertGhsaToRecord: `source` is required')
448
+ const advisory = await fetchAdvisory(source, { fetchImpl })
449
+ return mapAdvisoryToRecord(advisory, { cveId, engineId, assignerOrgId })
450
+ }