From 3c549597a1ddc05d180a0db335916fdac57c87bb Mon Sep 17 00:00:00 2001 From: Ulises Gascon Date: Wed, 28 Oct 2026 14:59:31 +0100 Subject: [PATCH 1/3] feat(security): add OpenJS CNA credentials and Worker client --- lib/auth.js | 29 +++++++++ lib/request.js | 83 +++++++++++++++++++++++ test/unit/request.test.js | 134 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 246 insertions(+) diff --git a/lib/auth.js b/lib/auth.js index 3e1ad68c..0a1d6820 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -106,6 +106,35 @@ async function auth( const h1 = encode(h1_username, h1_token); setOwnProperty(result, 'h1', h1); return h1; + }, + + // OpenJS Foundation CNA bucket token and Worker URL. Only used when + // `cve_source` is `openjs-cna`; HackerOne stays the bug-bounty source. + 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. Run:\n' + + ' ncu-config --global set -x openjs_cna_token \n' + + ' ncu-config --global set 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 `:`' + ); + } + if (!/^https:\/\//.test(openjs_cna_worker_url)) { + throw new Error( + 'openjs_cna_worker_url is misformatted; expected an https:// URL' + ); + } + 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 82350476..e5604e44 100644 --- a/lib/request.js +++ b/lib/request.js @@ -425,4 +425,87 @@ export default class Request { return all; } + + // --------------------------------------------------------------------------- + // OpenJS Foundation CNA + // + // The Worker is a thin edge in front of `workflow_dispatch`. POST /dispatch + // returns a correlation id; GET /runs/{id} polls until the backing workflow + // run completes and surfaces the operation result. + // --------------------------------------------------------------------------- + + cnaHeaders(extra = {}) { + const { token } = this.credentials.cna; + return { + Authorization: `Bearer ${token}`, + 'User-Agent': 'node-core-utils', + Accept: 'application/json', + ...extra + }; + } + + async cnaDispatch(operation, inputs = {}) { + const { worker_url } = this.credentials.cna; + const url = `${worker_url}/dispatch`; + const options = { + method: 'POST', + headers: this.cnaHeaders({ 'Content-Type': '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}` + ); + } + if (!response.correlation_id) { + throw new Error( + `OpenJS CNA dispatch (${operation}) returned no correlation_id` + ); + } + return response; + } + + async cnaPoll(correlationId) { + const { worker_url } = this.credentials.cna; + const url = `${worker_url}/runs/${correlationId}`; + const options = { + method: 'GET', + headers: this.cnaHeaders() + }; + const response = await this.json(url, options); + if (response.error) { + throw new Error( + `OpenJS CNA poll failed (${correlationId}): ${response.error}` + ); + } + return response; + } + + // Polls /runs/{id} until the run reports status === 'completed'. Throws a + // `CnaTimeoutError` when the deadline passes (the run keeps going on the + // server, so callers should persist the correlation id and resume later), + // and a plain Error on a `conclusion` other than 'success'. + 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)); + } + const error = new Error( + `OpenJS CNA run ${correlationId} did not complete within ${timeoutMs}ms` + ); + error.name = 'CnaTimeoutError'; + error.correlationId = correlationId; + throw error; + } } diff --git a/test/unit/request.test.js b/test/unit/request.test.js index 9fbbe608..e244150a 100644 --- a/test/unit/request.test.js +++ b/test/unit/request.test.js @@ -171,3 +171,137 @@ describe('Request', () => { }); }); }); + +describe('Request: OpenJS CNA', () => { + function createCnaRequest(responder) { + const calls = []; + const request = createRequest({}); + request.credentials = { + cna: { + token: 'test_nodejs:0123456789abcdef', + worker_url: 'https://worker.example.workers.dev' + } + }; + request.json = async(url, options) => { + calls.push({ url, options }); + return responder(url, options, calls.length); + }; + return { request, calls }; + } + + describe('cnaDispatch', () => { + it('POSTs /dispatch with the bearer token and a JSON body', async() => { + const { request, calls } = createCnaRequest( + () => ({ correlation_id: 'abc-123', status: 'queued' }) + ); + + const result = await request.cnaDispatch('reserve-cve'); + + 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.strictEqual(calls[0].options.headers['Content-Type'], 'application/json'); + assert.deepStrictEqual(JSON.parse(calls[0].options.body), + { operation: 'reserve-cve', inputs: {} }); + }); + + it('throws when the Worker returns an error envelope', async() => { + const { request } = createCnaRequest(() => ({ error: 'unknown_operation' })); + + await assert.rejects( + () => request.cnaDispatch('do-the-thing'), + /OpenJS CNA dispatch failed \(do-the-thing\): unknown_operation/ + ); + }); + + it('throws when the Worker returns no correlation id', async() => { + const { request } = createCnaRequest(() => ({ status: 'queued' })); + + await assert.rejects( + () => request.cnaDispatch('reserve-cve'), + /returned no correlation_id/ + ); + }); + }); + + describe('cnaPoll', () => { + it('GETs /runs/{id} with the bearer token', async() => { + const run = { + correlation_id: 'abc-123', + run_id: 999, + status: 'completed', + conclusion: 'success', + result: { cve_id: 'CVE-2026-1234' } + }; + const { request, calls } = createCnaRequest(() => run); + + const result = await request.cnaPoll('abc-123'); + + assert.deepStrictEqual(result, run); + assert.strictEqual(calls[0].url, 'https://worker.example.workers.dev/runs/abc-123'); + assert.strictEqual(calls[0].options.method, 'GET'); + assert.strictEqual(calls[0].options.headers.Authorization, + 'Bearer test_nodejs:0123456789abcdef'); + }); + + it('throws when the Worker returns an error envelope', async() => { + const { request } = createCnaRequest(() => ({ error: 'unauthorized' })); + + await assert.rejects( + () => request.cnaPoll('abc-123'), + /OpenJS CNA poll failed \(abc-123\): unauthorized/ + ); + }); + }); + + describe('cnaWaitForCompletion', () => { + it('polls until the run completes successfully', async() => { + const { request, calls } = createCnaRequest((url, options, call) => { + if (call === 1) return { status: 'in_progress' }; + return { + run_id: 42, + status: 'completed', + conclusion: 'success', + result: { cve_id: 'CVE-2026-1234' } + }; + }); + + const run = await request.cnaWaitForCompletion('abc-123', + { timeoutMs: 1_000, intervalMs: 1 }); + + assert.strictEqual(run.run_id, 42); + assert.deepStrictEqual(run.result, { cve_id: 'CVE-2026-1234' }); + assert.strictEqual(calls.length, 2); + }); + + it('throws when the run concludes without success', async() => { + const { request } = createCnaRequest(() => ({ + status: 'completed', + conclusion: 'failure', + url: 'https://github.com/example/runs/13' + })); + + await assert.rejects( + () => request.cnaWaitForCompletion('abc-123', { timeoutMs: 1_000, intervalMs: 1 }), + /OpenJS CNA run abc-123 concluded with 'failure'. See https:\/\/github.com\/example\/runs\/13/ + ); + }); + + it('throws a CnaTimeoutError carrying the correlation id on timeout', async() => { + const { request } = createCnaRequest(() => ({ status: 'in_progress' })); + + await assert.rejects( + () => request.cnaWaitForCompletion('abc-123', { timeoutMs: 5, intervalMs: 1 }), + (error) => { + assert.strictEqual(error.name, 'CnaTimeoutError'); + assert.strictEqual(error.correlationId, 'abc-123'); + assert.match(error.message, /did not complete within 5ms/); + return true; + } + ); + }); + }); +}); From c2bc03fe115b326af5767e545627ad50a087741b Mon Sep 17 00:00:00 2001 From: Ulises Gascon Date: Wed, 28 Oct 2026 14:59:37 +0100 Subject: [PATCH 2/3] feat(security): reserve CVEs through the OpenJS CNA in --request-cve --- lib/update_security_release.js | 219 +++++++++++++++++++++++-- test/unit/security_release.test.js | 252 ++++++++++++++++++++++++++++- 2 files changed, 457 insertions(+), 14 deletions(-) diff --git a/lib/update_security_release.js b/lib/update_security_release.js index 984b8477..6a2285eb 100644 --- a/lib/update_security_release.js +++ b/lib/update_security_release.js @@ -16,8 +16,52 @@ import Request from './request.js'; import nv from '@pkgjs/nv'; import semver from 'semver'; import { incrementVersion } from './release/utils.js'; +import { getMergedConfig } from './config.js'; + +export const CVE_SOURCES = ['hackerone', 'openjs-cna']; +const DEFAULT_CNA_TIMEOUT_SECONDS = 600; + +// `cve_source` picks who issues CVE ids: HackerOne (the default, unchanged +// behaviour) or the OpenJS Foundation CNA. HackerOne stays the bug-bounty +// source either way and is updated with the resulting id. +export function resolveCveSource(config = {}) { + const { cve_source: cveSource } = config; + if (cveSource === undefined || cveSource === null || cveSource === '') { + return 'hackerone'; + } + if (!CVE_SOURCES.includes(cveSource)) { + throw new Error( + `Unknown cve_source "${cveSource}"; expected one of: ${CVE_SOURCES.join(', ')}` + ); + } + return cveSource; +} export default class UpdateSecurityRelease extends SecurityRelease { + getCveSource() { + let config; + try { + config = getMergedConfig(); + } catch { + config = {}; + } + return resolveCveSource(config); + } + + // Seconds to wait for an OpenJS CNA run before saving the correlation id + // and giving control back. Supervised buckets wait for a coordinator, so + // this is a pause, not a failure. + getCnaTimeoutMs() { + let seconds; + try { + ({ openjs_cna_timeout: seconds } = getMergedConfig()); + } catch { + // fall through to the default + } + const parsed = Number(seconds); + return (Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_CNA_TIMEOUT_SECONDS) * 1000; + } + async sync() { await checkRemote(this.cli, this.repository); @@ -157,17 +201,30 @@ export default class UpdateSecurityRelease extends SecurityRelease { } async requestCVEs() { - const credentials = await auth({ - github: true, - h1: true - }); + const cveSource = this.getCveSource(); const vulnerabilitiesJSONPath = this.getVulnerabilitiesJSONPath(); const content = this.readVulnerabilitiesJSON(vulnerabilitiesJSONPath); const { reports } = content; this.validateReportsForCVE(reports); + + // A pending OpenJS reservation must be resumed through the CNA even if + // the operator flipped `cve_source` back to HackerOne in the meantime. + const hasPending = reports.some((report) => report.pendingCve?.correlationId); + const credentials = await auth({ + github: true, + h1: true, + cna: cveSource === 'openjs-cna' || hasPending + }); const req = new Request(credentials); - const programId = await this.getNodeProgramId(req); - await this.promptCVECreation(req, reports, programId, content); + if (cveSource === 'openjs-cna') { + this.cli.info( + `Requesting CVEs through the OpenJS Foundation CNA (${credentials.cna.worker_url})` + ); + } + const programId = cveSource === 'hackerone' + ? await this.getNodeProgramId(req) + : undefined; + await this.promptCVECreation(req, reports, programId, content, cveSource); } validateReportsForCVE(reports) { @@ -235,7 +292,7 @@ export default class UpdateSecurityRelease extends SecurityRelease { } } - async promptCVECreation(req, reports, programId, content) { + async promptCVECreation(req, reports, programId, content, cveSource = 'hackerone') { const reportsWithoutCVE = reports.filter((report) => !report.cveIds?.length); const requestAll = await this.promptRequestAllCVEs(reportsWithoutCVE); @@ -243,7 +300,9 @@ export default class UpdateSecurityRelease extends SecurityRelease { await confirmSecurityStep( this.cli, `request CVEs for ${reportsWithoutCVE.length} HackerOne reports`, - 'This submits CVE requests to HackerOne for every report without a CVE.' + cveSource === 'openjs-cna' + ? 'This reserves a CVE through the OpenJS Foundation CNA for every report without a CVE.' + : 'This submits CVE requests to HackerOne for every report without a CVE.' ); } @@ -257,7 +316,9 @@ export default class UpdateSecurityRelease extends SecurityRelease { supportedVersions, eolVersions, versionCache, - requestAll + requestAll, + content, + cveSource }); if (!successfulReports.length) { @@ -292,7 +353,9 @@ export default class UpdateSecurityRelease extends SecurityRelease { supportedVersions, eolVersions, versionCache, - requestAll + requestAll, + content, + cveSource = 'hackerone' }) { const successfulReports = []; try { @@ -304,7 +367,9 @@ export default class UpdateSecurityRelease extends SecurityRelease { supportedVersions, eolVersions, versionCache, - requestAll + requestAll, + content, + cveSource }); if (requested) successfulReports.push(report); } @@ -328,7 +393,9 @@ export default class UpdateSecurityRelease extends SecurityRelease { supportedVersions, eolVersions, versionCache, - requestAll + requestAll, + content, + cveSource = 'hackerone' }) { const { id, summary, title, affectedVersions, cveIds, link } = report; const affectedVersionLines = getAffectedVersionLines(affectedVersions); @@ -337,6 +404,17 @@ export default class UpdateSecurityRelease extends SecurityRelease { // mentioned in the report and not requested by Node if (cveIds?.length) return false; + // A previous run dispatched the reservation but did not see it complete + // (typically a supervised bucket waiting for a coordinator). Pick it up + // instead of reserving a second id. + if (report.pendingCve?.correlationId) { + this.cli.info( + `Resuming pending OpenJS CNA reservation for report ${id} ` + + `(correlation id ${report.pendingCve.correlationId})` + ); + return this.waitForReservedCve({ req, report, content }); + } + let severity = report.severity; if (!severity.cvss_vector_string || !severity.weakness_id) { @@ -359,8 +437,9 @@ export default class UpdateSecurityRelease extends SecurityRelease { const { cvss_vector_string, weakness_id } = severity; + const issuer = cveSource === 'openjs-cna' ? 'the OpenJS Foundation CNA' : 'HackerOne'; const create = requestAll || await this.cli.prompt( - `Request a CVE for: \n + `Request a CVE via ${issuer} for: \n Title: ${title}\n Link: ${link}\n Affected versions: ${affectedVersionLines.join(', ')}\n @@ -377,6 +456,18 @@ Summary: ${summary}\n`, eolVersions, versionCache ); + + if (cveSource === 'openjs-cna') { + if (!requestAll) { + await confirmSecurityStep( + this.cli, + `reserve a CVE through the OpenJS Foundation CNA for HackerOne report \`${id}\``, + 'This reserves a CVE id at MITRE through the OpenJS CNA for the selected report.' + ); + } + return this.reserveCveViaOpenJsCna({ req, report, content, patchedVersions }); + } + const body = { data: { type: 'cve-request', @@ -432,6 +523,108 @@ Summary: ${summary}\n`, return true; } + // Dispatches `reserve-cve` and saves the correlation id to + // vulnerabilities.json BEFORE waiting, so a timeout (or Ctrl-C) never + // orphans a reserved id: re-running --request-cve resumes from the file. + async reserveCveViaOpenJsCna({ req, report, content, patchedVersions }) { + const { id } = report; + this.cli.startSpinner(`Reserving a CVE for report ${id} through the OpenJS CNA...`); + let dispatch; + try { + dispatch = await req.cnaDispatch('reserve-cve'); + } catch (error) { + this.cli.stopSpinner( + `Error reserving a CVE for report ${id}`, + this.cli.SPINNER_STATUS.FAILED + ); + this.cli.error(error); + return false; + } + report.pendingCve = { + correlationId: dispatch.correlation_id, + dispatchedAt: new Date().toISOString(), + patchedVersions + }; + this.cli.stopSpinner( + `Dispatched reserve-cve for report ${id} (correlation id ${dispatch.correlation_id})` + ); + await this.persistPendingCve(content, report); + return this.waitForReservedCve({ req, report, content }); + } + + // Writes vulnerabilities.json locally (no commit) so the `pendingCve` + // marker survives an interrupted run. The commit happens with the regular + // post-request update. + async persistPendingCve(content, report) { + const vulnerabilitiesJSONPath = this.getVulnerabilitiesJSONPath(); + const action = report.pendingCve + ? `This saves the pending OpenJS CNA correlation id for report ${report.id} ` + + 'so the reservation can be resumed if this command is interrupted.' + : `This clears the failed OpenJS CNA reservation marker for report ${report.id} ` + + 'so the next attempt dispatches a fresh reservation.'; + await writeSecurityFile( + this.cli, + vulnerabilitiesJSONPath, + JSON.stringify(content, null, 2), + action + ); + } + + async waitForReservedCve({ req, report, content }) { + const { id, pendingCve } = report; + const { correlationId } = pendingCve; + this.cli.startSpinner( + `Waiting for the OpenJS CNA to reserve a CVE for report ${id} (${correlationId})...` + ); + let run; + try { + run = await req.cnaWaitForCompletion(correlationId, { + timeoutMs: this.getCnaTimeoutMs() + }); + } catch (error) { + this.cli.stopSpinner( + `CVE reservation for report ${id} did not complete`, + this.cli.SPINNER_STATUS.FAILED + ); + if (error.name === 'CnaTimeoutError') { + // Keep pendingCve: the run is still going on the server side. + this.cli.warn( + `Still waiting on ${correlationId} (supervised buckets wait for a ` + + 'coordinator approval). The correlation id is saved in ' + + 'vulnerabilities.json; re-run `git node security --request-cve` to resume.' + ); + return false; + } + // The run concluded without a CVE: drop the marker so the next attempt + // dispatches a fresh reservation. + this.cli.error(error); + delete report.pendingCve; + await this.persistPendingCve(content, report); + return false; + } + + const cveId = run.result?.cve_id; + if (!cveId) { + this.cli.stopSpinner( + `Reservation for report ${id} completed without a CVE id`, + this.cli.SPINNER_STATUS.FAILED + ); + this.cli.error( + `Run ${correlationId} succeeded but returned no cve_id. ` + + `Inspect ${run.url ?? 'the run'} with an OpenJS CNA coordinator before retrying.` + ); + delete report.pendingCve; + await this.persistPendingCve(content, report); + return false; + } + + report.cveIds = [cveId]; + report.patchedVersions = pendingCve.patchedVersions; + delete report.pendingCve; + this.cli.stopSpinner(`Reserved ${cveId} for report ${id}`); + return true; + } + async applySuccessfulCVEUpdates(req, content, successfulReports) { this.cli.info('CVE requests succeeded:'); for (const { id, cveIds } of successfulReports) { diff --git a/test/unit/security_release.test.js b/test/unit/security_release.test.js index a5447714..7e1e6815 100644 --- a/test/unit/security_release.test.js +++ b/test/unit/security_release.test.js @@ -9,7 +9,9 @@ import PrepareSecurityRelease, { getNextTuesdayReleaseDateChoices, getNextTuesdayReleaseDates } from '../../lib/prepare_security.js'; -import UpdateSecurityRelease from '../../lib/update_security_release.js'; +import UpdateSecurityRelease, { + resolveCveSource +} from '../../lib/update_security_release.js'; import { getAffectedVersionLines, getDependencyUpdates, @@ -1056,3 +1058,251 @@ describe('security_release: getDependencyUpdates', () => { assert.deepStrictEqual(getDependencyUpdates({}), []); }); }); + +describe('security_release: OpenJS CNA reservation', () => { + function createCnaTimeoutError(correlationId) { + const error = new Error(`OpenJS CNA run ${correlationId} did not complete`); + error.name = 'CnaTimeoutError'; + error.correlationId = correlationId; + return error; + } + + function createCli(events) { + return { + SPINNER_STATUS: { FAILED: 'failed' }, + info(message) { events.push(`info:${message}`); }, + warn(message) { events.push(`warn:${message}`); }, + error(message) { + events.push(`error:${message instanceof Error ? message.message : message}`); + }, + startSpinner(message) { events.push(`spinner:${message}`); }, + stopSpinner(message, status) { events.push(`stop:${status ?? 'ok'}:${message}`); }, + prompt() { return true; } + }; + } + + it('defaults cve_source to hackerone and rejects unknown values', () => { + assert.strictEqual(resolveCveSource({}), 'hackerone'); + assert.strictEqual(resolveCveSource({ cve_source: '' }), 'hackerone'); + assert.strictEqual(resolveCveSource({ cve_source: 'hackerone' }), 'hackerone'); + assert.strictEqual(resolveCveSource({ cve_source: 'openjs-cna' }), 'openjs-cna'); + assert.throws( + () => resolveCveSource({ cve_source: 'mitre' }), + /Unknown cve_source "mitre"; expected one of: hackerone, openjs-cna/ + ); + }); + + it('persists the correlation id before waiting and records the reserved id', async() => { + const events = []; + const release = new UpdateSecurityRelease(createCli(events)); + release.getCnaTimeoutMs = () => 1_000; + release.persistPendingCve = async(content, report) => { + events.push(`persist:${report.id}:${JSON.stringify(report.pendingCve)}`); + }; + const req = { + async cnaDispatch(operation) { + events.push(`dispatch:${operation}`); + return { correlation_id: 'corr-1', status: 'queued' }; + }, + async cnaWaitForCompletion(correlationId, { timeoutMs }) { + events.push(`wait:${correlationId}:${timeoutMs}`); + return { status: 'completed', conclusion: 'success', result: { cve_id: 'CVE-2026-1' } }; + } + }; + const report = { id: '1', cveIds: [] }; + const content = { reports: [report] }; + + const reserved = await release.reserveCveViaOpenJsCna({ + req, report, content, patchedVersions: ['24.18.1'] + }); + + assert.strictEqual(reserved, true); + assert.deepStrictEqual(report.cveIds, ['CVE-2026-1']); + assert.deepStrictEqual(report.patchedVersions, ['24.18.1']); + assert.strictEqual(report.pendingCve, undefined); + const order = events + .filter((event) => /^(dispatch|persist|wait):/.test(event)) + .map((event) => event.split(':')[0]); + assert.deepStrictEqual(order, ['dispatch', 'persist', 'wait']); + const persisted = events.find((event) => event.startsWith('persist:')); + const pending = JSON.parse(persisted.slice('persist:1:'.length)); + assert.strictEqual(pending.correlationId, 'corr-1'); + assert.deepStrictEqual(pending.patchedVersions, ['24.18.1']); + assert.ok(pending.dispatchedAt); + assert.ok(events.includes('wait:corr-1:1000')); + }); + + it('keeps the pending marker when the wait times out', async() => { + const events = []; + const release = new UpdateSecurityRelease(createCli(events)); + release.getCnaTimeoutMs = () => 1_000; + release.persistPendingCve = async() => { events.push('persist'); }; + const req = { + async cnaWaitForCompletion(correlationId) { + throw createCnaTimeoutError(correlationId); + } + }; + const report = { + id: '1', + cveIds: [], + pendingCve: { correlationId: 'corr-1', patchedVersions: ['24.18.1'] } + }; + + const content = { reports: [report] }; + const reserved = await release.waitForReservedCve({ req, report, content }); + + assert.strictEqual(reserved, false); + assert.deepStrictEqual(report.cveIds, []); + assert.strictEqual(report.pendingCve.correlationId, 'corr-1'); + assert.ok(!events.includes('persist'), 'timeout must not rewrite the file'); + assert.ok(events.some((event) => + event.startsWith('warn:Still waiting on corr-1') && event.includes('--request-cve'))); + }); + + it('drops and persists the cleared marker when the run fails', async() => { + const events = []; + const release = new UpdateSecurityRelease(createCli(events)); + release.getCnaTimeoutMs = () => 1_000; + release.persistPendingCve = async(content, persisted) => { + events.push(`persist:${persisted.pendingCve === undefined ? 'cleared' : 'pending'}`); + }; + const req = { + async cnaWaitForCompletion(correlationId) { + throw new Error(`OpenJS CNA run ${correlationId} concluded with 'failure'`); + } + }; + const report = { + id: '1', + cveIds: [], + pendingCve: { correlationId: 'corr-1', patchedVersions: ['24.18.1'] } + }; + + const content = { reports: [report] }; + const reserved = await release.waitForReservedCve({ req, report, content }); + + assert.strictEqual(reserved, false); + assert.strictEqual(report.pendingCve, undefined); + assert.ok(events.includes("error:OpenJS CNA run corr-1 concluded with 'failure'")); + assert.ok(events.includes('persist:cleared')); + }); + + it('drops the pending marker when the run returns no CVE id', async() => { + const events = []; + const release = new UpdateSecurityRelease(createCli(events)); + release.getCnaTimeoutMs = () => 1_000; + release.persistPendingCve = async(content, persisted) => { + events.push(`persist:${persisted.pendingCve === undefined ? 'cleared' : 'pending'}`); + }; + const req = { + async cnaWaitForCompletion() { + return { status: 'completed', conclusion: 'success', url: 'https://example/run/1' }; + } + }; + const report = { + id: '1', + cveIds: [], + pendingCve: { correlationId: 'corr-1', patchedVersions: ['24.18.1'] } + }; + + const content = { reports: [report] }; + const reserved = await release.waitForReservedCve({ req, report, content }); + + assert.strictEqual(reserved, false); + assert.strictEqual(report.pendingCve, undefined); + assert.ok(events.some((event) => + event.startsWith('error:Run corr-1 succeeded but returned no cve_id'))); + assert.ok(events.includes('persist:cleared')); + }); + + it('resumes a pending reservation without dispatching again', async() => { + const events = []; + const release = new UpdateSecurityRelease(createCli(events)); + release.getCnaTimeoutMs = () => 1_000; + const req = { + async cnaDispatch() { + throw new Error('must not dispatch while a reservation is pending'); + }, + async cnaWaitForCompletion(correlationId) { + events.push(`wait:${correlationId}`); + return { status: 'completed', conclusion: 'success', result: { cve_id: 'CVE-2026-2' } }; + } + }; + const report = { + id: '2', + cveIds: [], + affectedVersions: ['24.x'], + severity: { weakness_id: '1', cvss_vector_string: 'CVSS:3.0/AV:N' }, + pendingCve: { correlationId: 'corr-2', patchedVersions: ['24.18.1'] } + }; + + const reserved = await release.requestReportCVE({ + req, + report, + supportedVersions: [], + eolVersions: [], + versionCache: new Map(), + requestAll: true, + content: { reports: [report] }, + cveSource: 'openjs-cna' + }); + + assert.strictEqual(reserved, true); + assert.deepStrictEqual(report.cveIds, ['CVE-2026-2']); + assert.deepStrictEqual(report.patchedVersions, ['24.18.1']); + assert.strictEqual(report.pendingCve, undefined); + assert.ok(events.includes('wait:corr-2')); + assert.ok(events.some((event) => + event.startsWith('info:Resuming pending OpenJS CNA reservation for report 2'))); + }); + + it('reserves through the OpenJS CNA instead of HackerOne when selected', async() => { + const events = []; + const cli = createCli(events); + cli.prompt = (message, options) => { + if (options?.questionType === 'input') return '24.18.0'; + return true; + }; + cli.promptRadio = (message, choices) => choices[0]; + const release = new UpdateSecurityRelease(cli); + release.getCnaTimeoutMs = () => 1_000; + release.persistPendingCve = async() => { events.push('persist'); }; + const req = { + async requestCVE() { + throw new Error('HackerOne cve_requests must not be called'); + }, + async cnaDispatch(operation) { + events.push(`dispatch:${operation}`); + return { correlation_id: 'corr-3' }; + }, + async cnaWaitForCompletion() { + return { status: 'completed', conclusion: 'success', result: { cve_id: 'CVE-2026-3' } }; + } + }; + const report = { + id: '3', + title: 'Third report', + link: 'https://hackerone.com/reports/3', + summary: 'Third summary', + cveIds: [], + affectedVersions: ['24.x'], + severity: { weakness_id: '1', cvss_vector_string: 'CVSS:3.0/AV:N' } + }; + + const reserved = await release.requestReportCVE({ + req, + report, + supportedVersions: [{ major: 24, version: '24.18.0' }], + eolVersions: [], + versionCache: new Map(), + requestAll: true, + content: { reports: [report] }, + cveSource: 'openjs-cna' + }); + + assert.strictEqual(reserved, true); + assert.deepStrictEqual(report.cveIds, ['CVE-2026-3']); + assert.deepStrictEqual(report.patchedVersions, ['24.18.1']); + assert.ok(events.includes('dispatch:reserve-cve')); + assert.ok(events.includes('persist')); + }); +}); From a0a06cebb91dfe0c2174d54748289bdde436b07b Mon Sep 17 00:00:00 2001 From: Ulises Gascon Date: Wed, 28 Oct 2026 14:59:39 +0100 Subject: [PATCH 3/3] doc: document cve_source and OpenJS CNA configuration --- docs/git-node.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/git-node.md b/docs/git-node.md index fb366f92..b685f153 100644 --- a/docs/git-node.md +++ b/docs/git-node.md @@ -503,6 +503,37 @@ $ ncu-config --global set h1_username $H1_TOKEN access. - `h1_username`: HackerOne API Token username. +#### Optional: reserve CVEs through the OpenJS Foundation CNA + +By default `--request-cve` reserves CVE ids through HackerOne, which acts as +the Node.js CNA. Set `cve_source` to `openjs-cna` to reserve them through the +OpenJS Foundation CNA instead. HackerOne is still used for the bug-bounty +workflow (triage, `--sync`, disclosure) and is updated with the resulting CVE +id, 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 openjs_cna_worker_url https://.workers.dev +``` + +- `cve_source`: `hackerone` (default) or `openjs-cna`. +- `openjs_cna_token`: **secret**, store it encrypted with `-x`. The bucket + token issued by the OpenJS CNA coordinators, in `:` form + (for example `prod_nodejs:<256 hex chars>`). +- `openjs_cna_worker_url`: the OpenJS CNA API base URL. +- `openjs_cna_timeout`: optional, seconds to wait for a reservation before + pausing (default `600`). + +Reservations are asynchronous. The command saves the correlation id of each +dispatched reservation to `vulnerabilities.json` (`pendingCve`) before +waiting, so an interrupted or timed-out run never loses a reserved id. On a +supervised bucket the reservation waits for an OpenJS CNA coordinator to +approve it, which can exceed the timeout: re-run +`git node security --request-cve` to resume the pending reservations. + +To revert to HackerOne, delete `cve_source` or set it back to `hackerone`. + ### `git node security --start` This command prepares `vulnerabilities.json` and can open the Next Security