From b12497ae1d8e5908f18c678d36e98aa7414f054f Mon Sep 17 00:00:00 2001 From: RafaelGSS Date: Tue, 26 May 2026 09:50:55 -0300 Subject: [PATCH 1/2] feat: drop number of vulnerabilities on --pre-release When we announce a security release, we typically say we'll be fixing X High, X Medium, and so on. That policy was set before the AI era, when reports weren't as frequent. Signed-off-by: RafaelGSS --- lib/security-release/security-release.js | 34 ++++++++++ lib/security_blog.js | 37 +++++------ test/unit/security_release.test.js | 83 ++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 22 deletions(-) create mode 100644 test/unit/security_release.test.js diff --git a/lib/security-release/security-release.js b/lib/security-release/security-release.js index e662910a..a2af628b 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 = { + critical: 0, + high: 1, + medium: 2, + low: 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,26 @@ export function formatDateToYYYYMMDD(date) { return `${year}/${month}/${day}`; } +export function getHighestSeverity(reports) { + let highestSeverity = ''; + + for (const report of reports) { + const rating = report.severity.rating.toLowerCase(); + const currentRank = SEVERITY_RANK[rating] ?? Number.MAX_SAFE_INTEGER; + const highestRank = SEVERITY_RANK[highestSeverity] ?? Number.MAX_SAFE_INTEGER; + + if (!highestSeverity || currentRank < highestRank) { + highestSeverity = rating; + } + } + + return SEVERITY_LABEL[highestSeverity] ?? highestSeverity.toUpperCase(); +} + +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..305d1212 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'; @@ -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,12 +347,13 @@ export default class SecurityBlog extends SecurityRelease { } getVulnerabilities(content) { - const grouped = _.groupBy(content.reports, 'severity.rating'); - const text = []; - for (const [key, value] of Object.entries(grouped)) { - text.push(`- ${value.length} ${key.toLocaleLowerCase()} severity issues.`); + 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 text.join('\n'); + return getHighestSeverityAnnouncement(content.reports); } getSecurityPreReleaseTemplate() { diff --git a/test/unit/security_release.test.js b/test/unit/security_release.test.js new file mode 100644 index 00000000..eef08c3b --- /dev/null +++ b/test/unit/security_release.test.js @@ -0,0 +1,83 @@ +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 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.' + ); + }); +}); + +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.getVulnerabilities(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.' + ); + }); +}); From da25653c27e1ac2ed5519cf86dd41250b0891347 Mon Sep 17 00:00:00 2001 From: RafaelGSS Date: Thu, 28 May 2026 15:28:06 -0300 Subject: [PATCH 2/2] fix: split security release vulnerability wording Signed-off-by: RafaelGSS --- lib/security-release/security-release.js | 19 ++-- lib/security_blog.js | 24 ++++- test/unit/security_release.test.js | 115 ++++++++++++++++++++++- 3 files changed, 147 insertions(+), 11 deletions(-) diff --git a/lib/security-release/security-release.js b/lib/security-release/security-release.js index a2af628b..6ef8f7b2 100644 --- a/lib/security-release/security-release.js +++ b/lib/security-release/security-release.js @@ -12,10 +12,10 @@ export const NEXT_SECURITY_RELEASE_REPOSITORY = { }; const SEVERITY_RANK = { - critical: 0, - high: 1, - medium: 2, - low: 3 + low: 0, + medium: 1, + high: 2, + critical: 3 }; const SEVERITY_LABEL = { @@ -146,18 +146,19 @@ export function formatDateToYYYYMMDD(date) { export function getHighestSeverity(reports) { let highestSeverity = ''; + let highestRank = -1; for (const report of reports) { - const rating = report.severity.rating.toLowerCase(); - const currentRank = SEVERITY_RANK[rating] ?? Number.MAX_SAFE_INTEGER; - const highestRank = SEVERITY_RANK[highestSeverity] ?? Number.MAX_SAFE_INTEGER; + const rating = report.severity.rating; + const currentRank = SEVERITY_RANK[rating] ?? -1; - if (!highestSeverity || currentRank < highestRank) { + if (currentRank > highestRank) { highestSeverity = rating; + highestRank = currentRank; } } - return SEVERITY_LABEL[highestSeverity] ?? highestSeverity.toUpperCase(); + return SEVERITY_LABEL[highestSeverity] ?? 'NONE'; } export function getHighestSeverityAnnouncement(reports) { diff --git a/lib/security_blog.js b/lib/security_blog.js index 305d1212..4eeb4bc6 100644 --- a/lib/security_blog.js +++ b/lib/security_blog.js @@ -39,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) }; @@ -347,12 +347,34 @@ export default class SecurityBlog extends SecurityRelease { } getVulnerabilities(content) { + 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 [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); } diff --git a/test/unit/security_release.test.js b/test/unit/security_release.test.js index eef08c3b..84ab5d1f 100644 --- a/test/unit/security_release.test.js +++ b/test/unit/security_release.test.js @@ -11,6 +11,19 @@ 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, @@ -46,6 +59,16 @@ describe('security_release: severity announcement', () => { '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', () => { @@ -59,7 +82,7 @@ describe('security_blog: pre-release severity wording', () => { }; assert.strictEqual( - blog.getVulnerabilities(content), + blog.getPreReleaseVulnerabilities(content), 'The highest severity issue fixed in this release is MEDIUM.' ); }); @@ -80,4 +103,94 @@ describe('security_blog: pre-release severity wording', () => { '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.' + ]); + }); });