Skip to content

Commit e084f7a

Browse files
RafaelGSSaduh95
andauthored
update: drop number of vulnerabilities on --pre-release (#1080)
When we announce a security release, we used to say we'll be fixing X High, X Medium, and so on. By communicating only the expected highest vulnerability, the ecosystem can still prepare accordingly, while maintainers can drop lower vuln if needed. Co-authored-by: Antoine du Hamel <duhamelantoine1995@gmail.com>
1 parent fd0b688 commit e084f7a

3 files changed

Lines changed: 284 additions & 23 deletions

File tree

lib/security-release/security-release.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export const NEXT_SECURITY_RELEASE_REPOSITORY = {
1111
repo: 'security-release'
1212
};
1313

14+
const SEVERITY_RANKS = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];
15+
1416
export const PLACEHOLDERS = {
1517
releaseDate: '%RELEASE_DATE%',
1618
vulnerabilitiesPRURL: '%VULNERABILITIES_PR_URL%',
@@ -130,6 +132,16 @@ export function formatDateToYYYYMMDD(date) {
130132
return `${year}/${month}/${day}`;
131133
}
132134

135+
export function getHighestSeverityAnnouncement(reports, releaseLine = 'this release') {
136+
const highestSeverityIndex = Math.max(...reports.map(
137+
r => SEVERITY_RANKS.indexOf(r.severity.rating.toUpperCase())
138+
));
139+
140+
return `The highest severity issue fixed in ${releaseLine} is ${
141+
SEVERITY_RANKS[highestSeverityIndex] ?? 'NONE'
142+
}.`;
143+
}
144+
133145
export function promptDependencies(cli) {
134146
return cli.prompt('Enter the link to the dependency update PR (leave empty to exit): ', {
135147
defaultAnswer: '',

lib/security_blog.js

Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import fs from 'node:fs';
22
import path from 'node:path';
3-
import _ from 'lodash';
43
import nv from '@pkgjs/nv';
54
import {
65
PLACEHOLDERS,
76
checkoutOnSecurityReleaseBranch,
87
validateDate,
98
SecurityRelease,
109
commitAndPushVulnerabilitiesJSON,
10+
getHighestSeverityAnnouncement,
1111
} from './security-release/security-release.js';
1212
import auth from './auth.js';
1313
import Request from './request.js';
@@ -38,7 +38,7 @@ export default class SecurityBlog extends SecurityRelease {
3838
annoucementDate: await this.getAnnouncementDate(cli),
3939
releaseDate: this.formatReleaseDate(releaseDate),
4040
affectedVersions: this.getAffectedVersions(content),
41-
vulnerabilities: this.getVulnerabilities(content),
41+
vulnerabilities: this.getPreReleaseVulnerabilities(content),
4242
slug: this.getSlug(releaseDate),
4343
impact: this.getImpact(content)
4444
};
@@ -323,6 +323,11 @@ export default class SecurityBlog extends SecurityRelease {
323323
getImpact(content) {
324324
const impact = new Map();
325325
for (const report of content.reports) {
326+
if (!report.severity?.rating) {
327+
this.cli.error(`severity.rating not found for report ${report.id}.`);
328+
process.exit(1);
329+
}
330+
326331
for (const version of report.affectedVersions) {
327332
if (!impact.has(version)) impact.set(version, []);
328333
impact.get(version).push(report);
@@ -331,38 +336,45 @@ export default class SecurityBlog extends SecurityRelease {
331336

332337
const result = Array.from(impact.entries())
333338
.sort(([a], [b]) => b.localeCompare(a)) // DESC
334-
.map(([version, reports]) => {
335-
const severityCount = new Map();
336-
337-
for (const report of reports) {
338-
const rating = report.severity.rating?.toLowerCase();
339-
if (!rating) {
340-
this.cli.error(`severity.rating not found for report ${report.id}.`);
341-
process.exit(1);
342-
}
343-
severityCount.set(rating, (severityCount.get(rating) || 0) + 1);
344-
}
345-
346-
const groupedByRating = Array.from(severityCount.entries())
347-
.map(([rating, count]) => `${count} ${rating} severity issues`)
348-
.join(', ');
349-
350-
return `The ${version} release line of Node.js is vulnerable to ${groupedByRating}.`;
351-
})
339+
.map(([version, reports]) =>
340+
getHighestSeverityAnnouncement(reports, `the ${version} release line`))
352341
.join('\n');
353342

354343
return result;
355344
}
356345

357346
getVulnerabilities(content) {
358-
const grouped = _.groupBy(content.reports, 'severity.rating');
347+
const severityCount = new Map();
348+
349+
for (const report of content.reports) {
350+
if (!report.severity?.rating) {
351+
this.cli.error(`severity.rating not found for report ${report.id}.`);
352+
process.exit(1);
353+
}
354+
355+
const rating = report.severity.rating;
356+
severityCount.set(rating, (severityCount.get(rating) || 0) + 1);
357+
}
358+
359359
const text = [];
360-
for (const [key, value] of Object.entries(grouped)) {
361-
text.push(`- ${value.length} ${key.toLocaleLowerCase()} severity issues.`);
360+
for (const [rating, count] of severityCount) {
361+
text.push(`- ${count} ${rating} severity issues.`);
362362
}
363+
363364
return text.join('\n');
364365
}
365366

367+
getPreReleaseVulnerabilities(content) {
368+
for (const report of content.reports) {
369+
if (!report.severity?.rating) {
370+
this.cli.error(`severity.rating not found for report ${report.id}.`);
371+
process.exit(1);
372+
}
373+
}
374+
375+
return getHighestSeverityAnnouncement(content.reports);
376+
}
377+
366378
getSecurityPreReleaseTemplate() {
367379
return fs.readFileSync(
368380
new URL(

test/unit/security_release.test.js

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import { describe, it } from 'node:test';
2+
import assert from 'node:assert';
3+
4+
import SecurityBlog from '../../lib/security_blog.js';
5+
import {
6+
getHighestSeverityAnnouncement
7+
} from '../../lib/security-release/security-release.js';
8+
9+
const cli = {
10+
error() {}
11+
};
12+
13+
function assertExits(fn) {
14+
const originalExit = process.exit;
15+
process.exit = () => {
16+
throw new Error('process.exit');
17+
};
18+
19+
try {
20+
assert.throws(fn, /process\.exit/);
21+
} finally {
22+
process.exit = originalExit;
23+
}
24+
}
25+
26+
function report(id, rating, affectedVersions = ['24.x']) {
27+
return {
28+
id,
29+
severity: { rating },
30+
affectedVersions
31+
};
32+
}
33+
34+
describe('security_release: severity announcement', () => {
35+
it('uses the highest severity across reports', () => {
36+
const reports = [
37+
report(1, 'low'),
38+
report(2, 'medium'),
39+
report(3, 'high')
40+
];
41+
42+
assert.strictEqual(
43+
getHighestSeverityAnnouncement(reports),
44+
'The highest severity issue fixed in this release is HIGH.'
45+
);
46+
});
47+
48+
it('can be customized with second argument', () => {
49+
const reports = [
50+
report(1, 'low'),
51+
report(2, 'medium'),
52+
report(3, 'high')
53+
];
54+
55+
assert.strictEqual(
56+
getHighestSeverityAnnouncement(reports, 'special release'),
57+
'The highest severity issue fixed in special release is HIGH.'
58+
);
59+
});
60+
61+
it('invalid severity ratings are ignored', () => {
62+
const reports = [
63+
report(1, 'low'),
64+
report(2, 'medium'),
65+
report(3, 'hypercritical')
66+
];
67+
68+
assert.strictEqual(
69+
getHighestSeverityAnnouncement(reports),
70+
'The highest severity issue fixed in this release is MEDIUM.'
71+
);
72+
});
73+
74+
it('if no valid rating is passed, output NONE', () => {
75+
const reports = [
76+
report(3, 'hypercritical')
77+
];
78+
79+
assert.strictEqual(
80+
getHighestSeverityAnnouncement(reports),
81+
'The highest severity issue fixed in this release is NONE.'
82+
);
83+
});
84+
85+
it('uses medium severity wording', () => {
86+
const reports = [
87+
report(1, 'low'),
88+
report(2, 'medium')
89+
];
90+
91+
assert.strictEqual(
92+
getHighestSeverityAnnouncement(reports),
93+
'The highest severity issue fixed in this release is MEDIUM.'
94+
);
95+
});
96+
97+
it('ignores invalid severity ratings', () => {
98+
const reports = [
99+
report(1, 'low'),
100+
report(2, 'hypercritical'),
101+
report(3, 'medium')
102+
];
103+
104+
assert.strictEqual(
105+
getHighestSeverityAnnouncement(reports),
106+
'The highest severity issue fixed in this release is MEDIUM.'
107+
);
108+
});
109+
});
110+
111+
describe('security_blog: pre-release severity wording', () => {
112+
it('does not include severity counts in the summary', () => {
113+
const blog = new SecurityBlog(cli);
114+
const content = {
115+
reports: [
116+
report(1, 'low'),
117+
report(2, 'medium')
118+
]
119+
};
120+
121+
assert.strictEqual(
122+
blog.getPreReleaseVulnerabilities(content),
123+
'The highest severity issue fixed in this release is MEDIUM.'
124+
);
125+
assert.strictEqual(
126+
blog.getVulnerabilities(content),
127+
'- 1 low severity issues.\n- 1 medium severity issues.'
128+
);
129+
});
130+
131+
it('uses the highest severity per release line in impact text', () => {
132+
const blog = new SecurityBlog(cli);
133+
const content = {
134+
reports: [
135+
report(1, 'low', ['22.x', '20.x']),
136+
report(2, 'medium', ['22.x']),
137+
report(3, 'high', ['20.x'])
138+
]
139+
};
140+
141+
assert.strictEqual(
142+
blog.getImpact(content),
143+
'The highest severity issue fixed in the 22.x release line is MEDIUM.\n' +
144+
'The highest severity issue fixed in the 20.x release line is HIGH.'
145+
);
146+
});
147+
148+
it('replaces the pre-release template placeholder with the highest severity sentence', () => {
149+
const blog = new SecurityBlog(cli);
150+
const template = blog.getSecurityPreReleaseTemplate();
151+
const preRelease = blog.buildPreRelease(template, {
152+
annoucementDate: '2026-06-01T00:00:00.000Z',
153+
releaseDate: 'Tuesday, June 2, 2026',
154+
affectedVersions: '24.x, 22.x',
155+
vulnerabilities: blog.getPreReleaseVulnerabilities({
156+
reports: [
157+
report(1, 'low'),
158+
report(2, 'high')
159+
]
160+
}),
161+
slug: 'june-2026-security-releases',
162+
impact: 'The highest severity issue fixed in the 24.x release line is HIGH.'
163+
});
164+
165+
assert.match(
166+
preRelease,
167+
/The highest severity issue fixed in this release is HIGH\./
168+
);
169+
assert.doesNotMatch(preRelease, /%VULNERABILITIES%/);
170+
});
171+
172+
it('exits when a report is missing a severity rating', () => {
173+
const errors = [];
174+
const blog = new SecurityBlog({
175+
error(message) {
176+
errors.push(message);
177+
}
178+
});
179+
const content = {
180+
reports: [
181+
{
182+
id: 1,
183+
severity: {},
184+
affectedVersions: ['24.x']
185+
}
186+
]
187+
};
188+
189+
assertExits(() => blog.getPreReleaseVulnerabilities(content));
190+
assertExits(() => blog.getImpact(content));
191+
assert.deepStrictEqual(errors, [
192+
'severity.rating not found for report 1.',
193+
'severity.rating not found for report 1.'
194+
]);
195+
});
196+
});
197+
198+
describe('security_blog: post-release severity wording', () => {
199+
it('keeps the vulnerability count list', () => {
200+
const blog = new SecurityBlog(cli);
201+
const content = {
202+
reports: [
203+
report(1, 'low'),
204+
report(2, 'medium'),
205+
report(3, 'medium')
206+
]
207+
};
208+
209+
assert.strictEqual(
210+
blog.getVulnerabilities(content),
211+
'- 1 low severity issues.\n- 2 medium severity issues.'
212+
);
213+
});
214+
215+
it('exits when a report is missing a severity rating', () => {
216+
const errors = [];
217+
const blog = new SecurityBlog({
218+
error(message) {
219+
errors.push(message);
220+
}
221+
});
222+
const content = {
223+
reports: [
224+
{
225+
id: 1,
226+
severity: {},
227+
affectedVersions: ['24.x']
228+
}
229+
]
230+
};
231+
232+
assertExits(() => blog.getVulnerabilities(content));
233+
assert.deepStrictEqual(errors, [
234+
'severity.rating not found for report 1.'
235+
]);
236+
});
237+
});

0 commit comments

Comments
 (0)