diff --git a/lib/security-release/security-release.js b/lib/security-release/security-release.js index e662910a..6ef8f7b2 100644 --- a/lib/security-release/security-release.js +++ b/lib/security-release/security-release.js @@ -11,6 +11,20 @@ export const NEXT_SECURITY_RELEASE_REPOSITORY = { repo: 'security-release' }; +const SEVERITY_RANK = { + low: 0, + medium: 1, + high: 2, + critical: 3 +}; + +const SEVERITY_LABEL = { + critical: 'CRITICAL', + high: 'HIGH', + medium: 'MEDIUM', + low: 'LOW' +}; + export const PLACEHOLDERS = { releaseDate: '%RELEASE_DATE%', vulnerabilitiesPRURL: '%VULNERABILITIES_PR_URL%', @@ -130,6 +144,27 @@ export function formatDateToYYYYMMDD(date) { return `${year}/${month}/${day}`; } +export function getHighestSeverity(reports) { + let highestSeverity = ''; + let highestRank = -1; + + for (const report of reports) { + const rating = report.severity.rating; + const currentRank = SEVERITY_RANK[rating] ?? -1; + + if (currentRank > highestRank) { + highestSeverity = rating; + highestRank = currentRank; + } + } + + return SEVERITY_LABEL[highestSeverity] ?? 'NONE'; +} + +export function getHighestSeverityAnnouncement(reports) { + return `The highest severity issue fixed in this release is ${getHighestSeverity(reports)}.`; +} + 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..4eeb4bc6 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,8 @@ import { validateDate, SecurityRelease, commitAndPushVulnerabilitiesJSON, + getHighestSeverity, + getHighestSeverityAnnouncement, } from './security-release/security-release.js'; import auth from './auth.js'; import Request from './request.js'; @@ -38,7 +39,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 +324,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); @@ -332,22 +338,8 @@ 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}.`; + return `The highest severity issue fixed in the ${version} release line is ` + + `${getHighestSeverity(reports)}.`; }) .join('\n'); @@ -355,14 +347,37 @@ export default class SecurityBlog extends SecurityRelease { } 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..84ab5d1f --- /dev/null +++ b/test/unit/security_release.test.js @@ -0,0 +1,196 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; + +import SecurityBlog from '../../lib/security_blog.js'; +import { + getHighestSeverity, + 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(getHighestSeverity(reports), 'HIGH'); + assert.strictEqual( + getHighestSeverityAnnouncement(reports), + 'The highest severity issue fixed in this release is HIGH.' + ); + }); + + it('uses medium severity wording', () => { + const reports = [ + report(1, 'low'), + report(2, 'medium') + ]; + + assert.strictEqual(getHighestSeverity(reports), '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(getHighestSeverity(reports), '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.' + ); + }); + + 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.' + ]); + }); +});