import { describe, it } from 'node:test';
import assert from 'node:assert';
import SecurityBlog from '../../lib/security_blog.js';
import PrepareSecurityRelease, {
buildIncludedTriagedReport,
groupMissingReportInformation,
getMissingReportInformation,
getNextTuesdayReleaseDateChoices,
getNextTuesdayReleaseDates
} from '../../lib/prepare_security.js';
import UpdateSecurityRelease, {
resolveCveSource
} from '../../lib/update_security_release.js';
import {
getAffectedVersionLines,
getDependencyUpdates,
getHighestSeverityAnnouncement
} from '../../lib/security-release/security-release.js';
describe('security_release: cleanup pull requests', () => {
const vulnerabilities = {
reports: [
{
affectedVersions: {
main: 'https://github.com/nodejs-private/node-private/pull/2',
'24.x': 'https://github.com/nodejs-private/node-private/pull/3',
'22.x': 'https://github.com/nodejs-private/node-private/pull/3'
}
}
],
dependencies: {
undici: {
affectedVersions: {
main: 'https://github.com/nodejs/node/pull/5'
}
}
}
};
it('closes each PR from affectedVersions once', async() => {
const prompts = [];
const closed = [];
const release = new PrepareSecurityRelease({
startSpinner() {},
updateSpinner() {},
stopSpinner() {},
prompt(message) {
prompts.push(message);
return true;
}
});
release.req = {
closePullRequest(prid, repository) {
closed.push({ prid, ...repository });
}
};
await release.closePullRequests(vulnerabilities);
assert.deepStrictEqual(closed, [
{ owner: 'nodejs-private', repo: 'node-private', prid: 2 },
{ owner: 'nodejs-private', repo: 'node-private', prid: 3 },
{ owner: 'nodejs', repo: 'node', prid: 5 }
]);
assert.strictEqual(prompts.length, closed.length);
});
});
function report(id, rating, affectedVersions = ['24.x']) {
return {
id,
severity: { rating },
affectedVersions
};
}
function h1Report(overrides = {}) {
return {
id: '123',
attributes: {
title: 'Example vulnerability',
cve_ids: ['CVE-2026-0001'],
...overrides.attributes
},
relationships: {
reporter: {
data: {
attributes: {
username: 'reporter'
}
}
},
custom_field_values: {
data: [
{
attributes: {
value: 'https://github.com/nodejs-private/node-private/pull/1'
}
}
]
},
severity: {
data: {
attributes: {
rating: 'high',
cvss_vector_string: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H'
}
}
},
weakness: {
data: {
id: '79'
}
},
summaries: {
data: [
{
attributes: {
category: 'team',
content: 'A useful team summary.'
}
}
]
},
...overrides.relationships
}
};
}
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_release: affected versions', () => {
it('normalizes legacy arrays, strings, and keyed version objects', () => {
assert.deepStrictEqual(
getAffectedVersionLines(['v24.x', '22.x']),
['24.x', '22.x']
);
assert.deepStrictEqual(
getAffectedVersionLines('24.x, v22.x'),
['24.x', '22.x']
);
assert.deepStrictEqual(
getAffectedVersionLines({
'24.x': { affected: '<=24.4.0', patched: '24.4.1' },
'22.x': { affected: '<=22.17.0', patched: '22.17.1' }
}),
['24.x', '22.x']
);
});
});
describe('security_release: CVE request versions', () => {
it('shows reports selected for bulk CVE requests', () => {
const messages = [];
const release = new UpdateSecurityRelease({
info(message) {
messages.push(message);
}
});
release.showCVERequestSummary([
{
id: '3846922',
title: 'HTTP/2 retained header blocks evade maxSessionMemory',
severity: { rating: 'high' }
},
{
id: '3838601',
title: 'Permission Model bypass writes trace logs',
severity: { rating: 'medium' }
}
]);
assert.deepStrictEqual(messages, [
'Reports selected for CVE requests (2):',
'- 3846922 [high] HTTP/2 retained header blocks evade maxSessionMemory',
'- 3838601 [medium] Permission Model bypass writes trace logs'
]);
});
it('shows the bulk CVE summary before asking to request all reports', async() => {
const events = [];
const release = new UpdateSecurityRelease({
info(message) {
events.push(`info:${message}`);
},
warn() {},
prompt(message) {
events.push(`prompt:${message}`);
return false;
}
});
release.collectSuccessfulCVERequests = async() => [];
await release.promptRequestAllCVEs([
{
id: '1',
title: 'First report',
cveIds: [],
severity: {
rating: 'high',
weakness_id: '1',
cvss_vector_string: 'CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H'
},
affectedVersions: ['24.x'],
summary: 'First summary'
},
{
id: '2',
title: 'Second report',
cveIds: [],
severity: {
rating: 'medium',
weakness_id: '1',
cvss_vector_string: 'CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N'
},
affectedVersions: ['24.x'],
summary: 'Second summary'
}
]);
assert.deepStrictEqual(events.slice(0, 4), [
'info:Reports selected for CVE requests (2):',
'info:- 1 [high] First report',
'info:- 2 [medium] Second report',
'prompt:Request CVEs for all reports without prompting for each report?'
]);
});
it('reuses version answers for repeated release lines', async() => {
const prompts = [];
const radioPrompts = [];
const release = new UpdateSecurityRelease({
prompt(message, options) {
prompts.push(message);
assert.strictEqual(options.questionType, 'input');
if (message.includes('24.x')) return '24.18.0';
if (message.includes('22.x')) return '22.23.1';
throw new Error(`Unexpected prompt: ${message}`);
},
promptRadio(message, choices) {
radioPrompts.push(message);
return choices[0];
}
});
const supportedVersions = [
{ major: 24, version: '24.18.0' },
{ major: 22, version: '22.23.1' }
];
const versionCache = new Map();
assert.deepStrictEqual(
(await release.calculateVersions(['24.x'], supportedVersions, [], versionCache))
.patchedVersions,
['24.18.1']
);
assert.deepStrictEqual(
(await release.calculateVersions(['24.x', '22.x'], supportedVersions, [], versionCache))
.patchedVersions,
['24.18.1', '22.23.2']
);
assert.deepStrictEqual(prompts, [
'What is the affected version (<=) for release line 24.x?',
'What is the affected version (<=) for release line 22.x?'
]);
assert.deepStrictEqual(radioPrompts, [
'What is the patched version (>=) for release line 24.x?',
'What is the patched version (>=) for release line 22.x?'
]);
});
it('offers the next Alpha versions on an Alpha release line', async() => {
const radioChoices = [];
const release = new UpdateSecurityRelease({
prompt(message, options) {
assert.strictEqual(options.defaultAnswer, '');
return '27.0.0-alpha.1.2.3';
},
promptRadio(message, choices, options) {
radioChoices.push(choices, options.defaultAnswer);
return choices[0];
}
});
assert.deepStrictEqual(
(await release.calculateVersions(['27.x'], [{ major: 26, version: '26.12.0' }], []))
.patchedVersions,
['27.0.0-alpha.1.2.4']
);
assert.deepStrictEqual(radioChoices, [
['27.0.0-alpha.1.2.4', '27.0.0-alpha.1.3.0'],
'27.0.0-alpha.1.2.4'
]);
});
it('applies successful CVE updates once after requests finish', async() => {
const messages = [];
const prompts = [];
const h1Updates = [];
const release = new UpdateSecurityRelease({
info(message) {
messages.push(message);
},
warn() {},
prompt(message) {
prompts.push(message);
return true;
}
});
const content = { reports: [] };
const successfulReports = [
{ id: '1', cveIds: ['CVE-2026-0001'] },
{ id: '2', cveIds: ['CVE-2026-0002'] }
];
let jsonUpdates = 0;
release.updateVulnerabilitiesJSON = async(updatedContent) => {
assert.strictEqual(updatedContent, content);
jsonUpdates++;
return true;
};
release.updateHackonerReportCveWithoutConfirmation = async(req, report) => {
h1Updates.push(report.id);
return true;
};
await release.applySuccessfulCVEUpdates({}, content, successfulReports);
assert.deepStrictEqual(messages, [
'CVE requests succeeded:',
'- 1: CVE-2026-0001',
'- 2: CVE-2026-0002',
'HackerOne reports updated:',
'- 1: CVE-2026-0001',
'- 2: CVE-2026-0002'
]);
assert.deepStrictEqual(prompts, [
'Update vulnerabilities.json with the successful CVE requests?',
'Update HackerOne reports with the successful CVE IDs?',
'Allow action: update 2 HackerOne reports with CVE IDs?\n\n' +
'This writes the assigned CVE IDs back to all successful HackerOne reports.'
]);
assert.strictEqual(jsonUpdates, 1);
assert.deepStrictEqual(h1Updates, ['1', '2']);
});
it('skips HackerOne updates when vulnerabilities.json update is declined', async() => {
const warnings = [];
const release = new UpdateSecurityRelease({
info() {},
warn(message) {
warnings.push(message);
},
prompt() {
return false;
}
});
let h1Updates = 0;
release.updateVulnerabilitiesJSON = async() => {
throw new Error('should not update vulnerabilities.json');
};
release.updateHackonerReportCveWithoutConfirmation = async() => {
h1Updates++;
};
await release.applySuccessfulCVEUpdates({}, {}, [
{ id: '1', cveIds: ['CVE-2026-0001'] }
]);
assert.deepStrictEqual(warnings, [
'Skipping HackerOne updates because vulnerabilities.json was not updated.'
]);
assert.strictEqual(h1Updates, 0);
});
it('skips HackerOne updates when vulnerabilities.json update fails', async() => {
const warnings = [];
let promptCount = 0;
const release = new UpdateSecurityRelease({
info() {},
warn(message) {
warnings.push(message);
},
prompt() {
promptCount++;
return true;
}
});
let h1Updates = 0;
release.updateVulnerabilitiesJSON = async() => false;
release.updateHackonerReportCveWithoutConfirmation = async() => {
h1Updates++;
};
await release.applySuccessfulCVEUpdates({}, {}, [
{ id: '1', cveIds: ['CVE-2026-0001'] }
]);
assert.deepStrictEqual(warnings, [
'Skipping HackerOne updates because vulnerabilities.json update failed.'
]);
assert.strictEqual(promptCount, 1);
assert.strictEqual(h1Updates, 0);
});
it('summarizes HackerOne update failures', async() => {
const messages = [];
const warnings = [];
const release = new UpdateSecurityRelease({
info(message) {
messages.push(message);
},
warn(message) {
warnings.push(message);
},
prompt() {
return true;
}
});
release.updateVulnerabilitiesJSON = async() => true;
release.updateHackonerReportCveWithoutConfirmation = async(req, report) => report.id === '1';
await release.applySuccessfulCVEUpdates({}, {}, [
{ id: '1', cveIds: ['CVE-2026-0001'] },
{ id: '2', cveIds: ['CVE-2026-0002'] }
]);
assert.deepStrictEqual(messages, [
'CVE requests succeeded:',
'- 1: CVE-2026-0001',
'- 2: CVE-2026-0002',
'HackerOne reports updated:',
'- 1: CVE-2026-0001'
]);
assert.deepStrictEqual(warnings, [
'HackerOne reports not updated:',
'- 2: CVE-2026-0002'
]);
});
it('keeps successful CVE requests when interrupted', async() => {
const warnings = [];
const reports = [{ id: '1' }, { id: '2' }, { id: '3' }];
const release = new UpdateSecurityRelease({
warn(message) {
warnings.push(message);
}
});
release.requestReportCVE = async({ report }) => {
if (report.id === '1') return true;
const error = new Error('User force closed the prompt');
error.name = 'ExitPromptError';
throw error;
};
assert.deepStrictEqual(
await release.collectSuccessfulCVERequests({
reports,
req: {},
programId: '123',
supportedVersions: [],
eolVersions: [],
versionCache: new Map(),
requestAll: false
}),
[reports[0]]
);
assert.deepStrictEqual(warnings, [
'CVE request interrupted. Finalizing successful requests.'
]);
});
});
describe('security_release: release date choices', () => {
it('starts with the next Tuesday when today is Tuesday', () => {
assert.deepStrictEqual(
getNextTuesdayReleaseDates(new Date(2026, 6, 7), 4),
['2026/07/14', '2026/07/21', '2026/07/28', '2026/08/04']
);
});
it('uses the upcoming Tuesday when today is before Tuesday', () => {
assert.deepStrictEqual(
getNextTuesdayReleaseDates(new Date(2026, 6, 8), 3),
['2026/07/14', '2026/07/21', '2026/07/28']
);
});
it('describes upcoming Tuesdays with relative timing', () => {
assert.deepStrictEqual(
getNextTuesdayReleaseDateChoices(new Date(2026, 6, 7), 2),
[
{
name: '2026/07/14',
value: '2026/07/14',
description: 'Tuesday, in 7 days'
},
{
name: '2026/07/21',
value: '2026/07/21',
description: 'Tuesday, in 14 days'
},
{
name: 'TBD',
value: 'TBD',
description: 'Release date not defined yet'
}
]
);
});
it('prompts with upcoming Tuesdays and TBD', async() => {
const release = new PrepareSecurityRelease({
promptSelect(message, choices, options) {
assert.strictEqual(message, 'Select target release date:');
assert.deepStrictEqual(
choices.map(({ value }) => value),
[...getNextTuesdayReleaseDates(), 'TBD']
);
assert.strictEqual(options.defaultAnswer, choices[0].value);
return choices[0].value;
}
});
assert.strictEqual(
await release.promptReleaseDate(),
getNextTuesdayReleaseDates()[0]
);
});
});
describe('security_release: include all triaged reports', () => {
it('builds a vulnerabilities.json report from a triaged H1 report', () => {
assert.deepStrictEqual(
buildIncludedTriagedReport(h1Report(), {
affectedVersions: '24.x,22.x',
patchAuthors: ['author']
}),
{
id: '123',
title: 'Example vulnerability',
cveIds: ['CVE-2026-0001'],
severity: {
rating: 'high',
cvss_vector_string: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H',
weakness_id: '79'
},
summary: 'A useful team summary.',
patchAuthors: ['author'],
prURL: 'https://github.com/nodejs-private/node-private/pull/1',
affectedVersions: ['24.x', '22.x'],
link: 'https://hackerone.com/reports/123',
reporter: 'reporter'
}
);
});
it('identifies missing information in included reports', () => {
const includedReport = buildIncludedTriagedReport(
h1Report({
attributes: {
cve_ids: []
},
relationships: {
custom_field_values: {
data: []
},
severity: {
data: {
attributes: {
rating: '',
cvss_vector_string: ''
}
}
},
weakness: {
data: {}
},
summaries: {
data: []
}
}
}),
{ affectedVersions: '', patchAuthors: [] }
);
assert.deepStrictEqual(
getMissingReportInformation(includedReport),
[
'severity rating',
'CVSS vector',
'weakness ID',
'team summary',
'PR URL',
'patch authors',
'affected versions'
]
);
});
it('groups missing information by field', () => {
assert.deepStrictEqual(
groupMissingReportInformation([
{
id: '1',
missing: ['CVSS vector', 'team summary']
},
{
id: '2',
missing: ['CVSS vector']
}
]),
[
{
field: 'CVSS vector',
reports: [
{
id: '1',
missing: ['CVSS vector', 'team summary']
},
{
id: '2',
missing: ['CVSS vector']
}
]
},
{
field: 'team summary',
reports: [
{
id: '1',
missing: ['CVSS vector', 'team summary']
}
]
}
]
);
});
it('prints a compact missing information summary', () => {
const output = [];
const release = new PrepareSecurityRelease({
ok: (message) => output.push(['ok', message]),
warn: (message) => output.push(['warn', message]),
info: (message) => output.push(['info', message])
});
release.displayMissingReportInformationSummary([
{
id: '1',
title: 'Long report title',
link: 'https://hackerone.com/reports/1',
missing: ['CVSS vector', 'team summary']
},
{
id: '2',
title: 'Another long report title',
link: 'https://hackerone.com/reports/2',
missing: ['CVSS vector']
}
]);
assert.deepStrictEqual(output, [
['warn', '2 included reports are missing information:'],
['info', '- CVSS vector (2): H1 #1, H1 #2'],
['info', '- team summary (1): H1 #1']
]);
});
it('prompts for report selection mode', async() => {
const release = new PrepareSecurityRelease({
promptSelect(message, choices, options) {
assert.strictEqual(
message,
'How would you like to choose reports for the next security release?'
);
assert.deepStrictEqual(
choices.map(({ value }) => value),
['review', 'include-all']
);
assert.strictEqual(options.defaultAnswer, 'review');
return 'include-all';
}
});
assert.strictEqual(await release.promptReportSelectionMode(), 'include-all');
});
});
describe('security_blog: pre-release severity wording', () => {
it('does not include severity counts in the summary', () => {
const blog = new SecurityBlog();
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();
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('supports keyed affected version objects with legacy arrays', () => {
const blog = new SecurityBlog();
const content = {
reports: [
report(1, 'low', {
'24.x': { affected: '<=24.4.0', patched: '24.4.1' },
'22.x': { affected: '<=22.17.0', patched: '22.17.1' }
}),
report(2, 'high', ['22.x'])
]
};
assert.strictEqual(blog.getAffectedVersions(content), '24.x, 22.x');
assert.strictEqual(
blog.getImpact(content),
'The highest severity issue fixed in the 24.x release line is LOW.\n' +
'The highest severity issue fixed in the 22.x release line is HIGH.'
);
});
it('replaces the pre-release template placeholder with the highest severity sentence', () => {
const blog = new SecurityBlog();
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 blog = new SecurityBlog();
const content = {
reports: [
{
id: 1,
severity: {},
affectedVersions: ['24.x']
}
]
};
assert.throws(() => blog.getPreReleaseVulnerabilities(content), /severity\.rating not found for report 1/);
assert.throws(() => blog.getImpact(content), /severity\.rating not found for report 1/);
});
});
describe('security_blog: post-release severity wording', () => {
it('keeps the vulnerability count list', () => {
const blog = new SecurityBlog();
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 blog = new SecurityBlog();
const content = {
reports: [
{
id: 1,
severity: {},
affectedVersions: ['24.x']
}
]
};
assert.throws(() => blog.getVulnerabilities(content), /severity\.rating not found for report 1/);
});
it('sorts post-release reports by severity', async() => {
const blog = new SecurityBlog();
const createReport = (title, rating) => ({
title,
cveIds: [`CVE-2026-${title}`],
severity: { rating },
summary: 'summary',
affectedVersions: ['24.x'],
patchAuthors: ['nodejs'],
reporter: 'reporter',
link: `https://hackerone.com/reports/${title}`
});
const output = await blog.getReportsTemplate({
reports: [
createReport('low-report', 'low'),
createReport('high-report', 'high'),
createReport('medium-report', 'medium')
]
});
assert.ok(output.indexOf('## high-report') < output.indexOf('## medium-report'));
assert.ok(output.indexOf('## medium-report') < output.indexOf('## low-report'));
});
it('formats dependency update object versions', () => {
const blog = new SecurityBlog();
const output = blog.getDependencyUpdatesTemplate({
undici: {
versions: [
{ version: '6.22.0' },
{ version: '7.16.0' }
],
affectedVersions: ['22.x', '24.x', '26.x']
},
llhttp: {
versions: ['9.3.0'],
affectedVersions: {
'24.x': { affected: '<=24.4.0', patched: '24.4.1' },
'22.x': { affected: '<=22.17.0', patched: '22.17.1' }
}
}
});
assert.match(output, /- undici \(6\.22\.0, 7\.16\.0\) on 22\.x, 24\.x, 26\.x/);
assert.match(output, /- llhttp \(9\.3\.0\) on 24\.x, 22\.x/);
assert.doesNotMatch(output, /\[object Object\]/);
});
it('formats legacy dependency update arrays', () => {
const blog = new SecurityBlog();
const output = blog.getDependencyUpdatesTemplate([
{
name: 'undici',
title: 'deps: update undici to 6.22.0',
affectedVersions: ['22.x', '24.x']
}
]);
assert.match(output, /- update undici to 6\.22\.0 on 22\.x, 24\.x/);
});
});
describe('security_blog: getDependencyUpdatesTemplate', () => {
it('renders release lines from the reports-style affectedVersions object', () => {
const blog = new SecurityBlog();
const template = blog.getDependencyUpdatesTemplate({
undici: {
affectedVersions: {
'24.x': 'https://github.com/nodejs-private/node-private/pull/922',
'22.x': 'https://github.com/nodejs-private/node-private/pull/932'
}
}
});
assert.match(template, /- undici on 24\.x, 22\.x/);
});
it('keeps rendering versions from the legacy schema', () => {
const blog = new SecurityBlog();
const template = blog.getDependencyUpdatesTemplate({
undici: {
versions: ['8.5.0'],
affectedVersions: ['22.x', '24.x']
}
});
assert.match(template, /- undici \(8\.5\.0\) on 22\.x, 24\.x/);
});
it('does not repeat a version listed once per release line', () => {
const blog = new SecurityBlog();
const template = blog.getDependencyUpdatesTemplate({
llhttp: {
versions: [
{ version: '9.4.3', affectedVersions: ['main'] },
{ version: '9.4.3', affectedVersions: ['26.x'] },
{ version: '9.4.3', affectedVersions: ['24.x'] }
],
affectedVersions: {
main: 'https://github.com/nodejs-private/node-private/pull/935',
'26.x': 'https://github.com/nodejs-private/node-private/pull/935',
'24.x': 'https://github.com/nodejs-private/node-private/pull/935'
}
}
});
assert.match(template, /- llhttp \(9\.4\.3\) on 26\.x, 24\.x/);
});
it('renders a dependency holding a list of updates', () => {
const blog = new SecurityBlog();
const template = blog.getDependencyUpdatesTemplate({
nghttp2: [
{
title: 'deps: update nghttp2 to 1.68.0',
affectedVersions: ['24.x']
},
{
name: 'nghttp2',
versions: ['1.67.1'],
affectedVersions: ['22.x']
}
]
});
assert.match(template, /- update nghttp2 to 1\.68\.0 on 24\.x/);
assert.match(template, /- nghttp2 \(1\.67\.1\) on 22\.x/);
});
it('returns an empty string when there are no dependency updates', () => {
const blog = new SecurityBlog();
assert.strictEqual(blog.getDependencyUpdatesTemplate({}), '');
assert.strictEqual(blog.getDependencyUpdatesTemplate(undefined), '');
});
});
describe('security_release: getDependencyUpdates', () => {
it('names the updates of a map keyed by dependency', () => {
assert.deepStrictEqual(
getDependencyUpdates({ undici: { versions: ['8.9.0'] } }),
[{ versions: ['8.9.0'], name: 'undici' }]);
});
it('flattens a dependency holding a list of updates', () => {
assert.deepStrictEqual(
getDependencyUpdates({
nghttp2: [
{ affectedVersions: ['24.x'] },
{ name: 'nghttp2', affectedVersions: ['22.x'] }
]
}),
[
{ affectedVersions: ['24.x'], name: 'nghttp2' },
{ affectedVersions: ['22.x'], name: 'nghttp2' }
]);
});
it('returns an empty list when there are no dependency updates', () => {
assert.deepStrictEqual(getDependencyUpdates(undefined), []);
assert.deepStrictEqual(getDependencyUpdates({}), []);
});
});
describe('security_release: OpenJS CNA reservation', () => {
function createCnaTimeoutError(correlationId) {
const error = new Error(`OpenJS CNA run ${correlationId} did not complete`);
error.name = 'CnaTimeoutError';
error.correlationId = correlationId;
return error;
}
function createCli(events) {
return {
SPINNER_STATUS: { FAILED: 'failed' },
info(message) { events.push(`info:${message}`); },
warn(message) { events.push(`warn:${message}`); },
error(message) {
events.push(`error:${message instanceof Error ? message.message : message}`);
},
startSpinner(message) { events.push(`spinner:${message}`); },
stopSpinner(message, status) { events.push(`stop:${status ?? 'ok'}:${message}`); },
prompt() { return true; }
};
}
it('defaults cve_source to hackerone and rejects unknown values', () => {
assert.strictEqual(resolveCveSource({}), 'hackerone');
assert.strictEqual(resolveCveSource({ cve_source: '' }), 'hackerone');
assert.strictEqual(resolveCveSource({ cve_source: 'hackerone' }), 'hackerone');
assert.strictEqual(resolveCveSource({ cve_source: 'openjs-cna' }), 'openjs-cna');
assert.throws(
() => resolveCveSource({ cve_source: 'mitre' }),
/Unknown cve_source "mitre"; expected one of: hackerone, openjs-cna/
);
});
it('persists the correlation id before waiting and records the reserved id', async() => {
const events = [];
const release = new UpdateSecurityRelease(createCli(events));
release.getCnaTimeoutMs = () => 1_000;
release.persistPendingCve = async(content, report) => {
events.push(`persist:${report.id}:${JSON.stringify(report.pendingCve)}`);
};
const req = {
async cnaDispatch(operation) {
events.push(`dispatch:${operation}`);
return { correlation_id: 'corr-1', status: 'queued' };
},
async cnaWaitForCompletion(correlationId, { timeoutMs }) {
events.push(`wait:${correlationId}:${timeoutMs}`);
return { status: 'completed', conclusion: 'success', result: { cve_id: 'CVE-2026-1' } };
}
};
const report = { id: '1', cveIds: [] };
const content = { reports: [report] };
const reserved = await release.reserveCveViaOpenJsCna({
req, report, content, patchedVersions: ['24.18.1']
});
assert.strictEqual(reserved, true);
assert.deepStrictEqual(report.cveIds, ['CVE-2026-1']);
assert.deepStrictEqual(report.patchedVersions, ['24.18.1']);
assert.strictEqual(report.pendingCve, undefined);
const order = events
.filter((event) => /^(dispatch|persist|wait):/.test(event))
.map((event) => event.split(':')[0]);
assert.deepStrictEqual(order, ['dispatch', 'persist', 'wait']);
const persisted = events.find((event) => event.startsWith('persist:'));
const pending = JSON.parse(persisted.slice('persist:1:'.length));
assert.strictEqual(pending.correlationId, 'corr-1');
assert.deepStrictEqual(pending.patchedVersions, ['24.18.1']);
assert.ok(pending.dispatchedAt);
assert.ok(events.includes('wait:corr-1:1000'));
});
it('keeps the pending marker when the wait times out', async() => {
const events = [];
const release = new UpdateSecurityRelease(createCli(events));
release.getCnaTimeoutMs = () => 1_000;
release.persistPendingCve = async() => { events.push('persist'); };
const req = {
async cnaWaitForCompletion(correlationId) {
throw createCnaTimeoutError(correlationId);
}
};
const report = {
id: '1',
cveIds: [],
pendingCve: { correlationId: 'corr-1', patchedVersions: ['24.18.1'] }
};
const content = { reports: [report] };
const reserved = await release.waitForReservedCve({ req, report, content });
assert.strictEqual(reserved, false);
assert.deepStrictEqual(report.cveIds, []);
assert.strictEqual(report.pendingCve.correlationId, 'corr-1');
assert.ok(!events.includes('persist'), 'timeout must not rewrite the file');
assert.ok(events.some((event) =>
event.startsWith('warn:Still waiting on corr-1') && event.includes('--request-cve')));
});
it('drops and persists the cleared marker when the run fails', async() => {
const events = [];
const release = new UpdateSecurityRelease(createCli(events));
release.getCnaTimeoutMs = () => 1_000;
release.persistPendingCve = async(content, persisted) => {
events.push(`persist:${persisted.pendingCve === undefined ? 'cleared' : 'pending'}`);
};
const req = {
async cnaWaitForCompletion(correlationId) {
throw new Error(`OpenJS CNA run ${correlationId} concluded with 'failure'`);
}
};
const report = {
id: '1',
cveIds: [],
pendingCve: { correlationId: 'corr-1', patchedVersions: ['24.18.1'] }
};
const content = { reports: [report] };
const reserved = await release.waitForReservedCve({ req, report, content });
assert.strictEqual(reserved, false);
assert.strictEqual(report.pendingCve, undefined);
assert.ok(events.includes("error:OpenJS CNA run corr-1 concluded with 'failure'"));
assert.ok(events.includes('persist:cleared'));
});
it('drops the pending marker when the run returns no CVE id', async() => {
const events = [];
const release = new UpdateSecurityRelease(createCli(events));
release.getCnaTimeoutMs = () => 1_000;
release.persistPendingCve = async(content, persisted) => {
events.push(`persist:${persisted.pendingCve === undefined ? 'cleared' : 'pending'}`);
};
const req = {
async cnaWaitForCompletion() {
return { status: 'completed', conclusion: 'success', url: 'https://example/run/1' };
}
};
const report = {
id: '1',
cveIds: [],
pendingCve: { correlationId: 'corr-1', patchedVersions: ['24.18.1'] }
};
const content = { reports: [report] };
const reserved = await release.waitForReservedCve({ req, report, content });
assert.strictEqual(reserved, false);
assert.strictEqual(report.pendingCve, undefined);
assert.ok(events.some((event) =>
event.startsWith('error:Run corr-1 succeeded but returned no cve_id')));
assert.ok(events.includes('persist:cleared'));
});
it('resumes a pending reservation without dispatching again', async() => {
const events = [];
const release = new UpdateSecurityRelease(createCli(events));
release.getCnaTimeoutMs = () => 1_000;
const req = {
async cnaDispatch() {
throw new Error('must not dispatch while a reservation is pending');
},
async cnaWaitForCompletion(correlationId) {
events.push(`wait:${correlationId}`);
return { status: 'completed', conclusion: 'success', result: { cve_id: 'CVE-2026-2' } };
}
};
const report = {
id: '2',
cveIds: [],
affectedVersions: ['24.x'],
severity: { weakness_id: '1', cvss_vector_string: 'CVSS:3.0/AV:N' },
pendingCve: { correlationId: 'corr-2', patchedVersions: ['24.18.1'] }
};
const reserved = await release.requestReportCVE({
req,
report,
supportedVersions: [],
eolVersions: [],
versionCache: new Map(),
requestAll: true,
content: { reports: [report] },
cveSource: 'openjs-cna'
});
assert.strictEqual(reserved, true);
assert.deepStrictEqual(report.cveIds, ['CVE-2026-2']);
assert.deepStrictEqual(report.patchedVersions, ['24.18.1']);
assert.strictEqual(report.pendingCve, undefined);
assert.ok(events.includes('wait:corr-2'));
assert.ok(events.some((event) =>
event.startsWith('info:Resuming pending OpenJS CNA reservation for report 2')));
});
it('reserves through the OpenJS CNA instead of HackerOne when selected', async() => {
const events = [];
const cli = createCli(events);
cli.prompt = (message, options) => {
if (options?.questionType === 'input') return '24.18.0';
return true;
};
cli.promptRadio = (message, choices) => choices[0];
const release = new UpdateSecurityRelease(cli);
release.getCnaTimeoutMs = () => 1_000;
release.persistPendingCve = async() => { events.push('persist'); };
const req = {
async requestCVE() {
throw new Error('HackerOne cve_requests must not be called');
},
async cnaDispatch(operation) {
events.push(`dispatch:${operation}`);
return { correlation_id: 'corr-3' };
},
async cnaWaitForCompletion() {
return { status: 'completed', conclusion: 'success', result: { cve_id: 'CVE-2026-3' } };
}
};
const report = {
id: '3',
title: 'Third report',
link: 'https://hackerone.com/reports/3',
summary: 'Third summary',
cveIds: [],
affectedVersions: ['24.x'],
severity: { weakness_id: '1', cvss_vector_string: 'CVSS:3.0/AV:N' }
};
const reserved = await release.requestReportCVE({
req,
report,
supportedVersions: [{ major: 24, version: '24.18.0' }],
eolVersions: [],
versionCache: new Map(),
requestAll: true,
content: { reports: [report] },
cveSource: 'openjs-cna'
});
assert.strictEqual(reserved, true);
assert.deepStrictEqual(report.cveIds, ['CVE-2026-3']);
assert.deepStrictEqual(report.patchedVersions, ['24.18.1']);
assert.ok(events.includes('dispatch:reserve-cve'));
assert.ok(events.includes('persist'));
});
});