import path from 'node:path';
import auth from './auth.js';
import Request from './request.js';
import { parsePRFromURL } from './links.js';
import {
assertNewSecurityRelease,
getSecurityReleaseDraftPath,
writeSecurityReleaseDraft
} from './security-release/draft.js';
import {
buildIncludedTriagedReport,
getReportPRURL,
getMissingReportInformation,
groupMissingReportInformation,
listSecurityReleaseCandidates,
prepareSecurityRelease
} from './security-release/preparation.js';
import {
NEXT_SECURITY_RELEASE_BRANCH,
checkoutOnSecurityReleaseBranch,
commitAndPushVulnerabilitiesJSON,
validateDate,
promptDependencies,
getSupportedVersions,
getReportSeverity,
pickReport,
confirmSecurityStep,
SecurityRelease
} from './security-release/security-release.js';
export {
buildIncludedTriagedReport,
getMissingReportInformation,
groupMissingReportInformation
} from './security-release/preparation.js';
function relativeDate(date) {
const days = Math.floor((Date.now() - date) / (1000 * 60 * 60 * 24));
if (days < 30) return days === 1 ? '1 day ago' : `${days} days ago`;
const months = Math.floor(days / 30);
return months === 1 ? '1 month ago' : `${months} months ago`;
}
function formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}/${month}/${day}`;
}
function formatDaysFromNow(days) {
return days === 1 ? 'in 1 day' : `in ${days} days`;
}
function getNextTuesdayReleaseDateObjects(fromDate = new Date(), count = 4) {
const TUESDAY = 2;
const start = new Date(
fromDate.getFullYear(),
fromDate.getMonth(),
fromDate.getDate()
);
const daysUntilTuesday = (TUESDAY - start.getDay() + 7) % 7 || 7;
start.setDate(start.getDate() + daysUntilTuesday);
return Array.from({ length: count }, (_, index) => {
const date = new Date(start);
date.setDate(start.getDate() + (index * 7));
return {
date,
daysFromNow: daysUntilTuesday + (index * 7)
};
});
}
export function getNextTuesdayReleaseDates(fromDate = new Date(), count = 4) {
return getNextTuesdayReleaseDateObjects(fromDate, count)
.map(({ date }) => formatDate(date));
}
export function getNextTuesdayReleaseDateChoices(fromDate = new Date(), count = 4) {
const choices = getNextTuesdayReleaseDateObjects(fromDate, count)
.map(({ date, daysFromNow }) => {
const formattedDate = formatDate(date);
return {
name: formattedDate,
value: formattedDate,
description: `Tuesday, ${formatDaysFromNow(daysFromNow)}`
};
});
choices.push({
name: 'TBD',
value: 'TBD',
description: 'Release date not defined yet'
});
return choices;
}
export default class PrepareSecurityRelease extends SecurityRelease {
title = 'Next Security Release';
async start() {
assertNewSecurityRelease(process.cwd());
const credentials = await auth({
github: true,
h1: true
});
this.req = new Request(credentials);
let excludedReports = [];
const releaseDate = await this.promptReleaseDate();
if (releaseDate !== 'TBD') {
validateDate(releaseDate);
}
const createVulnerabilitiesJSON = await this.promptVulnerabilitiesJSON();
const content = await this.buildDescription(releaseDate);
if (createVulnerabilitiesJSON) {
const reportSelectionMode = await this.promptReportSelectionMode();
const candidates = await listSecurityReleaseCandidates(this.req);
if (reportSelectionMode === 'review') {
const showTriaged = await this.promptShowTriagedWithoutPR();
if (showTriaged) {
excludedReports = await this.showTriagedReportsWithoutPR(candidates);
}
}
await this.startVulnerabilitiesJSONCreation(
releaseDate, content, excludedReports, reportSelectionMode, candidates);
}
this.cli.ok('Done!');
}
async cleanup() {
const credentials = await auth({
github: true,
h1: true
});
this.req = new Request(credentials);
const vulnerabilityJSON = this.readVulnerabilitiesJSON();
this.cli.info('Closing and request disclosure to HackerOne reports');
await this.closeAndRequestDisclosure(vulnerabilityJSON.reports);
this.cli.info('Closing pull requests');
await this.closePullRequests(vulnerabilityJSON);
if (vulnerabilityJSON.buildIssue) {
this.cli.info('Commenting on nodejs/build issue');
await confirmSecurityStep(
this.cli,
`comment on GitHub issue \`${vulnerabilityJSON.buildIssue}\``,
'This posts that the security release is out on the build tracking issue.'
);
await this.req.commentIssue(
vulnerabilityJSON.buildIssue,
'Security release is out'
);
}
if (vulnerabilityJSON.dockerIssue) {
this.cli.info('Commenting on nodejs/docker-node issue');
await confirmSecurityStep(
this.cli,
`comment on GitHub issue \`${vulnerabilityJSON.dockerIssue}\``,
'This posts that the security release is out on the docker-node tracking issue.'
);
await this.req.commentIssue(
vulnerabilityJSON.dockerIssue,
'Security release is out'
);
}
const updateFolder = await this.cli.prompt(
`Would you like to update the next-security-release folder to ${
vulnerabilityJSON.releaseDate}?`,
{ defaultAnswer: true });
if (updateFolder) {
await this.updateReleaseFolder(
vulnerabilityJSON.releaseDate.replaceAll('/', '-')
);
const securityReleaseFolder = path.join(process.cwd(), 'security-release');
await commitAndPushVulnerabilitiesJSON(
securityReleaseFolder,
'chore: change next-security-release folder',
{ cli: this.cli, repository: this.repository }
);
}
this.cli.info(`If the PR is ready (CI is passing): merge pull request with:
- git checkout main
- git merge ${NEXT_SECURITY_RELEASE_BRANCH} --no-ff -m "chore: add latest security release"
- git push origin main`);
this.cli.ok('Done!');
}
async startVulnerabilitiesJSONCreation(
releaseDate,
content,
excludedReports = [],
reportSelectionMode = 'review',
candidates
) {
assertNewSecurityRelease(process.cwd());
// choose the reports to include in the security release
const reports = reportSelectionMode === 'include-all'
? await this.includeAllTriagedReports(excludedReports, candidates)
: await this.chooseReports(excludedReports, candidates);
const deps = await this.getDependencyUpdates();
const { release } = prepareSecurityRelease({ releaseDate, reports, dependencies: deps });
// Prepare all data before changing Git state. An existing branch may contain
// a draft that was not present on the branch where this command started.
await checkoutOnSecurityReleaseBranch(this.cli, this.repository);
assertNewSecurityRelease(process.cwd());
const filePath = await this.createVulnerabilitiesJSON(
release.reports, release.dependencies, release.releaseDate);
// review the vulnerabilities.json file
const review = await this.promptReviewVulnerabilitiesJSON();
if (!review) {
this.cli.info(`To push the vulnerabilities.json file run:
- git add ${filePath}
- git commit -m "chore: create vulnerabilities.json for next security release"
- git push -u origin ${NEXT_SECURITY_RELEASE_BRANCH}
- open a PR on ${this.repository.owner}/${this.repository.repo}`);
return;
}
// commit and push the vulnerabilities.json file
const commitMessage = 'chore: create vulnerabilities.json for next security release';
await commitAndPushVulnerabilitiesJSON(filePath,
commitMessage,
{ cli: this.cli, repository: this.repository });
const createPr = await this.promptCreatePR();
if (!createPr) return;
// create pr on the security-release repo
return this.createPullRequest(content);
}
promptCreatePR() {
return this.cli.prompt(
'Create the Next Security Release PR?',
{ defaultAnswer: true });
}
async getSecurityIssueTemplate() {
const url = 'https://raw.githubusercontent.com/nodejs/node/main/doc/contributing/security-release-process.md';
try {
// fetch document from nodejs/node main so we dont need to keep a copy
const response = await fetch(url);
const body = await response.text();
// remove everything before the Planning section
const index = body.indexOf('## Planning');
if (index !== -1) {
return body.substring(index);
}
return body;
} catch {
this.cli.error(`Could not retrieve the security issue template from ${url}`);
}
}
async promptReleaseDate() {
const choices = getNextTuesdayReleaseDateChoices();
return this.cli.promptSelect(
'Select target release date:',
choices,
{ defaultAnswer: choices[0].value }
);
}
async promptVulnerabilitiesJSON() {
return this.cli.prompt(
'Create the vulnerabilities.json?',
{ defaultAnswer: true });
}
async promptReportSelectionMode() {
return this.cli.promptSelect(
'How would you like to choose reports for the next security release?',
[
{
name: 'Review each triaged report',
value: 'review',
description: 'Iterate over reports and fill missing details interactively'
},
{
name: 'Include all triaged reports',
value: 'include-all',
description: 'Add every triaged report and summarize missing information'
}
],
{ defaultAnswer: 'review' }
);
}
async promptCreateRelaseIssue() {
return this.cli.prompt(
'Create the Next Security Release issue?',
{ defaultAnswer: true });
}
async promptReviewVulnerabilitiesJSON() {
return this.cli.prompt(
'Please review vulnerabilities.json and press enter to proceed.',
{ defaultAnswer: true });
}
async promptShowTriagedWithoutPR() {
return this.cli.prompt(
'Do you want to see which reports are triaged but have no PR URL?',
{ defaultAnswer: true });
}
async showTriagedReportsWithoutPR(candidates) {
this.cli.info('Fetching triaged reports without PR URL...');
const reports = candidates ?? await listSecurityReleaseCandidates(this.req);
const reportsWithoutPR = reports.filter(
(report) => !getReportPRURL(report)
);
if (!reportsWithoutPR.length) {
this.cli.ok('All triaged reports have a PR URL.');
return [];
}
const severityRank = { critical: 0, high: 1, medium: 2, low: 3 };
const choices = reportsWithoutPR
.sort((a, b) => {
const dateA = new Date(a.attributes.created_at);
const dateB = new Date(b.attributes.created_at);
if (dateB - dateA !== 0) return dateB - dateA;
const rankA = severityRank[getReportSeverity(a).rating] ?? 4;
const rankB = severityRank[getReportSeverity(b).rating] ?? 4;
return rankA - rankB;
})
.map((report) => {
const { id, attributes: { title, created_at } } = report;
const { rating } = getReportSeverity(report);
const openedDate = relativeDate(new Date(created_at));
const link = `https://hackerone.com/reports/${id}`;
return {
name: `[${openedDate}] (${rating}) ${title} - ${link}`,
value: id,
checked: true
};
});
return this.cli.promptCheckbox(
'Select reports to exclude from the upcoming security release:',
choices
);
}
async buildDescription() {
const template = await this.getSecurityIssueTemplate();
return template;
}
async chooseReports(excludedReports = [], candidates) {
this.cli.info('Getting triaged H1 reports...');
const reports = candidates ?? await listSecurityReleaseCandidates(this.req);
const selectedReports = [];
for (const report of reports) {
if (excludedReports.includes(report.id)) continue;
const rep = await pickReport(report, { cli: this.cli, req: this.req });
if (!rep) continue;
selectedReports.push(rep);
}
return selectedReports;
}
async includeAllTriagedReports(excludedReports = [], candidates) {
this.cli.info('Getting triaged H1 reports...');
const reports = candidates ?? await listSecurityReleaseCandidates(this.req);
const supportedVersions = await getSupportedVersions();
const selectedReports = [];
const missingInformation = [];
for (const report of reports) {
if (excludedReports.includes(report.id)) continue;
const reportData = await this.buildIncludedTriagedReport(
report,
supportedVersions
);
selectedReports.push(reportData);
const missing = getMissingReportInformation(reportData);
if (missing.length) {
missingInformation.push({
id: reportData.id,
title: reportData.title,
link: reportData.link,
missing
});
}
}
this.displayMissingReportInformationSummary(missingInformation);
return selectedReports;
}
async buildIncludedTriagedReport(report, supportedVersions) {
const prURL = getReportPRURL(report);
const patchAuthors = await this.getPatchAuthorsFromPR(prURL);
return buildIncludedTriagedReport(report, {
affectedVersions: supportedVersions,
patchAuthors,
prURL
});
}
async getPatchAuthorsFromPR(prURL) {
if (!prURL) return [];
try {
const { user } = await this.req.getPullRequest(prURL);
return user?.login ? [user.login] : [];
} catch (error) {
this.cli.warn(`Could not fetch patch author from ${prURL}`);
this.cli.error(error);
return [];
}
}
displayMissingReportInformationSummary(reports) {
if (!reports.length) {
this.cli.ok('All included reports have the expected information.');
return;
}
const grouped = groupMissingReportInformation(reports);
this.cli.warn(
`${reports.length} included reports are missing information:`
);
for (const { field, reports: fieldReports } of grouped) {
const reportList = fieldReports
.map(({ id }) => `H1 #${id}`)
.join(', ');
this.cli.info(
`- ${field} (${fieldReports.length}): ${reportList}`
);
}
}
async createVulnerabilitiesJSON(reports, dependencies, releaseDate) {
const { release } = prepareSecurityRelease({ releaseDate, reports, dependencies });
const directory = process.cwd();
const fullPath = getSecurityReleaseDraftPath(directory);
assertNewSecurityRelease(directory);
await confirmSecurityStep(
this.cli,
`create directory \`${path.dirname(fullPath)}\``,
'This creates the security release folder if it does not already exist.'
);
await confirmSecurityStep(
this.cli,
`write \`${fullPath}\``,
'This creates vulnerabilities.json for the next security release.'
);
this.cli.startSpinner('Creating vulnerabilities.json...');
writeSecurityReleaseDraft(directory, release);
this.cli.stopSpinner(`Created ${fullPath}`);
return fullPath;
}
async createPullRequest(content) {
const { owner, repo } = this.repository;
await confirmSecurityStep(
this.cli,
`create GitHub pull request \`${owner}/${repo}: ${this.title}\``,
`This opens a pull request from ${NEXT_SECURITY_RELEASE_BRANCH} to main.`
);
const response = await this.req.createPullRequest(
this.title,
content ?? 'List of vulnerabilities to be included in the next security release',
{
owner,
repo,
base: 'main',
head: 'next-security-release'
}
);
const url = response?.html_url;
if (url) {
this.cli.ok(`Created: ${url}`);
return url;
}
if (response?.errors) {
for (const error of response.errors) {
this.cli.error(error.message);
}
} else {
this.cli.error(response);
}
process.exit(1);
}
// Collect dependency updates using the same schema as the reports: each
// dependency maps every affected release line to the PR that lands it, e.g.
// { undici: { affectedVersions: { '22.x': '<prURL>', '24.x': '<prURL>' } } }
async getDependencyUpdates() {
const deps = {};
const updates = await this.cli.prompt('Are there dependency updates in this security release?',
{
defaultAnswer: true,
questionType: 'confirm'
});
if (!updates) return deps;
const supportedVersions = await getSupportedVersions();
while (true) {
const dep = await promptDependencies(this.cli);
if (!dep) {
break;
}
const name = await this.cli.prompt(
'What is the name of the dependency that has been updated?', {
defaultAnswer: '',
questionType: 'input'
});
const versions = await this.cli.prompt(
'Which release line(s) does this dependency update PR affect? ' +
'(comma-separated)', {
defaultAnswer: supportedVersions,
questionType: 'input'
});
try {
const res = await this.req.getPullRequest(dep);
const { html_url: prURL } = res;
const affectedVersions = versions
.split(',')
.map((v) => v.replace('v', '').trim())
.filter(Boolean);
deps[name] ??= { affectedVersions: {} };
for (const line of affectedVersions) {
deps[name].affectedVersions[line] = prURL;
}
this.cli.separator();
} catch (error) {
this.cli.error('Invalid PR url. Please provide a valid PR url.');
this.cli.error(error);
}
}
return deps;
}
async closeAndRequestDisclosure(jsonReports) {
this.cli.startSpinner('Closing HackerOne reports');
for (const report of jsonReports) {
this.cli.updateSpinner(`Closing report ${report.id}...`);
await confirmSecurityStep(
this.cli,
`resolve HackerOne report \`${report.id}\``,
'This marks the HackerOne report as resolved.'
);
await this.req.updateReportState(
report.id,
'resolved',
'Closing as resolved'
);
this.cli.updateSpinner(`Requesting disclosure to report ${report.id}...`);
await confirmSecurityStep(
this.cli,
`request disclosure for HackerOne report \`${report.id}\``,
'This asks HackerOne to disclose the resolved report.'
);
await this.req.requestDisclosure(report.id);
}
this.cli.stopSpinner('Done closing H1 Reports and requesting disclosure');
}
async closePullRequests(vulnerabilityJSON) {
this.cli.startSpinner('Closing GitHub Pull Requests...');
const entries = [
...vulnerabilityJSON.reports,
...Object.values(vulnerabilityJSON.dependencies ?? {})
];
const urls = new Set(entries.flatMap(
({ affectedVersions }) => Object.values(affectedVersions)));
for (const url of urls) {
const { owner, repo, prid } = parsePRFromURL(url);
const name = `${owner}/${repo}#${prid}`;
this.cli.updateSpinner(`Closing Pull Request: ${name}`);
await confirmSecurityStep(
this.cli,
`close GitHub pull request \`${name}\``,
'This closes a pull request listed in vulnerabilities.json.'
);
await this.req.closePullRequest(prid, { owner, repo });
}
this.cli.stopSpinner('Closed GitHub Pull Requests.');
}
}