import { runSync } from '../run.js';
import nv from '@pkgjs/nv';
import fs from 'node:fs';
import path from 'node:path';
export const NEXT_SECURITY_RELEASE_BRANCH = 'next-security-release';
export const NEXT_SECURITY_RELEASE_FOLDER = 'security-release/next-security-release';
export const NEXT_SECURITY_RELEASE_REPOSITORY = {
owner: 'nodejs-private',
repo: 'security-release'
};
export const SEVERITY_RANKS = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];
const RELEASE_LINE_RE = /^v?(\d+)(?:\.x)?$/;
const SEMVER_RE = /^v?(\d+)\.\d+\.\d+/;
export const PLACEHOLDERS = {
releaseDate: '%RELEASE_DATE%',
vulnerabilitiesPRURL: '%VULNERABILITIES_PR_URL%',
preReleasePrivate: '%PRE_RELEASE_PRIV%',
postReleasePrivate: '%POS_RELEASE_PRIV%',
affectedLines: '%AFFECTED_LINES%',
annoucementDate: '%ANNOUNCEMENT_DATE%',
slug: '%SLUG%',
affectedVersions: '%AFFECTED_VERSIONS%',
impact: '%IMPACT%',
vulnerabilities: '%VULNERABILITIES%',
reports: '%REPORTS%',
author: '%AUTHOR%',
dependencyUpdates: '%DEPENDENCY_UPDATES%',
downloads: '%DOWNLOADS%'
};
function formatCommand(command, args) {
return [command, ...args].join(' ');
}
export async function confirmSecurityStep(cli, action, detail) {
let message = `Allow action: ${action}?`;
if (detail) {
message += `\n\n${detail}`;
}
const allowed = await cli.prompt(message, { defaultAnswer: false });
if (!allowed) {
throw new Error(`Aborted: ${action}.`);
}
}
export async function runSecurityGitCommand(cli, args, detail) {
const command = formatCommand('git', args);
await confirmSecurityStep(cli, `run \`${command}\``, detail);
return runSync('git', args);
}
export async function writeSecurityFile(cli, filePath, content, detail) {
await confirmSecurityStep(cli, `write \`${filePath}\``, detail);
return fs.writeFileSync(filePath, content);
}
export async function checkRemote(cli, repository) {
const remote = runSync('git', ['ls-remote', '--get-url', 'origin']).trim();
const { owner, repo } = repository;
const securityReleaseOrigin = [
`https://github.com/${owner}/${repo}.git`,
`git@github.com:${owner}/${repo}.git`
];
if (!securityReleaseOrigin.includes(remote)) {
cli.error(`Wrong repository! It should be ${securityReleaseOrigin}`);
process.exit(1);
}
}
export async function checkoutOnSecurityReleaseBranch(cli, repository) {
await checkRemote(cli, repository);
const currentBranch = runSync('git', ['branch', '--show-current']).trim();
cli.info(`Current branch: ${currentBranch} `);
if (currentBranch !== NEXT_SECURITY_RELEASE_BRANCH) {
const refs = new Set(runSync('git', [
'for-each-ref', '--format=%(refname)',
`refs/heads/${NEXT_SECURITY_RELEASE_BRANCH}`,
`refs/remotes/origin/${NEXT_SECURITY_RELEASE_BRANCH}`
]).trim().split('\n'));
let args;
if (refs.has(`refs/heads/${NEXT_SECURITY_RELEASE_BRANCH}`)) {
args = ['checkout', NEXT_SECURITY_RELEASE_BRANCH];
} else if (refs.has(`refs/remotes/origin/${NEXT_SECURITY_RELEASE_BRANCH}`)) {
args = ['checkout', '-b', NEXT_SECURITY_RELEASE_BRANCH,
'--track', `origin/${NEXT_SECURITY_RELEASE_BRANCH}`];
} else {
args = ['checkout', '-b', NEXT_SECURITY_RELEASE_BRANCH];
}
await runSecurityGitCommand(
cli,
args,
`This switches to ${NEXT_SECURITY_RELEASE_BRANCH} without resetting an existing branch.`
);
cli.ok(`Checkout on branch: ${NEXT_SECURITY_RELEASE_BRANCH} `);
}
}
export async function commitAndPushVulnerabilitiesJSON(
filePath,
commitMessage,
{ cli, repository }
) {
await checkRemote(cli, repository);
const files = (Array.isArray(filePath) ? filePath : [filePath])
.map((file) => path.resolve(file));
const stagedFiles = runSync('git', ['diff', '--cached', '--name-only', '-z'])
.split('\0').filter(Boolean);
const unrelated = stagedFiles.filter((file) => {
const absolute = path.resolve(file);
return !files.some((target) =>
absolute === target || absolute.startsWith(`${target}${path.sep}`));
});
if (unrelated.length) {
throw new Error(
'Unrelated staged changes would be included in the security release commit: ' +
unrelated.join(', ') + '. Commit or unstage them before continuing.'
);
}
if (Array.isArray(filePath)) {
for (const currentPath of filePath) {
runSync('git', ['add', currentPath]);
}
} else {
runSync('git', ['add', filePath]);
}
const staged = runSync('git', ['diff', '--name-only', '--cached']).trim();
if (!staged) {
cli.ok('No changes to commit');
return;
}
await runSecurityGitCommand(
cli,
['commit', '-m', commitMessage],
`This creates a local commit with message: ${commitMessage}`
);
try {
await runSecurityGitCommand(
cli,
['push', '-u', 'origin', NEXT_SECURITY_RELEASE_BRANCH],
`This pushes the security release branch to origin/${NEXT_SECURITY_RELEASE_BRANCH}.`
);
} catch {
cli.warn('Rebasing...');
// try to pull rebase and push again
await runSecurityGitCommand(
cli,
['pull', 'origin', NEXT_SECURITY_RELEASE_BRANCH, '--rebase'],
`This rebases local changes on origin/${NEXT_SECURITY_RELEASE_BRANCH}.`
);
await runSecurityGitCommand(
cli,
['push', '-u', 'origin', NEXT_SECURITY_RELEASE_BRANCH],
`This retries pushing the security release branch to origin/${NEXT_SECURITY_RELEASE_BRANCH}.`
);
}
cli.ok(`Pushed commit: ${commitMessage} to ${NEXT_SECURITY_RELEASE_BRANCH}`);
}
export async function getSupportedVersions() {
const supportedVersions = (await nv('supported'))
.map((v) => `${v.versionName}.x`)
.join(',');
return supportedVersions;
}
export function getSummary(report) {
const summaryList = report?.relationships?.summaries?.data;
if (!summaryList?.length) return;
const summaries = summaryList.filter((summary) => summary?.attributes?.category === 'team');
if (!summaries?.length) return;
return summaries?.[0].attributes?.content;
}
export function getVulnerabilitiesJSON(cli) {
const vulnerabilitiesJSONPath = path.join(process.cwd(),
NEXT_SECURITY_RELEASE_FOLDER, 'vulnerabilities.json');
cli.startSpinner(`Reading vulnerabilities.json from ${vulnerabilitiesJSONPath}..`);
const file = JSON.parse(fs.readFileSync(vulnerabilitiesJSONPath, 'utf-8'));
cli.stopSpinner(`Done reading vulnerabilities.json from ${vulnerabilitiesJSONPath}`);
return file;
}
export function getVulnerabilitiesJSONPath() {
const vulnerabilitiesJSONPath = path.join(process.cwd(),
NEXT_SECURITY_RELEASE_FOLDER, 'vulnerabilities.json');
return vulnerabilitiesJSONPath;
}
export function validateDate(releaseDate) {
const value = new Date(releaseDate).valueOf();
if (Number.isNaN(value) || value < 0) {
throw new Error('Invalid date format');
}
}
export function formatDateToYYYYMMDD(date) {
// Get year, month, and day
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0'); // Months are zero-based
const day = String(date.getDate()).padStart(2, '0');
// Concatenate year, month, and day with slashes
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'
}.`;
}
function normalizeReleaseLine(version) {
if (typeof version !== 'string') return;
const trimmed = version.trim();
let match = RELEASE_LINE_RE.exec(trimmed);
if (match) return `${match[1]}.x`;
match = SEMVER_RE.exec(trimmed);
if (match) return `${match[1]}.x`;
}
function uniqueReleaseLines(releaseLines) {
return [...new Set(releaseLines.filter(Boolean))];
}
export function getAffectedVersionLines(affectedVersions) {
if (!affectedVersions) return [];
if (typeof affectedVersions === 'string') {
return uniqueReleaseLines(affectedVersions.split(',').map(normalizeReleaseLine));
}
if (Array.isArray(affectedVersions)) {
return uniqueReleaseLines(affectedVersions.flatMap((version) => {
if (typeof version === 'string') {
return normalizeReleaseLine(version);
}
if (version && typeof version === 'object') {
return normalizeReleaseLine(version.version);
}
return [];
}));
}
if (typeof affectedVersions === 'object') {
const releaseLines = Object.keys(affectedVersions).map(normalizeReleaseLine);
if (releaseLines.some(Boolean)) {
return uniqueReleaseLines(releaseLines);
}
return uniqueReleaseLines(
Object.values(affectedVersions).flatMap(getAffectedVersionLines)
);
}
return [];
}
// `dependencies` maps a dependency name to its update, or to a list of updates
// when the release lines got different ones. Flatten it into a list of updates
// that each know their name.
export function getDependencyUpdates(dependencies) {
return Object.entries(dependencies ?? {}).flatMap(([name, update]) =>
(Array.isArray(update) ? update : [update]).map((entry) => ({ ...entry, name })));
}
export function promptDependencies(cli) {
return cli.prompt('Enter the link to the dependency update PR (leave empty to exit): ', {
defaultAnswer: '',
questionType: 'input'
});
}
export async function createIssue(title, content, repository, { cli, req }) {
await confirmSecurityStep(
cli,
`create GitHub issue \`${repository.owner}/${repository.repo}: ${title}\``,
`This creates an issue in ${repository.owner}/${repository.repo}.`
);
const data = await req.createIssue(title, content, repository);
if (data.html_url) {
cli.ok(`Created: ${data.html_url}`);
return data.html_url;
} else {
cli.error(data);
process.exit(1);
}
}
export function getReportSeverity(report) {
const {
relationships: { severity, weakness }
} = report;
const reportSeverity = {
rating: severity?.data?.attributes?.rating || '',
cvss_vector_string: severity?.data?.attributes?.cvss_vector_string || '',
weakness_id: weakness?.data?.id || ''
};
return reportSeverity;
}
export async function pickReport(report, { cli, req }) {
const {
id, attributes: { title, cve_ids },
relationships: { reporter, custom_field_values }
} = report;
const link = `https://hackerone.com/reports/${id}`;
const reportSeverity = getReportSeverity(report);
cli.separator();
cli.info(`Report: ${link} - ${title} (${reportSeverity?.rating})`);
const include = await cli.prompt(
'Would you like to include this report to the next security release?',
{ defaultAnswer: true });
if (!include) {
return;
}
const versions = await cli.prompt('Which active release lines this report affects?', {
questionType: 'input',
defaultAnswer: await getSupportedVersions()
});
let prURL = '';
let patchAuthors;
if (custom_field_values.data.length) {
prURL = custom_field_values.data[0].attributes.value;
const { user } = await req.getPullRequest(prURL);
patchAuthors = [user.login];
} else {
patchAuthors = await cli.prompt(
'Add github username of the authors of the patch (split by comma if multiple)', {
questionType: 'input',
defaultAnswer: ''
});
if (!patchAuthors) {
patchAuthors = [];
} else {
patchAuthors = patchAuthors.split(',').map((p) => p.trim());
}
}
const summaryContent = getSummary(report);
return {
id,
title,
cveIds: cve_ids,
severity: reportSeverity,
summary: summaryContent ?? '',
patchAuthors,
prURL,
affectedVersions: versions.split(',').map((v) => v.replace('v', '').trim()),
link,
reporter: reporter.data.attributes.username
};
}
export class SecurityRelease {
constructor(cli, repository = NEXT_SECURITY_RELEASE_REPOSITORY) {
this.cli = cli;
this.repository = repository;
}
readVulnerabilitiesJSON(vulnerabilitiesJSONPath = this.getVulnerabilitiesJSONPath()) {
const exists = fs.existsSync(vulnerabilitiesJSONPath);
if (!exists) {
this.cli.error(`The file vulnerabilities.json does not exist at ${vulnerabilitiesJSONPath}`);
process.exit(1);
}
return JSON.parse(fs.readFileSync(vulnerabilitiesJSONPath, 'utf8'));
}
getVulnerabilitiesJSONPath() {
return path.join(process.cwd(),
NEXT_SECURITY_RELEASE_FOLDER, 'vulnerabilities.json');
}
async updateReleaseFolder(releaseDate) {
const folder = path.join(process.cwd(),
NEXT_SECURITY_RELEASE_FOLDER);
const newFolder = path.join(process.cwd(), 'security-release', releaseDate);
await confirmSecurityStep(
this.cli,
`rename \`${folder}\` to \`${newFolder}\``,
'This moves the next-security-release folder to the dated release folder.'
);
fs.renameSync(folder, newFolder);
return newFolder;
}
async updateVulnerabilitiesJSON(content) {
try {
const vulnerabilitiesJSONPath = this.getVulnerabilitiesJSONPath();
this.cli.startSpinner(`Updating vulnerabilities.json from ${vulnerabilitiesJSONPath}...`);
await writeSecurityFile(
this.cli,
vulnerabilitiesJSONPath,
JSON.stringify(content, null, 2),
'This updates vulnerabilities.json with the latest security release data.'
);
await commitAndPushVulnerabilitiesJSON(vulnerabilitiesJSONPath,
'chore: updated vulnerabilities.json',
{ cli: this.cli, repository: this.repository });
this.cli.stopSpinner(`Done updating vulnerabilities.json from ${vulnerabilitiesJSONPath}`);
return true;
} catch (error) {
this.cli.error('Error updating vulnerabilities.json');
this.cli.error(error);
return false;
}
}
getAffectedVersions(content) {
const affectedVersions = new Set();
for (const report of Object.values(content.reports)) {
for (const affectedVersion of getAffectedVersionLines(report.affectedVersions)) {
affectedVersions.add(affectedVersion);
}
}
const parseToNumber = str => +(str.match(/[\d.]+/g)[0]);
return Array.from(affectedVersions)
.sort((a, b) => {
return parseToNumber(a) > parseToNumber(b) ? -1 : 1;
})
.join(', ');
}
}