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