Skip to content
Open
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
35 changes: 35 additions & 0 deletions lib/security-release/security-release.js
Original file line number Diff line number Diff line change
Expand Up @@ -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%',
Expand Down Expand Up @@ -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: '',
Expand Down
57 changes: 36 additions & 21 deletions lib/security_blog.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import fs from 'node:fs';
import path from 'node:path';
import _ from 'lodash';
import nv from '@pkgjs/nv';
import {
PLACEHOLDERS,
checkoutOnSecurityReleaseBranch,
validateDate,
SecurityRelease,
commitAndPushVulnerabilitiesJSON,
getHighestSeverity,
getHighestSeverityAnnouncement,
} from './security-release/security-release.js';
import auth from './auth.js';
import Request from './request.js';
Expand Down Expand Up @@ -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)
};
Expand Down Expand Up @@ -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);
Expand All @@ -332,37 +338,46 @@ 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');

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
196 changes: 196 additions & 0 deletions test/unit/security_release.test.js
Original file line number Diff line number Diff line change
@@ -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.'
]);
});
});
Loading