diff --git a/lib/security-release/security-release.js b/lib/security-release/security-release.js index e662910a..94e8780d 100644 --- a/lib/security-release/security-release.js +++ b/lib/security-release/security-release.js @@ -11,6 +11,8 @@ export const NEXT_SECURITY_RELEASE_REPOSITORY = { repo: 'security-release' }; +const SEVERITY_RANKS = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; + export const PLACEHOLDERS = { releaseDate: '%RELEASE_DATE%', vulnerabilitiesPRURL: '%VULNERABILITIES_PR_URL%', @@ -130,6 +132,16 @@ export function formatDateToYYYYMMDD(date) { return `${year}/${month}/${day}`; } +export function getHighestSeverityAnnouncement(reports, releaseLine = 'this release') { + const highestSeverityIndex = Math.max(...reports.map( + r => SEVERITY_RANKS.indexOf(r.severity.rating.toUpperCase()) + )); + + return `The highest severity issue fixed in ${releaseLine} is ${ + SEVERITY_RANKS[highestSeverityIndex] ?? 'NONE' + }.`; +} + export function promptDependencies(cli) { return cli.prompt('Enter the link to the dependency update PR (leave empty to exit): ', { defaultAnswer: '', diff --git a/lib/security_blog.js b/lib/security_blog.js index 6ecd0e6d..194e4d06 100644 --- a/lib/security_blog.js +++ b/lib/security_blog.js @@ -1,6 +1,5 @@ import fs from 'node:fs'; import path from 'node:path'; -import _ from 'lodash'; import nv from '@pkgjs/nv'; import { PLACEHOLDERS, @@ -8,6 +7,7 @@ import { validateDate, SecurityRelease, commitAndPushVulnerabilitiesJSON, + getHighestSeverityAnnouncement, } from './security-release/security-release.js'; import auth from './auth.js'; import Request from './request.js'; @@ -38,7 +38,7 @@ export default class SecurityBlog extends SecurityRelease { annoucementDate: await this.getAnnouncementDate(cli), releaseDate: this.formatReleaseDate(releaseDate), affectedVersions: this.getAffectedVersions(content), - vulnerabilities: this.getVulnerabilities(content), + vulnerabilities: this.getPreReleaseVulnerabilities(content), slug: this.getSlug(releaseDate), impact: this.getImpact(content) }; @@ -323,6 +323,11 @@ export default class SecurityBlog extends SecurityRelease { getImpact(content) { const impact = new Map(); for (const report of content.reports) { + if (!report.severity?.rating) { + this.cli.error(`severity.rating not found for report ${report.id}.`); + process.exit(1); + } + for (const version of report.affectedVersions) { if (!impact.has(version)) impact.set(version, []); impact.get(version).push(report); @@ -331,38 +336,45 @@ export default class SecurityBlog extends SecurityRelease { const result = Array.from(impact.entries()) .sort(([a], [b]) => b.localeCompare(a)) // DESC - .map(([version, reports]) => { - const severityCount = new Map(); - - for (const report of reports) { - const rating = report.severity.rating?.toLowerCase(); - if (!rating) { - this.cli.error(`severity.rating not found for report ${report.id}.`); - process.exit(1); - } - severityCount.set(rating, (severityCount.get(rating) || 0) + 1); - } - - const groupedByRating = Array.from(severityCount.entries()) - .map(([rating, count]) => `${count} ${rating} severity issues`) - .join(', '); - - return `The ${version} release line of Node.js is vulnerable to ${groupedByRating}.`; - }) + .map(([version, reports]) => + getHighestSeverityAnnouncement(reports, `the ${version} release line`)) .join('\n'); return result; } getVulnerabilities(content) { - const grouped = _.groupBy(content.reports, 'severity.rating'); + const severityCount = new Map(); + + for (const report of content.reports) { + if (!report.severity?.rating) { + this.cli.error(`severity.rating not found for report ${report.id}.`); + process.exit(1); + } + + const rating = report.severity.rating; + severityCount.set(rating, (severityCount.get(rating) || 0) + 1); + } + const text = []; - for (const [key, value] of Object.entries(grouped)) { - text.push(`- ${value.length} ${key.toLocaleLowerCase()} severity issues.`); + for (const [rating, count] of severityCount) { + text.push(`- ${count} ${rating} severity issues.`); } + return text.join('\n'); } + getPreReleaseVulnerabilities(content) { + for (const report of content.reports) { + if (!report.severity?.rating) { + this.cli.error(`severity.rating not found for report ${report.id}.`); + process.exit(1); + } + } + + return getHighestSeverityAnnouncement(content.reports); + } + getSecurityPreReleaseTemplate() { return fs.readFileSync( new URL( diff --git a/test/unit/security_release.test.js b/test/unit/security_release.test.js new file mode 100644 index 00000000..80541024 --- /dev/null +++ b/test/unit/security_release.test.js @@ -0,0 +1,237 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; + +import SecurityBlog from '../../lib/security_blog.js'; +import { + getHighestSeverityAnnouncement +} from '../../lib/security-release/security-release.js'; + +const cli = { + error() {} +}; + +function assertExits(fn) { + const originalExit = process.exit; + process.exit = () => { + throw new Error('process.exit'); + }; + + try { + assert.throws(fn, /process\.exit/); + } finally { + process.exit = originalExit; + } +} + +function report(id, rating, affectedVersions = ['24.x']) { + return { + id, + severity: { rating }, + affectedVersions + }; +} + +describe('security_release: severity announcement', () => { + it('uses the highest severity across reports', () => { + const reports = [ + report(1, 'low'), + report(2, 'medium'), + report(3, 'high') + ]; + + assert.strictEqual( + getHighestSeverityAnnouncement(reports), + 'The highest severity issue fixed in this release is HIGH.' + ); + }); + + it('can be customized with second argument', () => { + const reports = [ + report(1, 'low'), + report(2, 'medium'), + report(3, 'high') + ]; + + assert.strictEqual( + getHighestSeverityAnnouncement(reports, 'special release'), + 'The highest severity issue fixed in special release is HIGH.' + ); + }); + + it('invalid severity ratings are ignored', () => { + const reports = [ + report(1, 'low'), + report(2, 'medium'), + report(3, 'hypercritical') + ]; + + assert.strictEqual( + getHighestSeverityAnnouncement(reports), + 'The highest severity issue fixed in this release is MEDIUM.' + ); + }); + + it('if no valid rating is passed, output NONE', () => { + const reports = [ + report(3, 'hypercritical') + ]; + + assert.strictEqual( + getHighestSeverityAnnouncement(reports), + 'The highest severity issue fixed in this release is NONE.' + ); + }); + + it('uses medium severity wording', () => { + const reports = [ + report(1, 'low'), + report(2, 'medium') + ]; + + assert.strictEqual( + getHighestSeverityAnnouncement(reports), + 'The highest severity issue fixed in this release is MEDIUM.' + ); + }); + + it('ignores invalid severity ratings', () => { + const reports = [ + report(1, 'low'), + report(2, 'hypercritical'), + report(3, 'medium') + ]; + + assert.strictEqual( + getHighestSeverityAnnouncement(reports), + 'The highest severity issue fixed in this release is MEDIUM.' + ); + }); +}); + +describe('security_blog: pre-release severity wording', () => { + it('does not include severity counts in the summary', () => { + const blog = new SecurityBlog(cli); + const content = { + reports: [ + report(1, 'low'), + report(2, 'medium') + ] + }; + + assert.strictEqual( + blog.getPreReleaseVulnerabilities(content), + 'The highest severity issue fixed in this release is MEDIUM.' + ); + assert.strictEqual( + blog.getVulnerabilities(content), + '- 1 low severity issues.\n- 1 medium severity issues.' + ); + }); + + it('uses the highest severity per release line in impact text', () => { + const blog = new SecurityBlog(cli); + const content = { + reports: [ + report(1, 'low', ['22.x', '20.x']), + report(2, 'medium', ['22.x']), + report(3, 'high', ['20.x']) + ] + }; + + assert.strictEqual( + blog.getImpact(content), + 'The highest severity issue fixed in the 22.x release line is MEDIUM.\n' + + 'The highest severity issue fixed in the 20.x release line is HIGH.' + ); + }); + + it('replaces the pre-release template placeholder with the highest severity sentence', () => { + const blog = new SecurityBlog(cli); + const template = blog.getSecurityPreReleaseTemplate(); + const preRelease = blog.buildPreRelease(template, { + annoucementDate: '2026-06-01T00:00:00.000Z', + releaseDate: 'Tuesday, June 2, 2026', + affectedVersions: '24.x, 22.x', + vulnerabilities: blog.getPreReleaseVulnerabilities({ + reports: [ + report(1, 'low'), + report(2, 'high') + ] + }), + slug: 'june-2026-security-releases', + impact: 'The highest severity issue fixed in the 24.x release line is HIGH.' + }); + + assert.match( + preRelease, + /The highest severity issue fixed in this release is HIGH\./ + ); + assert.doesNotMatch(preRelease, /%VULNERABILITIES%/); + }); + + it('exits when a report is missing a severity rating', () => { + const errors = []; + const blog = new SecurityBlog({ + error(message) { + errors.push(message); + } + }); + const content = { + reports: [ + { + id: 1, + severity: {}, + affectedVersions: ['24.x'] + } + ] + }; + + assertExits(() => blog.getPreReleaseVulnerabilities(content)); + assertExits(() => blog.getImpact(content)); + assert.deepStrictEqual(errors, [ + 'severity.rating not found for report 1.', + 'severity.rating not found for report 1.' + ]); + }); +}); + +describe('security_blog: post-release severity wording', () => { + it('keeps the vulnerability count list', () => { + const blog = new SecurityBlog(cli); + const content = { + reports: [ + report(1, 'low'), + report(2, 'medium'), + report(3, 'medium') + ] + }; + + assert.strictEqual( + blog.getVulnerabilities(content), + '- 1 low severity issues.\n- 2 medium severity issues.' + ); + }); + + it('exits when a report is missing a severity rating', () => { + const errors = []; + const blog = new SecurityBlog({ + error(message) { + errors.push(message); + } + }); + const content = { + reports: [ + { + id: 1, + severity: {}, + affectedVersions: ['24.x'] + } + ] + }; + + assertExits(() => blog.getVulnerabilities(content)); + assert.deepStrictEqual(errors, [ + 'severity.rating not found for report 1.' + ]); + }); +});