From dd6fdfcd64f1556742d9c0d7cc663cab73d25bcc Mon Sep 17 00:00:00 2001 From: RafaelGSS Date: Tue, 7 Jul 2026 11:51:53 -0300 Subject: [PATCH] feat(security): add include-all report selection mode Signed-off-by: RafaelGSS --- lib/prepare_security.js | 188 +++++++++++++++++++++++-- test/unit/security_release.test.js | 219 ++++++++++++++++++++++++++++- 2 files changed, 398 insertions(+), 9 deletions(-) diff --git a/lib/prepare_security.js b/lib/prepare_security.js index 4342d881..9ae446e5 100644 --- a/lib/prepare_security.js +++ b/lib/prepare_security.js @@ -11,6 +11,7 @@ import { promptDependencies, getSupportedVersions, getReportSeverity, + getSummary, pickReport, confirmSecurityStep, writeSecurityFile, @@ -81,6 +82,75 @@ export function getNextTuesdayReleaseDateChoices(fromDate = new Date(), count = return choices; } +function getReportPRURL(report) { + const customFieldValues = report.relationships.custom_field_values?.data ?? []; + return customFieldValues[0]?.attributes?.value ?? ''; +} + +export function buildIncludedTriagedReport(report, options = {}) { + const { + affectedVersions = '', + patchAuthors = [], + prURL = getReportPRURL(report) + } = options; + const { + id, + attributes: { title, cve_ids = [] }, + relationships: { reporter } + } = report; + const link = `https://hackerone.com/reports/${id}`; + const summaryContent = getSummary(report); + + return { + id, + title, + cveIds: cve_ids, + severity: getReportSeverity(report), + summary: summaryContent ?? '', + patchAuthors, + prURL, + affectedVersions: affectedVersions + .split(',') + .map((v) => v.replace('v', '').trim()) + .filter(Boolean), + link, + reporter: reporter?.data?.attributes?.username ?? '' + }; +} + +export function getMissingReportInformation(report) { + const missing = []; + + if (!report.severity?.rating) missing.push('severity rating'); + if (!report.severity?.cvss_vector_string) missing.push('CVSS vector'); + if (!report.severity?.weakness_id) missing.push('weakness ID'); + if (!report.summary) missing.push('team summary'); + if (!report.prURL) missing.push('PR URL'); + if (!report.patchAuthors?.length) missing.push('patch authors'); + if (!report.affectedVersions?.length) missing.push('affected versions'); + + return missing; +} + +export function groupMissingReportInformation(reports) { + const grouped = new Map(); + + for (const report of reports) { + for (const field of report.missing) { + const current = grouped.get(field) ?? []; + current.push(report); + grouped.set(field, current); + } + } + + return Array.from(grouped.entries()) + .map(([field, fieldReports]) => ({ + field, + reports: fieldReports + })) + .sort((a, b) => b.reports.length - a.reports.length); +} + export default class PrepareSecurityRelease extends SecurityRelease { title = 'Next Security Release'; @@ -93,11 +163,6 @@ export default class PrepareSecurityRelease extends SecurityRelease { this.req = new Request(credentials); let excludedReports = []; - const showTriaged = await this.promptShowTriagedWithoutPR(); - if (showTriaged) { - excludedReports = await this.showTriagedReportsWithoutPR(); - } - const releaseDate = await this.promptReleaseDate(); if (releaseDate !== 'TBD') { validateDate(releaseDate); @@ -106,8 +171,15 @@ export default class PrepareSecurityRelease extends SecurityRelease { const content = await this.buildDescription(releaseDate); if (createVulnerabilitiesJSON) { + const reportSelectionMode = await this.promptReportSelectionMode(); + if (reportSelectionMode === 'review') { + const showTriaged = await this.promptShowTriagedWithoutPR(); + if (showTriaged) { + excludedReports = await this.showTriagedReportsWithoutPR(); + } + } await this.startVulnerabilitiesJSONCreation( - releaseDate, content, excludedReports); + releaseDate, content, excludedReports, reportSelectionMode); } this.cli.ok('Done!'); @@ -176,12 +248,19 @@ export default class PrepareSecurityRelease extends SecurityRelease { this.cli.ok('Done!'); } - async startVulnerabilitiesJSONCreation(releaseDate, content, excludedReports = []) { + async startVulnerabilitiesJSONCreation( + releaseDate, + content, + excludedReports = [], + reportSelectionMode = 'review' + ) { // checkout on the next-security-release branch await checkoutOnSecurityReleaseBranch(this.cli, this.repository); // choose the reports to include in the security release - const reports = await this.chooseReports(excludedReports); + const reports = reportSelectionMode === 'include-all' + ? await this.includeAllTriagedReports(excludedReports) + : await this.chooseReports(excludedReports); const depUpdates = await this.getDependencyUpdates(); const deps = _.groupBy(depUpdates, 'name'); @@ -252,6 +331,25 @@ export default class PrepareSecurityRelease extends SecurityRelease { { defaultAnswer: true }); } + async promptReportSelectionMode() { + return this.cli.promptSelect( + 'How would you like to choose reports for the next security release?', + [ + { + name: 'Review each triaged report', + value: 'review', + description: 'Iterate over reports and fill missing details interactively' + }, + { + name: 'Include all triaged reports', + value: 'include-all', + description: 'Add every triaged report and summarize missing information' + } + ], + { defaultAnswer: 'review' } + ); + } + async promptCreateRelaseIssue() { return this.cli.prompt( 'Create the Next Security Release issue?', @@ -326,6 +424,80 @@ export default class PrepareSecurityRelease extends SecurityRelease { return selectedReports; } + async includeAllTriagedReports(excludedReports = []) { + this.cli.info('Getting triaged H1 reports...'); + const reports = await this.req.getTriagedReports(); + const supportedVersions = await getSupportedVersions(); + const selectedReports = []; + const missingInformation = []; + + for (const report of reports.data) { + if (excludedReports.includes(report.id)) continue; + + const reportData = await this.buildIncludedTriagedReport( + report, + supportedVersions + ); + selectedReports.push(reportData); + + const missing = getMissingReportInformation(reportData); + if (missing.length) { + missingInformation.push({ + id: reportData.id, + title: reportData.title, + link: reportData.link, + missing + }); + } + } + + this.displayMissingReportInformationSummary(missingInformation); + return selectedReports; + } + + async buildIncludedTriagedReport(report, supportedVersions) { + const prURL = getReportPRURL(report); + const patchAuthors = await this.getPatchAuthorsFromPR(prURL); + return buildIncludedTriagedReport(report, { + affectedVersions: supportedVersions, + patchAuthors, + prURL + }); + } + + async getPatchAuthorsFromPR(prURL) { + if (!prURL) return []; + + try { + const { user } = await this.req.getPullRequest(prURL); + return user?.login ? [user.login] : []; + } catch (error) { + this.cli.warn(`Could not fetch patch author from ${prURL}`); + this.cli.error(error); + return []; + } + } + + displayMissingReportInformationSummary(reports) { + if (!reports.length) { + this.cli.ok('All included reports have the expected information.'); + return; + } + + const grouped = groupMissingReportInformation(reports); + this.cli.warn( + `${reports.length} included reports are missing information:` + ); + for (const { field, reports: fieldReports } of grouped) { + const reportList = fieldReports + .map(({ id }) => `H1 #${id}`) + .join(', '); + this.cli.info( + `- ${field} (${fieldReports.length}): ${reportList}` + ); + } + } + async createVulnerabilitiesJSON(reports, dependencies, releaseDate) { this.cli.startSpinner('Creating vulnerabilities.json...'); const fileContent = JSON.stringify({ diff --git a/test/unit/security_release.test.js b/test/unit/security_release.test.js index 67ea65b6..61161133 100644 --- a/test/unit/security_release.test.js +++ b/test/unit/security_release.test.js @@ -3,6 +3,9 @@ import assert from 'node:assert'; import SecurityBlog from '../../lib/security_blog.js'; import PrepareSecurityRelease, { + buildIncludedTriagedReport, + groupMissingReportInformation, + getMissingReportInformation, getNextTuesdayReleaseDateChoices, getNextTuesdayReleaseDates } from '../../lib/prepare_security.js'; @@ -18,6 +21,59 @@ function report(id, rating, affectedVersions = ['24.x']) { }; } +function h1Report(overrides = {}) { + return { + id: '123', + attributes: { + title: 'Example vulnerability', + cve_ids: ['CVE-2026-0001'], + ...overrides.attributes + }, + relationships: { + reporter: { + data: { + attributes: { + username: 'reporter' + } + } + }, + custom_field_values: { + data: [ + { + attributes: { + value: 'https://github.com/nodejs-private/node-private/pull/1' + } + } + ] + }, + severity: { + data: { + attributes: { + rating: 'high', + cvss_vector_string: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H' + } + } + }, + weakness: { + data: { + id: '79' + } + }, + summaries: { + data: [ + { + attributes: { + category: 'team', + content: 'A useful team summary.' + } + } + ] + }, + ...overrides.relationships + } + }; +} + describe('security_release: severity announcement', () => { it('uses the highest severity across reports', () => { const reports = [ @@ -146,7 +202,168 @@ describe('security_release: release date choices', () => { } }); - assert.strictEqual(await release.promptReleaseDate(), getNextTuesdayReleaseDates()[0]); + assert.strictEqual( + await release.promptReleaseDate(), + getNextTuesdayReleaseDates()[0] + ); + }); +}); + +describe('security_release: include all triaged reports', () => { + it('builds a vulnerabilities.json report from a triaged H1 report', () => { + assert.deepStrictEqual( + buildIncludedTriagedReport(h1Report(), { + affectedVersions: '24.x,22.x', + patchAuthors: ['author'] + }), + { + id: '123', + title: 'Example vulnerability', + cveIds: ['CVE-2026-0001'], + severity: { + rating: 'high', + cvss_vector_string: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H', + weakness_id: '79' + }, + summary: 'A useful team summary.', + patchAuthors: ['author'], + prURL: 'https://github.com/nodejs-private/node-private/pull/1', + affectedVersions: ['24.x', '22.x'], + link: 'https://hackerone.com/reports/123', + reporter: 'reporter' + } + ); + }); + + it('identifies missing information in included reports', () => { + const includedReport = buildIncludedTriagedReport( + h1Report({ + attributes: { + cve_ids: [] + }, + relationships: { + custom_field_values: { + data: [] + }, + severity: { + data: { + attributes: { + rating: '', + cvss_vector_string: '' + } + } + }, + weakness: { + data: {} + }, + summaries: { + data: [] + } + } + }), + { affectedVersions: '', patchAuthors: [] } + ); + + assert.deepStrictEqual( + getMissingReportInformation(includedReport), + [ + 'severity rating', + 'CVSS vector', + 'weakness ID', + 'team summary', + 'PR URL', + 'patch authors', + 'affected versions' + ] + ); + }); + + it('groups missing information by field', () => { + assert.deepStrictEqual( + groupMissingReportInformation([ + { + id: '1', + missing: ['CVSS vector', 'team summary'] + }, + { + id: '2', + missing: ['CVSS vector'] + } + ]), + [ + { + field: 'CVSS vector', + reports: [ + { + id: '1', + missing: ['CVSS vector', 'team summary'] + }, + { + id: '2', + missing: ['CVSS vector'] + } + ] + }, + { + field: 'team summary', + reports: [ + { + id: '1', + missing: ['CVSS vector', 'team summary'] + } + ] + } + ] + ); + }); + + it('prints a compact missing information summary', () => { + const output = []; + const release = new PrepareSecurityRelease({ + ok: (message) => output.push(['ok', message]), + warn: (message) => output.push(['warn', message]), + info: (message) => output.push(['info', message]) + }); + + release.displayMissingReportInformationSummary([ + { + id: '1', + title: 'Long report title', + link: 'https://hackerone.com/reports/1', + missing: ['CVSS vector', 'team summary'] + }, + { + id: '2', + title: 'Another long report title', + link: 'https://hackerone.com/reports/2', + missing: ['CVSS vector'] + } + ]); + + assert.deepStrictEqual(output, [ + ['warn', '2 included reports are missing information:'], + ['info', '- CVSS vector (2): H1 #1, H1 #2'], + ['info', '- team summary (1): H1 #1'] + ]); + }); + + it('prompts for report selection mode', async() => { + const release = new PrepareSecurityRelease({ + promptSelect(message, choices, options) { + assert.strictEqual( + message, + 'How would you like to choose reports for the next security release?' + ); + assert.deepStrictEqual( + choices.map(({ value }) => value), + ['review', 'include-all'] + ); + assert.strictEqual(options.defaultAnswer, 'review'); + return 'include-all'; + } + }); + + assert.strictEqual(await release.promptReportSelectionMode(), 'include-all'); }); });