Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions lib/security-release/security-release.js
Original file line number Diff line number Diff line change
Expand Up @@ -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%',
Expand Down Expand Up @@ -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: '',
Expand Down
58 changes: 35 additions & 23 deletions lib/security_blog.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import fs from 'node:fs';
import path from 'node:path';
import _ from 'lodash';
import nv from '@pkgjs/nv';
import {
PLACEHOLDERS,
checkoutOnSecurityReleaseBranch,
validateDate,
SecurityRelease,
commitAndPushVulnerabilitiesJSON,
getHighestSeverityAnnouncement,
} from './security-release/security-release.js';
import auth from './auth.js';
import Request from './request.js';
Expand Down Expand Up @@ -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)
};
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
Comment thread
RafaelGSS marked this conversation as resolved.

getSecurityPreReleaseTemplate() {
return fs.readFileSync(
new URL(
Expand Down
237 changes: 237 additions & 0 deletions test/unit/security_release.test.js
Original file line number Diff line number Diff line change
@@ -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.'
]);
});
});
Loading