diff --git a/components/git/security.js b/components/git/security.js index a73b3d6a..ab776628 100644 --- a/components/git/security.js +++ b/components/git/security.js @@ -40,6 +40,11 @@ const securityOptions = { describe: 'Request CVEs for a security release', type: 'boolean' }, + 'publish-cve': { + describe: + 'Publish reserved CVEs to MITRE via the OpenJS Foundation CNA', + type: 'boolean' + }, 'post-release': { describe: 'Create the post-release announcement to the given nodejs.org folder', type: 'string' @@ -88,6 +93,9 @@ export function builder(yargs) { ).example( 'git node security --cleanup', 'Cleanup the security release. Merge the PR and close H1 reports' + ).example( + 'git node security --publish-cve', + 'Publish reserved CVEs to MITRE via the OpenJS Foundation CNA' ); } @@ -119,6 +127,9 @@ export function handler(argv) { if (argv['request-cve']) { return requestCVEs(cli, argv); } + if (argv['publish-cve']) { + return publishCVEs(cli, argv); + } if (argv['post-release']) { return createPostRelease(cli, argv); } @@ -157,6 +168,11 @@ async function requestCVEs(cli) { return hackerOneCve.requestCVEs(); } +async function publishCVEs(cli) { + const release = new UpdateSecurityRelease(cli); + return release.publishCVEs(); +} + async function createPostRelease(cli, argv) { const nodejsOrgFolder = argv['post-release']; const blog = new SecurityBlog(cli); diff --git a/docs/git-node.md b/docs/git-node.md index f36be7c5..9a984d4b 100644 --- a/docs/git-node.md +++ b/docs/git-node.md @@ -455,6 +455,29 @@ $ ncu-config --global set h1_username $H1_TOKEN access. - `h1_username`: HackerOne API Token username. +#### Optional: source CVEs from the OpenJS Foundation CNA + +By default, `--request-cve` reserves CVE identifiers through HackerOne (which +acts as Node.js' CNA). You can switch to the **OpenJS Foundation CNA** as the +issuer by setting `cve_source: 'openjs-cna'` in `.ncurc`. HackerOne is still +used for the bug-bounty workflow (triage, sync, disclosure) and is updated with +the resulting CVE id at the end of the request flow, so reports stay in sync. + +```console +$ ncu-config --global set cve_source openjs-cna +$ ncu-config --global set -x openjs_cna_token ":" +$ ncu-config --global set -x openjs_cna_worker_url "https://.workers.dev" +``` + +- `cve_source`: `hackerone` (default) or `openjs-cna`. +- `openjs_cna_token`: **secret**. Bearer in `bucket:secret` form (e.g. + `prod_nodejs:<256 hex chars>`). +- `openjs_cna_worker_url`: The Cloudflare Worker URL for your + deployment. + +To revert to HackerOne, either delete `cve_source` or set it back to +`hackerone`. + ### `git node security --start` This command creates the Next Security Issue in Node.js private repository diff --git a/lib/auth.js b/lib/auth.js index 41f7d0a9..17911c69 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -107,6 +107,30 @@ async function auth( const h1 = encode(h1_username, h1_token); setOwnProperty(result, 'h1', h1); return h1; + }, + + get cna() { + const { openjs_cna_token, openjs_cna_worker_url } = getMergedConfig(); + if (!openjs_cna_token || !openjs_cna_worker_url) { + throw new Error( + 'OpenJS CNA credentials are not configured. Both values are secrets ' + + 'and must be stored encrypted with the `-x` flag. Run:\n' + + ' ncu-config --global set -x openjs_cna_token \n' + + ' ncu-config --global set -x openjs_cna_worker_url ' + ); + } + if (!/^[a-z0-9_]+:[0-9a-f]+$/i.test(openjs_cna_token)) { + throw new Error( + 'openjs_cna_token is misformatted; expected `:`' + ); + } + + const cna = { + token: openjs_cna_token, + worker_url: openjs_cna_worker_url.replace(/\/$/, '') + }; + setOwnProperty(result, 'cna', cna); + return cna; } }; if (options.github) { diff --git a/lib/request.js b/lib/request.js index c28e92a0..01587424 100644 --- a/lib/request.js +++ b/lib/request.js @@ -339,4 +339,101 @@ export default class Request { return all; } + + // --------------------------------------------------------------------------- + // OpenJS Foundation CNA — github.com/UlisesGascon/openjs-cna-api-poc + // + // The Worker is a thin edge in front of `workflow_dispatch`. POST /dispatch + // returns a Worker-minted correlation_id; GET /runs/{id} polls until the + // backing workflow run completes. + // --------------------------------------------------------------------------- + + async cnaDispatch(operation, inputs = {}) { + const { worker_url, token } = this.credentials.cna; + const url = `${worker_url}/dispatch`; + const options = { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'User-Agent': 'node-core-utils', + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + body: JSON.stringify({ operation, inputs }) + }; + const response = await this.json(url, options); + if (response.error) { + throw new Error( + `OpenJS CNA dispatch failed (${operation}): ${response.error}` + ); + } + return response; + } + + async cnaPoll(correlationId) { + const { worker_url, token } = this.credentials.cna; + const url = `${worker_url}/runs/${correlationId}`; + const options = { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + 'User-Agent': 'node-core-utils', + Accept: 'application/json' + } + }; + return this.json(url, options); + } + + // Polls /runs/{id} until the workflow reports status === 'completed'. + // Throws on timeout or on a `conclusion` other than 'success'. Default + // timeout is generous (10 min) — publish-cve in particular has been seen + // to take 3-4 min during MITRE staging slowdowns. + async cnaWaitForCompletion(correlationId, { timeoutMs = 600_000, intervalMs = 5_000 } = {}) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const run = await this.cnaPoll(correlationId); + if (run.status === 'completed') { + if (run.conclusion !== 'success') { + throw new Error( + `OpenJS CNA run ${correlationId} concluded with ` + + `'${run.conclusion}'. See ${run.url} for details.` + ); + } + return run; + } + await new Promise(resolve => setTimeout(resolve, intervalMs)); + } + throw new Error( + `OpenJS CNA run ${correlationId} did not complete within ${timeoutMs}ms` + ); + } + + // Reserve a CVE id via the OpenJS CNA. /runs/{correlation_id} surfaces the + // operation result (e.g. `{ cve_id: "CVE-2026-..." }`) on the same response + // once the run completes, so the caller only needs to await this one method. + async cnaReserveCve(opts = {}) { + const dispatch = await this.cnaDispatch('reserve-cve', opts); + const run = await this.cnaWaitForCompletion(dispatch.correlation_id, opts); + return { + correlation_id: dispatch.correlation_id, + run_url: run.url, + run_id: run.run_id, + result: run.result // shape depends on the operation; reserve returns { cve_id } + }; + } + + // Publish a v5.2 CNA Container against an already-reserved CVE id. + async cnaPublishCve(cveId, cnaContainer, opts = {}) { + const dispatch = await this.cnaDispatch('publish-cve', { + cve_id: cveId, + cnaContainer + }); + const run = await this.cnaWaitForCompletion(dispatch.correlation_id, opts); + return { + correlation_id: dispatch.correlation_id, + run_url: run.url, + run_id: run.run_id, + result: run.result + }; + } } diff --git a/lib/update_security_release.js b/lib/update_security_release.js index 8125e648..4f3a1bc0 100644 --- a/lib/update_security_release.js +++ b/lib/update_security_release.js @@ -13,6 +13,27 @@ import auth from './auth.js'; import Request from './request.js'; import nv from '@pkgjs/nv'; import semver from 'semver'; +import { getMergedConfig } from './config.js'; + +// Read once from .ncurc. Defaults to 'hackerone' so the existing behavior is +// unchanged for anyone who hasn't opted in to the OpenJS CNA path. +function getCveSource() { + try { + const { cve_source } = getMergedConfig(); + return cve_source === 'openjs-cna' ? 'openjs-cna' : 'hackerone'; + } catch { + return 'hackerone'; + } +} + +export function releaseBlogUrlFromDate(releaseDate) { + if (!releaseDate || releaseDate === 'TBD') return null; + const d = new Date(releaseDate.replaceAll('/', '-')); + if (Number.isNaN(d.getTime())) return null; + const month = d.toLocaleString('en-US', { month: 'long' }).toLowerCase(); + const year = d.getFullYear(); + return `https://nodejs.org/en/blog/vulnerability/${month}-${year}-security-releases`; +} export default class UpdateSecurityRelease extends SecurityRelease { async sync() { @@ -134,15 +155,27 @@ export default class UpdateSecurityRelease extends SecurityRelease { } async requestCVEs() { + const cveSource = getCveSource(); + // Always need github + h1 (h1 stays as the bug-bounty source even when CVEs + // come from OpenJS CNA — sync-back of the CVE id to the H1 report still + // happens). Add the `cna` token only when the operator opted in. const credentials = await auth({ github: true, - h1: true + h1: true, + cna: cveSource === 'openjs-cna' }); const vulnerabilitiesJSONPath = this.getVulnerabilitiesJSONPath(); const content = this.readVulnerabilitiesJSON(vulnerabilitiesJSONPath); const { reports } = content; this.validateReportsForCVE(reports); const req = new Request(credentials); + + if (cveSource === 'openjs-cna') { + this.cli.info('Requesting CVEs via the OpenJS Foundation CNA'); + await this.promptCVECreationViaOpenJsCna(req, reports, content); + return; + } + const programId = await this.getNodeProgramId(req); await this.promptCVECreation(req, reports, programId, content); } @@ -266,6 +299,202 @@ Summary: ${summary}\n`, } } + // Mirror of promptCVECreation that sources the CVE id from the OpenJS + // Foundation CNA (workflow-backed) instead of HackerOne's cve_requests API. + // The CVE is then pushed BACK to HackerOne via updateHackonerReportCve so the + // bug-bounty report stays in sync — H1 is still the source of truth for the + // report-level state, just not the issuer of the CVE id. + async promptCVECreationViaOpenJsCna(req, reports, content) { + const supportedVersions = (await nv('supported')); + const eolVersions = (await nv('eol')); + for (const report of reports) { + const { id, summary, title, affectedVersions, cveIds, link } = report; + if (cveIds?.length) continue; + + let severity = report.severity; + if (!severity.cvss_vector_string || !severity.weakness_id) { + try { + const h1Report = await req.getReport(id); + if (!h1Report.data.relationships.severity?.data.attributes.cvss_vector_string) { + throw new Error('No severity found'); + } + severity = { + weakness_id: h1Report.data.relationships.weakness?.data.id, + cvss_vector_string: + h1Report.data.relationships.severity?.data.attributes.cvss_vector_string, + rating: h1Report.data.relationships.severity?.data.attributes.rating + }; + } catch { + this.cli.error(`Couldnt not retrieve severity from report ${id}, skipping...`); + continue; + } + } + + const { cvss_vector_string } = severity; + const create = await this.cli.prompt( + `Reserve a CVE via the OpenJS Foundation CNA for: \n +Title: ${title}\n +Link: ${link}\n +Affected versions: ${affectedVersions.join(', ')}\n +Vector: ${cvss_vector_string}\n +Summary: ${summary}\n`, + { defaultAnswer: true }); + if (!create) continue; + + const { patchedVersions } = + await this.calculateVersions(affectedVersions, supportedVersions, eolVersions); + + this.cli.startSpinner(`Reserving CVE via OpenJS CNA for report ${id}...`); + let reserved; + try { + // /runs/{correlation_id} returns the operation's structured result on + // the same response now (see the workflow's CNA_RESULT marker block + // + the Worker's extractRunResult). Every operation returns + // `{ cve_id, ... }`-shaped JSON; we just read it off the run object. + reserved = await req.cnaReserveCve(); + } catch (e) { + this.cli.stopSpinner(`Failed to reserve CVE for report ${id}: ${e.message}`); + continue; + } + const cveId = reserved.result?.cve_id; + if (!cveId) { + this.cli.stopSpinner( + `Reserve completed but no CVE id in /runs/{id} response for report ${id}; ` + + `inspect ${reserved.run_url} and retry.` + ); + continue; + } + this.cli.stopSpinner(`Reserved ${cveId} for report ${id} (run ${reserved.run_url}).`); + + report.cveIds = [cveId]; + report.patchedVersions = patchedVersions; + this.updateVulnerabilitiesJSON(content); + + // Push the CVE id back to HackerOne so the report carries it. This is the + // exact same call the HackerOne-sourced flow makes — H1 cares that the + // report has a CVE, not who issued it. + await this.updateHackonerReportCve(req, report); + } + } + + // ----------------------------------------------------------------------- + // git node security --publish-cve + // ----------------------------------------------------------------------- + // Publish each reserved CVE in vulnerabilities.json by POSTing a v5.2 + // CNA Container to MITRE via the OpenJS Foundation CNA. Reservation and + // publication are intentionally split: reserve early (so the CVE id can go + // into the release changelog and blog post), publish late (after the + // release has shipped on nodejs.org). + // + // Only runs when cve_source === 'openjs-cna'. With HackerOne, MITRE is + // poked automatically via `auto_submit_on_publicly_disclosing_report`, so + // there's nothing for NCU to do. + async publishCVEs() { + const cveSource = getCveSource(); + if (cveSource !== 'openjs-cna') { + this.cli.warn( + 'cve_source is not "openjs-cna" — nothing to do. ' + + 'HackerOne publishes CVEs automatically via auto_submit_on_publicly_disclosing_report.' + ); + return; + } + const credentials = await auth({ github: true, cna: true }); + const req = new Request(credentials); + const vulnerabilitiesJSONPath = this.getVulnerabilitiesJSONPath(); + const content = this.readVulnerabilitiesJSON(vulnerabilitiesJSONPath); + const reports = (content.reports || []).filter(r => r.cveIds?.length); + if (!reports.length) { + this.cli.info('No reports with reserved CVEs to publish.'); + return; + } + const releaseBlogUrl = releaseBlogUrlFromDate(content.releaseDate); + if (!releaseBlogUrl) { + this.cli.error( + `vulnerabilities.json has no usable releaseDate (got ${JSON.stringify(content.releaseDate)}). ` + + 'Set the release date before publishing so the MITRE record can point at the per-release blog post.' + ); + return; + } + for (const report of reports) { + let publishedAny = false; + for (const cveId of report.cveIds) { + if (report.publishedAt) { + this.cli.info(`Skipping ${cveId} (already published).`); + continue; + } + const container = this.buildCnaContainerFromReport(report, cveId, releaseBlogUrl); + const confirm = await this.cli.prompt( + `Publish ${cveId} for "${report.title}"?`, + { defaultAnswer: true } + ); + if (!confirm) continue; + this.cli.startSpinner(`Publishing ${cveId} to MITRE…`); + try { + await req.cnaPublishCve(cveId, container); + } catch (e) { + this.cli.stopSpinner(`Failed to publish ${cveId}: ${e.message}`); + continue; + } + this.cli.stopSpinner(`Published ${cveId}.`); + publishedAny = true; + } + if (publishedAny) { + report.publishedAt = new Date().toISOString(); + this.updateVulnerabilitiesJSON(content); + } + } + } + + // ----------------------------------------------------------------------- + // vulnerabilities.json → v5.2 CNA Container shape + // ----------------------------------------------------------------------- + // Minimal viable mapper. Covers Impact / Affected / References / CWE which + // are the load-bearing fields MITRE requires (providerMetadata.orgId is + // server-injected by MITRE based on the authed CNA org, so omitted here). + // Designed to be evolved by replacing this method, not by sprinkling more + // mapping code into publishCVEs. + buildCnaContainerFromReport(report, cveId, releaseBlogUrl) { + const description = report.summary || report.title || ''; + const cweId = report.severity?.weakness_id + ? `CWE-${report.severity.weakness_id}` + : null; + const cvssVector = report.severity?.cvss_vector_string; + const patchedVersions = report.patchedVersions || []; + // Pair each affected version with the patched version on the same major + // release line. Strict equality on the numeric major prevents '18.x' from + // accidentally matching a patched '1.x' or '180.x' via .startsWith. + const lessThanFor = (v) => { + const major = v.split('.')[0]; + return patchedVersions.find(pv => pv.split('.')[0] === major); + }; + return { + title: report.title, + descriptions: [{ lang: 'en', value: description }], + affected: [{ + vendor: 'nodejs', + product: 'node', + defaultStatus: 'unaffected', + versions: (report.affectedVersions || []).map(v => ({ + version: v, + status: 'affected', + lessThan: lessThanFor(v) || undefined + })) + }], + problemTypes: cweId ? [{ + descriptions: [{ + lang: 'en', + cweId, + type: 'CWE', + description: cweId + }] + }] : undefined, + metrics: cvssVector ? [{ + cvssV3_1: { vectorString: cvssVector } + }] : undefined, + references: [{ url: releaseBlogUrl, tags: ['vendor-advisory'] }] + }; + } + async getNodeProgramId(req) { const programs = await req.getPrograms(); const { data } = programs; diff --git a/test/unit/request.test.js b/test/unit/request.test.js new file mode 100644 index 00000000..74b6444a --- /dev/null +++ b/test/unit/request.test.js @@ -0,0 +1,167 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import Request from '../../lib/request.js'; + +const CREDENTIALS = { + cna: { + token: 'test_nodejs:0123456789abcdef', + worker_url: 'https://worker.example.workers.dev' + } +}; + +const createMockRequest = (responder) => { + const calls = []; + const req = new Request(CREDENTIALS); + req.json = async (url, options) => { + calls.push({ url, options }); + return responder(url, options); + }; + return { req, calls }; +}; + +describe('Request#cnaDispatch', () => { + it('POSTs /dispatch with the bearer token and JSON body', async () => { + const { req, calls } = createMockRequest( + () => ({ correlation_id: 'abc-123', status: 'queued' }) + ); + const result = await req.cnaDispatch('reserve-cve', { foo: 'bar' }); + assert.deepStrictEqual(result, { correlation_id: 'abc-123', status: 'queued' }); + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0].url, + 'https://worker.example.workers.dev/dispatch'); + assert.strictEqual(calls[0].options.method, 'POST'); + assert.strictEqual(calls[0].options.headers.Authorization, + 'Bearer test_nodejs:0123456789abcdef'); + assert.deepStrictEqual( + JSON.parse(calls[0].options.body), + { operation: 'reserve-cve', inputs: { foo: 'bar' } } + ); + }); + + it('throws when the Worker returns an error envelope', async () => { + const { req } = createMockRequest( + () => ({ error: 'unknown_operation' }) + ); + await assert.rejects( + () => req.cnaDispatch('do-the-thing'), + /OpenJS CNA dispatch failed \(do-the-thing\): unknown_operation/ + ); + }); +}); + +describe('Request#cnaPoll', () => { + it('GETs /runs/{id} with the bearer token', async () => { + const { req, calls } = createMockRequest(() => ({ + correlation_id: 'abc-123', + run_id: 999, + status: 'completed', + conclusion: 'success', + url: 'https://github.com/.../runs/999', + result: { cve_id: 'CVE-2026-1234' } + })); + const result = await req.cnaPoll('abc-123'); + assert.strictEqual(result.status, 'completed'); + assert.deepStrictEqual(result.result, { cve_id: 'CVE-2026-1234' }); + assert.strictEqual(calls[0].url, + 'https://worker.example.workers.dev/runs/abc-123'); + assert.strictEqual(calls[0].options.method, 'GET'); + }); +}); + +describe('Request#cnaWaitForCompletion', () => { + it('returns the run when status is completed and conclusion is success', async () => { + let i = 0; + const { req, calls } = createMockRequest(() => { + i += 1; + if (i === 1) return { status: 'in_progress' }; + return { + correlation_id: 'abc-123', + run_id: 42, + status: 'completed', + conclusion: 'success', + url: 'https://github.com/.../runs/42', + result: { cve_id: 'CVE-2026-1234' } + }; + }); + const run = await req.cnaWaitForCompletion('abc-123', + { timeoutMs: 1_000, intervalMs: 10 }); + assert.strictEqual(run.run_id, 42); + assert.deepStrictEqual(run.result, { cve_id: 'CVE-2026-1234' }); + assert.strictEqual(calls.length, 2); + }); + + it('throws when conclusion is failure', async () => { + const { req } = createMockRequest(() => ({ + status: 'completed', + conclusion: 'failure', + url: 'https://github.com/.../runs/13' + })); + await assert.rejects( + () => req.cnaWaitForCompletion('abc-123', + { timeoutMs: 1_000, intervalMs: 10 }), + /OpenJS CNA run abc-123 concluded with 'failure'/ + ); + }); + + it('throws on timeout', async () => { + const { req } = createMockRequest(() => ({ status: 'in_progress' })); + await assert.rejects( + () => req.cnaWaitForCompletion('abc-123', + { timeoutMs: 30, intervalMs: 10 }), + /did not complete within 30ms/ + ); + }); +}); + +describe('Request#cnaReserveCve', () => { + it('returns the reserved CVE id from the Worker result field', async () => { + let i = 0; + const { req } = createMockRequest(() => { + i += 1; + if (i === 1) return { correlation_id: 'abc-123', status: 'queued' }; + return { + correlation_id: 'abc-123', + run_id: 42, + status: 'completed', + conclusion: 'success', + url: 'https://github.com/.../runs/42', + result: { cve_id: 'CVE-2026-1234' } + }; + }); + const reserved = await req.cnaReserveCve({ timeoutMs: 1_000, intervalMs: 10 }); + assert.strictEqual(reserved.result.cve_id, 'CVE-2026-1234'); + assert.strictEqual(reserved.run_id, 42); + }); +}); + +describe('Request#cnaPublishCve', () => { + it('dispatches publish-cve with the cve_id and cnaContainer payload', async () => { + let i = 0; + const { req, calls } = createMockRequest(() => { + i += 1; + if (i === 1) return { correlation_id: 'def-456', status: 'queued' }; + return { + correlation_id: 'def-456', + run_id: 99, + status: 'completed', + conclusion: 'success', + url: 'https://github.com/.../runs/99', + result: { cve_id: 'CVE-2026-9999', published: true } + }; + }); + const container = { + title: 'demo', + descriptions: [{ lang: 'en', value: 'demo' }] + }; + const published = await req.cnaPublishCve('CVE-2026-9999', container, + { timeoutMs: 1_000, intervalMs: 10 }); + assert.strictEqual(published.result.cve_id, 'CVE-2026-9999'); + assert.strictEqual(published.result.published, true); + // First call is the dispatch; verify the body carried both the cve_id and + // the CNA Container payload so the workflow can hand them to MITRE as-is. + const dispatchBody = JSON.parse(calls[0].options.body); + assert.strictEqual(dispatchBody.operation, 'publish-cve'); + assert.strictEqual(dispatchBody.inputs.cve_id, 'CVE-2026-9999'); + assert.deepStrictEqual(dispatchBody.inputs.cnaContainer, container); + }); +}); diff --git a/test/unit/update_security_release.test.js b/test/unit/update_security_release.test.js new file mode 100644 index 00000000..37fa6c22 --- /dev/null +++ b/test/unit/update_security_release.test.js @@ -0,0 +1,135 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; + +import UpdateSecurityRelease, { + releaseBlogUrlFromDate +} from '../../lib/update_security_release.js'; +import SecurityBlog from '../../lib/security_blog.js'; + +describe('releaseBlogUrlFromDate', () => { + it('derives the per-release blog URL from YYYY/MM/DD', () => { + assert.strictEqual( + releaseBlogUrlFromDate('2024/04/10'), + 'https://nodejs.org/en/blog/vulnerability/april-2024-security-releases' + ); + }); + + it('produces the same slug shape SecurityBlog#getSlug uses', () => { + const blog = new SecurityBlog(); + const date = new Date('2024-04-10'); + const slug = blog.getSlug(date); + const url = releaseBlogUrlFromDate('2024/04/10'); + assert.strictEqual(url, `https://nodejs.org/en/blog/vulnerability/${slug}`); + }); + + it('returns null for the TBD sentinel', () => { + assert.strictEqual(releaseBlogUrlFromDate('TBD'), null); + }); + + it('returns null for empty / undefined input', () => { + assert.strictEqual(releaseBlogUrlFromDate(''), null); + assert.strictEqual(releaseBlogUrlFromDate(undefined), null); + assert.strictEqual(releaseBlogUrlFromDate(null), null); + }); + + it('returns null for unparseable strings', () => { + assert.strictEqual(releaseBlogUrlFromDate('not-a-date'), null); + }); +}); + +describe('UpdateSecurityRelease#buildCnaContainerFromReport', () => { + const RELEASE_URL = + 'https://nodejs.org/en/blog/vulnerability/april-2024-security-releases'; + + const baseReport = () => ({ + id: 12345, + title: 'node: vulnerable to demo', + summary: 'A demo vulnerability.', + link: 'https://hackerone.com/reports/12345', + affectedVersions: ['18.x', '20.x'], + patchedVersions: ['18.20.10', '20.18.2'], + severity: { + weakness_id: 400, + cvss_vector_string: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H', + rating: 'high' + } + }); + + it('produces a v5.2 CNA Container with title, descriptions, and CWE/CVSS', () => { + const release = new UpdateSecurityRelease(); + const container = release.buildCnaContainerFromReport( + baseReport(), 'CVE-2024-12345', RELEASE_URL + ); + assert.strictEqual(container.title, 'node: vulnerable to demo'); + assert.deepStrictEqual(container.descriptions, [ + { lang: 'en', value: 'A demo vulnerability.' } + ]); + assert.strictEqual(container.problemTypes[0].descriptions[0].cweId, 'CWE-400'); + assert.strictEqual( + container.metrics[0].cvssV3_1.vectorString, + 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H' + ); + }); + + it('uses the per-release blog URL as the only reference, never the HackerOne link', () => { + const release = new UpdateSecurityRelease(); + const container = release.buildCnaContainerFromReport( + baseReport(), 'CVE-2024-12345', RELEASE_URL + ); + assert.deepStrictEqual(container.references, [ + { url: RELEASE_URL, tags: ['vendor-advisory'] } + ]); + // Belt-and-braces: report.link is the private H1 report and must not leak. + const stringified = JSON.stringify(container); + assert.ok(!stringified.includes('hackerone.com'), + 'CNA Container leaked the private HackerOne report URL'); + }); + + it('pairs affected and patched versions by numeric major', () => { + const release = new UpdateSecurityRelease(); + const container = release.buildCnaContainerFromReport( + baseReport(), 'CVE-2024-12345', RELEASE_URL + ); + assert.deepStrictEqual(container.affected[0].versions, [ + { version: '18.x', status: 'affected', lessThan: '18.20.10' }, + { version: '20.x', status: 'affected', lessThan: '20.18.2' } + ]); + }); + + it('does not match patched versions across different majors via prefix', () => { + const release = new UpdateSecurityRelease(); + const container = release.buildCnaContainerFromReport({ + ...baseReport(), + affectedVersions: ['1.x'], + patchedVersions: ['18.20.10'] + }, 'CVE-2024-12345', RELEASE_URL); + assert.strictEqual(container.affected[0].versions[0].lessThan, undefined); + }); + + it('omits problemTypes when the report has no CWE id', () => { + const release = new UpdateSecurityRelease(); + const container = release.buildCnaContainerFromReport({ + ...baseReport(), + severity: { cvss_vector_string: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H' } + }, 'CVE-2024-12345', RELEASE_URL); + assert.strictEqual(container.problemTypes, undefined); + }); + + it('omits metrics when the report has no CVSS vector', () => { + const release = new UpdateSecurityRelease(); + const container = release.buildCnaContainerFromReport({ + ...baseReport(), + severity: { weakness_id: 400 } + }, 'CVE-2024-12345', RELEASE_URL); + assert.strictEqual(container.metrics, undefined); + }); + + it('falls back to title when summary is empty', () => { + const release = new UpdateSecurityRelease(); + const container = release.buildCnaContainerFromReport({ + ...baseReport(), + summary: '' + }, 'CVE-2024-12345', RELEASE_URL); + assert.strictEqual(container.descriptions[0].value, 'node: vulnerable to demo'); + }); +});