diff --git a/components/git/security.js b/components/git/security.js index 393ebea9..769fdce3 100644 --- a/components/git/security.js +++ b/components/git/security.js @@ -71,6 +71,26 @@ const securityOptions = { let yargsInstance; +function isPromptInterrupted(error) { + return error?.name === 'ExitPromptError' || + error?.name === 'AbortPromptError' || + error?.message?.includes('User force closed'); +} + +function runSecurityAction(cli, action) { + return Promise.resolve(action).catch((error) => { + if (!isPromptInterrupted(error)) { + throw error; + } + + if (cli.spinner.isSpinning) { + cli.spinner.stop(); + } + cli.warn('Aborted.'); + cli.setExitCode(1); + }); +} + export function builder(yargs) { yargsInstance = yargs; return yargs.options(securityOptions) @@ -117,40 +137,36 @@ export function builder(yargs) { export function handler(argv) { const logStream = process.stdout.isTTY ? process.stdout : process.stderr; const cli = new CLI(logStream); + let action; if (argv.start) { - return startSecurityRelease(cli, argv); - } - if (argv['apply-patches']) { - return applySecurityPatches(cli, argv); + action = startSecurityRelease(cli, argv); + } else if (argv['apply-patches']) { + action = applySecurityPatches(cli, argv); + } else if (argv.sync) { + action = syncSecurityRelease(cli, argv); + } else if (argv['update-date']) { + action = updateReleaseDate(cli, argv); + } else if (argv['pre-release']) { + action = createPreRelease(cli, argv); + } else if (argv['add-report']) { + action = addReport(cli, argv); + } else if (argv['remove-report']) { + action = removeReport(cli, argv); + } else if (argv['notify-pre-release']) { + action = notifyPreRelease(cli, argv); + } else if (argv['request-cve']) { + action = requestCVEs(cli, argv); + } else if (argv['post-release']) { + action = createPostRelease(cli, argv); + } else if (argv.cleanup) { + action = cleanupSecurityRelease(cli, argv); } - if (argv.sync) { - return syncSecurityRelease(cli, argv); - } - if (argv['update-date']) { - return updateReleaseDate(cli, argv); - } - if (argv['pre-release']) { - return createPreRelease(cli, argv); - } - if (argv['add-report']) { - return addReport(cli, argv); - } - if (argv['remove-report']) { - return removeReport(cli, argv); - } - if (argv['notify-pre-release']) { - return notifyPreRelease(cli, argv); - } - if (argv['request-cve']) { - return requestCVEs(cli, argv); - } - if (argv['post-release']) { - return createPostRelease(cli, argv); - } - if (argv.cleanup) { - return cleanupSecurityRelease(cli, argv); + + if (action) { + return runSecurityAction(cli, action); } + yargsInstance.showHelp(); } diff --git a/lib/security-release/security-release.js b/lib/security-release/security-release.js index 80890a05..66d93ef2 100644 --- a/lib/security-release/security-release.js +++ b/lib/security-release/security-release.js @@ -387,9 +387,11 @@ export class SecurityRelease { 'chore: updated vulnerabilities.json', { cli: this.cli, repository: this.repository }); this.cli.stopSpinner(`Done updating vulnerabilities.json from ${vulnerabilitiesJSONPath}`); + return true; } catch (error) { this.cli.error('Error updating vulnerabilities.json'); this.cli.error(error); + return false; } } diff --git a/lib/update_security_release.js b/lib/update_security_release.js index 69fc6871..9690766d 100644 --- a/lib/update_security_release.js +++ b/lib/update_security_release.js @@ -197,6 +197,11 @@ export default class UpdateSecurityRelease extends SecurityRelease { `update HackerOne report \`${id}\` with CVEs ${cveIds}`, 'This writes the assigned CVE IDs back to the HackerOne report.' ); + return this.updateHackonerReportCveWithoutConfirmation(req, report); + } + + async updateHackonerReportCveWithoutConfirmation(req, report) { + const { id, cveIds } = report; this.cli.startSpinner(`Updating report ${id} with CVEs ${cveIds}..`); const body = { data: { @@ -206,96 +211,286 @@ export default class UpdateSecurityRelease extends SecurityRelease { } } }; - const response = await req.updateReportCVE(id, body); - if (response.errors) { - this.cli.error(`Error updating report ${id}`); - this.cli.error(JSON.stringify(response.errors, null, 2)); + try { + const response = await req.updateReportCVE(id, body); + if (response.errors) { + this.cli.stopSpinner( + `Error updating report ${id}`, + this.cli.SPINNER_STATUS.FAILED + ); + this.cli.error(`Error updating report ${id}`); + this.cli.error(JSON.stringify(response.errors, null, 2)); + return false; + } + this.cli.stopSpinner(`Done updating report ${id} with CVEs ${cveIds}..`); + return true; + } catch (error) { + this.cli.stopSpinner( + `Error updating report ${id}`, + this.cli.SPINNER_STATUS.FAILED + ); + this.cli.error(error); + return false; } - this.cli.stopSpinner(`Done updating report ${id} with CVEs ${cveIds}..`); } async promptCVECreation(req, reports, programId, content) { + const reportsWithoutCVE = reports.filter((report) => !report.cveIds?.length); + const requestAll = await this.promptRequestAllCVEs(reportsWithoutCVE); + + if (requestAll) { + await confirmSecurityStep( + this.cli, + `request CVEs for ${reportsWithoutCVE.length} HackerOne reports`, + 'This submits CVE requests to HackerOne for every report without a CVE.' + ); + } + const supportedVersions = (await nv('supported')); const eolVersions = (await nv('eol')); - for (const report of reports) { - const { id, summary, title, affectedVersions, cveIds, link } = report; - const affectedVersionLines = getAffectedVersionLines(affectedVersions); - // skip if already has a CVE - // risky because the CVE associated might be - // mentioned in the report and not requested by Node - if (cveIds?.length) continue; + const versionCache = new Map(); + const successfulReports = await this.collectSuccessfulCVERequests({ + req, + reports, + programId, + supportedVersions, + eolVersions, + versionCache, + requestAll + }); - let severity = report.severity; + if (!successfulReports.length) { + this.cli.warn('No CVE requests succeeded.'); + return; + } - 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 (error) { - this.cli.error(`Couldnt not retrieve severity from report ${id}, skipping...`); - continue; + await this.applySuccessfulCVEUpdates(req, content, successfulReports); + } + + showCVERequestSummary(reports) { + this.cli.info(`Reports selected for CVE requests (${reports.length}):`); + for (const { id, severity, title } of reports) { + const rating = severity?.rating || 'unknown'; + this.cli.info(`- ${id} [${rating}] ${title}`); + } + } + + promptRequestAllCVEs(reportsWithoutCVE) { + if (reportsWithoutCVE.length <= 1) return false; + this.showCVERequestSummary(reportsWithoutCVE); + return this.cli.prompt( + 'Request CVEs for all reports without prompting for each report?', + { defaultAnswer: false } + ); + } + + async collectSuccessfulCVERequests({ + req, + reports, + programId, + supportedVersions, + eolVersions, + versionCache, + requestAll + }) { + const successfulReports = []; + try { + for (const report of reports) { + const requested = await this.requestReportCVE({ + req, + report, + programId, + supportedVersions, + eolVersions, + versionCache, + requestAll + }); + if (requested) successfulReports.push(report); + } + } catch (error) { + if (!this.isPromptInterrupted(error)) throw error; + this.cli.warn('CVE request interrupted. Finalizing successful requests.'); + } + return successfulReports; + } + + isPromptInterrupted(error) { + return error?.name === 'ExitPromptError' || + error?.name === 'AbortPromptError' || + error?.message?.includes('User force closed'); + } + + async requestReportCVE({ + req, + report, + programId, + supportedVersions, + eolVersions, + versionCache, + requestAll + }) { + const { id, summary, title, affectedVersions, cveIds, link } = report; + const affectedVersionLines = getAffectedVersionLines(affectedVersions); + // skip if already has a CVE + // risky because the CVE associated might be + // mentioned in the report and not requested by Node + if (cveIds?.length) return false; + + 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 (error) { + this.cli.error(`Couldnt not retrieve severity from report ${id}, skipping...`); + return false; } + } - const { cvss_vector_string, weakness_id } = severity; + const { cvss_vector_string, weakness_id } = severity; - const create = await this.cli.prompt( - `Request a CVE for: \n + const create = requestAll || await this.cli.prompt( + `Request a CVE for: \n Title: ${title}\n Link: ${link}\n Affected versions: ${affectedVersionLines.join(', ')}\n Vector: ${cvss_vector_string}\n Summary: ${summary}\n`, - { defaultAnswer: true }); - - if (!create) continue; - - const { h1AffectedVersions, patchedVersions } = - await this.calculateVersions(affectedVersionLines, supportedVersions, eolVersions); - const body = { - data: { - type: 'cve-request', - attributes: { - team_handle: 'nodejs-team', - versions: h1AffectedVersions, - metrics: [ - { - vectorString: cvss_vector_string - } - ], - auto_submit_on_publicly_disclosing_report: true, - references: ['https://nodejs.org/en/blog/vulnerability'], - report_id: report.id, - weakness_id: Number(weakness_id), - description: report.summary, - vulnerability_discovered_at: new Date().toISOString() - } + { defaultAnswer: true }); + + if (!create) return false; + + const { h1AffectedVersions, patchedVersions } = + await this.calculateVersions( + affectedVersionLines, + supportedVersions, + eolVersions, + versionCache + ); + const body = { + data: { + type: 'cve-request', + attributes: { + team_handle: 'nodejs-team', + versions: h1AffectedVersions, + metrics: [ + { + vectorString: cvss_vector_string + } + ], + auto_submit_on_publicly_disclosing_report: true, + references: ['https://nodejs.org/en/blog/vulnerability'], + report_id: report.id, + weakness_id: Number(weakness_id), + description: report.summary, + vulnerability_discovered_at: new Date().toISOString() } - }; + } + }; + if (!requestAll) { await confirmSecurityStep( this.cli, `request CVE for HackerOne report \`${report.id}\``, 'This submits a CVE request to HackerOne for the selected report.' ); - const response = await req.requestCVE(programId, body); - if (response.errors) { - this.cli.error(`Error requesting CVE for report ${id}`); - this.cli.error(JSON.stringify(response.errors, null, 2)); - continue; + } + this.cli.startSpinner(`Requesting CVE for report ${id}...`); + let response; + try { + response = await req.requestCVE(programId, body); + } catch (error) { + this.cli.stopSpinner( + `Error requesting CVE for report ${id}`, + this.cli.SPINNER_STATUS.FAILED + ); + this.cli.error(error); + return false; + } + if (response.errors) { + this.cli.stopSpinner( + `Error requesting CVE for report ${id}`, + this.cli.SPINNER_STATUS.FAILED + ); + this.cli.error(`Error requesting CVE for report ${id}`); + this.cli.error(JSON.stringify(response.errors, null, 2)); + return false; + } + this.cli.stopSpinner(`Requested CVE for report ${id}`); + const { cve_identifier } = response.data.attributes; + report.cveIds = [cve_identifier]; + report.patchedVersions = patchedVersions; + return true; + } + + async applySuccessfulCVEUpdates(req, content, successfulReports) { + this.cli.info('CVE requests succeeded:'); + for (const { id, cveIds } of successfulReports) { + this.cli.info(`- ${id}: ${cveIds.join(', ')}`); + } + const updateVulnerabilitiesJSON = await this.cli.prompt( + 'Update vulnerabilities.json with the successful CVE requests?', + { defaultAnswer: true } + ); + if (!updateVulnerabilitiesJSON) { + this.cli.warn( + 'Skipping HackerOne updates because vulnerabilities.json was not updated.' + ); + return; + } + + const vulnerabilitiesUpdated = await this.updateVulnerabilitiesJSON(content); + if (!vulnerabilitiesUpdated) { + this.cli.warn('Skipping HackerOne updates because vulnerabilities.json update failed.'); + return; + } + + const updateHackerOne = await this.cli.prompt( + 'Update HackerOne reports with the successful CVE IDs?', + { defaultAnswer: true } + ); + if (updateHackerOne) { + await confirmSecurityStep( + this.cli, + `update ${successfulReports.length} HackerOne reports with CVE IDs`, + 'This writes the assigned CVE IDs back to all successful HackerOne reports.' + ); + const h1Succeeded = []; + const h1Failed = []; + for (const report of successfulReports) { + try { + const updated = await this.updateHackonerReportCveWithoutConfirmation(req, report); + if (updated) { + h1Succeeded.push(report); + } else { + h1Failed.push(report); + } + } catch (error) { + this.cli.error(error); + h1Failed.push(report); + } + } + + if (h1Succeeded.length) { + this.cli.info('HackerOne reports updated:'); + for (const { id, cveIds } of h1Succeeded) { + this.cli.info(`- ${id}: ${cveIds.join(', ')}`); + } + } + if (h1Failed.length) { + this.cli.warn('HackerOne reports not updated:'); + for (const { id, cveIds } of h1Failed) { + this.cli.warn(`- ${id}: ${cveIds.join(', ')}`); + } } - const { cve_identifier } = response.data.attributes; - report.cveIds = [cve_identifier]; - report.patchedVersions = patchedVersions; - await this.updateVulnerabilitiesJSON(content); - await this.updateHackonerReportCve(req, report); } } @@ -310,40 +505,53 @@ Summary: ${summary}\n`, } } - async calculateVersions(affectedVersions, supportedVersions, eolVersions) { + async calculateVersions( + affectedVersions, + supportedVersions, + eolVersions, + versionCache = new Map() + ) { affectedVersions = getAffectedVersionLines(affectedVersions); const h1AffectedVersions = []; const patchedVersions = []; let isPatchRelease = true; for (const affectedVersion of affectedVersions) { - const affectedMajor = affectedVersion.split('.')[0]; - const latest = supportedVersions.find((v) => v.major === Number(affectedMajor)).version; - const version = await this.cli.prompt( - `What is the affected version (<=) for release line ${affectedVersion}?`, - { questionType: 'input', defaultAnswer: latest }); - - const nextPatchVersion = semver.inc(version, 'patch'); - const nextMinorVersion = semver.inc(version, 'minor'); - const patchedVersion = await this.cli.promptRadio( - `What is the patched version (>=) for release line ${affectedVersion}?`, - [nextPatchVersion, nextMinorVersion], - { - defaultAnswer: isPatchRelease ? nextPatchVersion : nextMinorVersion - }); + if (!versionCache.has(affectedVersion)) { + const affectedMajor = affectedVersion.split('.')[0]; + const latest = supportedVersions.find((v) => v.major === Number(affectedMajor)).version; + const version = await this.cli.prompt( + `What is the affected version (<=) for release line ${affectedVersion}?`, + { questionType: 'input', defaultAnswer: latest }); + + const nextPatchVersion = semver.inc(version, 'patch'); + const nextMinorVersion = semver.inc(version, 'minor'); + const patchedVersion = await this.cli.promptRadio( + `What is the patched version (>=) for release line ${affectedVersion}?`, + [nextPatchVersion, nextMinorVersion], + { + defaultAnswer: isPatchRelease ? nextPatchVersion : nextMinorVersion + }); + + if (patchedVersion !== nextPatchVersion) { + isPatchRelease = false; // is a minor release + } - if (patchedVersion !== nextPatchVersion) { - isPatchRelease = false; // is a minor release + versionCache.set(affectedVersion, { + patchedVersion, + h1AffectedVersion: { + vendor: 'nodejs', + product: 'node', + func: '<=', + version, + versionType: 'semver', + affected: true + } + }); } + const { h1AffectedVersion, patchedVersion } = versionCache.get(affectedVersion); patchedVersions.push(patchedVersion); - h1AffectedVersions.push({ - vendor: 'nodejs', - product: 'node', - func: '<=', - version, - versionType: 'semver', - affected: true - }); + h1AffectedVersions.push(h1AffectedVersion); } // All EOL versions are affected since they no longer receive security patches diff --git a/test/unit/security_release.test.js b/test/unit/security_release.test.js index ae13364a..5f7d20d6 100644 --- a/test/unit/security_release.test.js +++ b/test/unit/security_release.test.js @@ -6,6 +6,7 @@ import PrepareSecurityRelease, { getNextTuesdayReleaseDateChoices, getNextTuesdayReleaseDates } from '../../lib/prepare_security.js'; +import UpdateSecurityRelease from '../../lib/update_security_release.js'; import { getAffectedVersionLines, getHighestSeverityAnnouncement @@ -116,6 +117,304 @@ describe('security_release: affected versions', () => { }); }); +describe('security_release: CVE request versions', () => { + it('shows reports selected for bulk CVE requests', () => { + const messages = []; + const release = new UpdateSecurityRelease({ + info(message) { + messages.push(message); + } + }); + + release.showCVERequestSummary([ + { + id: '3846922', + title: 'HTTP/2 retained header blocks evade maxSessionMemory', + severity: { rating: 'high' } + }, + { + id: '3838601', + title: 'Permission Model bypass writes trace logs', + severity: { rating: 'medium' } + } + ]); + + assert.deepStrictEqual(messages, [ + 'Reports selected for CVE requests (2):', + '- 3846922 [high] HTTP/2 retained header blocks evade maxSessionMemory', + '- 3838601 [medium] Permission Model bypass writes trace logs' + ]); + }); + + it('shows the bulk CVE summary before asking to request all reports', async() => { + const events = []; + const release = new UpdateSecurityRelease({ + info(message) { + events.push(`info:${message}`); + }, + warn() {}, + prompt(message) { + events.push(`prompt:${message}`); + return false; + } + }); + release.collectSuccessfulCVERequests = async() => []; + + await release.promptRequestAllCVEs([ + { + id: '1', + title: 'First report', + cveIds: [], + severity: { + rating: 'high', + weakness_id: '1', + cvss_vector_string: 'CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H' + }, + affectedVersions: ['24.x'], + summary: 'First summary' + }, + { + id: '2', + title: 'Second report', + cveIds: [], + severity: { + rating: 'medium', + weakness_id: '1', + cvss_vector_string: 'CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N' + }, + affectedVersions: ['24.x'], + summary: 'Second summary' + } + ]); + + assert.deepStrictEqual(events.slice(0, 4), [ + 'info:Reports selected for CVE requests (2):', + 'info:- 1 [high] First report', + 'info:- 2 [medium] Second report', + 'prompt:Request CVEs for all reports without prompting for each report?' + ]); + }); + + it('reuses version answers for repeated release lines', async() => { + const prompts = []; + const radioPrompts = []; + const release = new UpdateSecurityRelease({ + prompt(message, options) { + prompts.push(message); + assert.strictEqual(options.questionType, 'input'); + if (message.includes('24.x')) return '24.18.0'; + if (message.includes('22.x')) return '22.23.1'; + throw new Error(`Unexpected prompt: ${message}`); + }, + promptRadio(message, choices) { + radioPrompts.push(message); + return choices[0]; + } + }); + const supportedVersions = [ + { major: 24, version: '24.18.0' }, + { major: 22, version: '22.23.1' } + ]; + const versionCache = new Map(); + + assert.deepStrictEqual( + (await release.calculateVersions(['24.x'], supportedVersions, [], versionCache)) + .patchedVersions, + ['24.18.1'] + ); + assert.deepStrictEqual( + (await release.calculateVersions(['24.x', '22.x'], supportedVersions, [], versionCache)) + .patchedVersions, + ['24.18.1', '22.23.2'] + ); + assert.deepStrictEqual(prompts, [ + 'What is the affected version (<=) for release line 24.x?', + 'What is the affected version (<=) for release line 22.x?' + ]); + assert.deepStrictEqual(radioPrompts, [ + 'What is the patched version (>=) for release line 24.x?', + 'What is the patched version (>=) for release line 22.x?' + ]); + }); + + it('applies successful CVE updates once after requests finish', async() => { + const messages = []; + const prompts = []; + const h1Updates = []; + const release = new UpdateSecurityRelease({ + info(message) { + messages.push(message); + }, + warn() {}, + prompt(message) { + prompts.push(message); + return true; + } + }); + const content = { reports: [] }; + const successfulReports = [ + { id: '1', cveIds: ['CVE-2026-0001'] }, + { id: '2', cveIds: ['CVE-2026-0002'] } + ]; + let jsonUpdates = 0; + + release.updateVulnerabilitiesJSON = async(updatedContent) => { + assert.strictEqual(updatedContent, content); + jsonUpdates++; + return true; + }; + release.updateHackonerReportCveWithoutConfirmation = async(req, report) => { + h1Updates.push(report.id); + return true; + }; + + await release.applySuccessfulCVEUpdates({}, content, successfulReports); + + assert.deepStrictEqual(messages, [ + 'CVE requests succeeded:', + '- 1: CVE-2026-0001', + '- 2: CVE-2026-0002', + 'HackerOne reports updated:', + '- 1: CVE-2026-0001', + '- 2: CVE-2026-0002' + ]); + assert.deepStrictEqual(prompts, [ + 'Update vulnerabilities.json with the successful CVE requests?', + 'Update HackerOne reports with the successful CVE IDs?', + 'Allow action: update 2 HackerOne reports with CVE IDs?\n\n' + + 'This writes the assigned CVE IDs back to all successful HackerOne reports.' + ]); + assert.strictEqual(jsonUpdates, 1); + assert.deepStrictEqual(h1Updates, ['1', '2']); + }); + + it('skips HackerOne updates when vulnerabilities.json update is declined', async() => { + const warnings = []; + const release = new UpdateSecurityRelease({ + info() {}, + warn(message) { + warnings.push(message); + }, + prompt() { + return false; + } + }); + let h1Updates = 0; + release.updateVulnerabilitiesJSON = async() => { + throw new Error('should not update vulnerabilities.json'); + }; + release.updateHackonerReportCveWithoutConfirmation = async() => { + h1Updates++; + }; + + await release.applySuccessfulCVEUpdates({}, {}, [ + { id: '1', cveIds: ['CVE-2026-0001'] } + ]); + + assert.deepStrictEqual(warnings, [ + 'Skipping HackerOne updates because vulnerabilities.json was not updated.' + ]); + assert.strictEqual(h1Updates, 0); + }); + + it('skips HackerOne updates when vulnerabilities.json update fails', async() => { + const warnings = []; + let promptCount = 0; + const release = new UpdateSecurityRelease({ + info() {}, + warn(message) { + warnings.push(message); + }, + prompt() { + promptCount++; + return true; + } + }); + let h1Updates = 0; + release.updateVulnerabilitiesJSON = async() => false; + release.updateHackonerReportCveWithoutConfirmation = async() => { + h1Updates++; + }; + + await release.applySuccessfulCVEUpdates({}, {}, [ + { id: '1', cveIds: ['CVE-2026-0001'] } + ]); + + assert.deepStrictEqual(warnings, [ + 'Skipping HackerOne updates because vulnerabilities.json update failed.' + ]); + assert.strictEqual(promptCount, 1); + assert.strictEqual(h1Updates, 0); + }); + + it('summarizes HackerOne update failures', async() => { + const messages = []; + const warnings = []; + const release = new UpdateSecurityRelease({ + info(message) { + messages.push(message); + }, + warn(message) { + warnings.push(message); + }, + prompt() { + return true; + } + }); + release.updateVulnerabilitiesJSON = async() => true; + release.updateHackonerReportCveWithoutConfirmation = async(req, report) => report.id === '1'; + + await release.applySuccessfulCVEUpdates({}, {}, [ + { id: '1', cveIds: ['CVE-2026-0001'] }, + { id: '2', cveIds: ['CVE-2026-0002'] } + ]); + + assert.deepStrictEqual(messages, [ + 'CVE requests succeeded:', + '- 1: CVE-2026-0001', + '- 2: CVE-2026-0002', + 'HackerOne reports updated:', + '- 1: CVE-2026-0001' + ]); + assert.deepStrictEqual(warnings, [ + 'HackerOne reports not updated:', + '- 2: CVE-2026-0002' + ]); + }); + + it('keeps successful CVE requests when interrupted', async() => { + const warnings = []; + const reports = [{ id: '1' }, { id: '2' }, { id: '3' }]; + const release = new UpdateSecurityRelease({ + warn(message) { + warnings.push(message); + } + }); + release.requestReportCVE = async({ report }) => { + if (report.id === '1') return true; + const error = new Error('User force closed the prompt'); + error.name = 'ExitPromptError'; + throw error; + }; + + assert.deepStrictEqual( + await release.collectSuccessfulCVERequests({ + reports, + req: {}, + programId: '123', + supportedVersions: [], + eolVersions: [], + versionCache: new Map(), + requestAll: false + }), + [reports[0]] + ); + assert.deepStrictEqual(warnings, [ + 'CVE request interrupted. Finalizing successful requests.' + ]); + }); +}); + describe('security_release: release date choices', () => { it('starts with the next Tuesday when today is Tuesday', () => { assert.deepStrictEqual(