import fs from 'node:fs';
import path from 'node:path';
import nv from '@pkgjs/nv';
import {
PLACEHOLDERS,
checkoutOnSecurityReleaseBranch,
validateDate,
SecurityRelease,
commitAndPushVulnerabilitiesJSON,
getAffectedVersionLines,
getDependencyUpdates,
getHighestSeverityAnnouncement,
writeSecurityFile,
} from './security-release/security-release.js';
import auth from './auth.js';
import Request from './request.js';
const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low'];
export default class SecurityBlog extends SecurityRelease {
req;
async createPreRelease(nodejsOrgFolder) {
const { cli } = this;
// checkout on security release branch
await checkoutOnSecurityReleaseBranch(cli, this.repository);
// read vulnerabilities JSON file
const content = this.readVulnerabilitiesJSON();
// validate the release date read from vulnerabilities JSON
if (!content.releaseDate) {
throw new Error('Release date is not set in vulnerabilities.json,' +
' run `git node security --update-date=YYYY/MM/DD` to set the release date.');
}
validateDate(content.releaseDate);
const releaseDate = new Date(content.releaseDate);
const template = this.getSecurityPreReleaseTemplate();
const data = {
annoucementDate: await this.getAnnouncementDate(cli),
releaseDate: this.formatReleaseDate(releaseDate),
affectedVersions: this.getAffectedVersions(content),
vulnerabilities: this.getPreReleaseVulnerabilities(content),
slug: this.getSlug(releaseDate),
impact: this.getImpact(content)
};
const month = releaseDate.toLocaleString('en-US', { month: 'long' }).toLowerCase();
const year = releaseDate.getFullYear();
const fileName = `${month}-${year}-security-releases`;
const fileNameExt = fileName + '.md';
const preRelease = this.buildPreRelease(template, data);
const pathToBlogPosts = 'apps/site/pages/en/blog/vulnerability';
const pathToBannerJson = 'apps/site/site.json';
const file = path.resolve(process.cwd(), nodejsOrgFolder, pathToBlogPosts, fileNameExt);
const site = path.resolve(process.cwd(), nodejsOrgFolder, pathToBannerJson);
const endDate = new Date(data.annoucementDate);
endDate.setDate(endDate.getDate() + 7);
const link = `https://nodejs.org/en/blog/vulnerability/${fileName}`;
await this.updateWebsiteBanner(site, {
startDate: data.annoucementDate,
endDate: endDate.toISOString(),
text: `New security releases to be made available ${data.releaseDate}`,
link,
type: 'warning'
});
await writeSecurityFile(
cli,
file,
preRelease,
'This writes the pre-release vulnerability announcement post.'
);
cli.ok(`Announcement file created and banner has been updated. Folder: ${nodejsOrgFolder}`);
await this.updateAnnouncementLink(link);
}
async updateAnnouncementLink(link) {
const vulnerabilitiesJSONPath = this.getVulnerabilitiesJSONPath();
const content = this.readVulnerabilitiesJSON(vulnerabilitiesJSONPath);
if (content.announcement === link) {
return [vulnerabilitiesJSONPath];
}
content.announcement = link;
await writeSecurityFile(
this.cli,
vulnerabilitiesJSONPath,
JSON.stringify(content, null, 2),
'This writes the announcement link to vulnerabilities.json.'
);
const commitMessage = 'chore: add announcement link';
await commitAndPushVulnerabilitiesJSON([vulnerabilitiesJSONPath],
commitMessage, { cli: this.cli, repository: this.repository });
this.cli.ok('Updated the announcement link in vulnerabilities.json');
return [vulnerabilitiesJSONPath];
}
async createPostRelease(nodejsOrgFolder) {
const { cli } = this;
const credentials = await auth({
github: true,
h1: true
});
this.req = new Request(credentials);
// checkout on security release branch
await checkoutOnSecurityReleaseBranch(cli, this.repository);
// read vulnerabilities JSON file
const content = this.readVulnerabilitiesJSON();
if (!content.releaseDate) {
throw new Error('Release date is not set in vulnerabilities.json,' +
' run `git node security --update-date=YYYY/MM/DD` to set the release date.');
}
validateDate(content.releaseDate);
const releaseDate = new Date(content.releaseDate);
const template = this.getSecurityPostReleaseTemplate();
const data = {
annoucementDate: releaseDate.toISOString(),
releaseDate: this.formatReleaseDate(releaseDate),
affectedVersions: this.getAffectedVersions(content),
vulnerabilities: this.getVulnerabilities(content),
slug: this.getSlug(releaseDate),
author: 'The Node.js Project',
dependencyUpdates: content.dependencies
};
const pathToBlogPosts = path.resolve(nodejsOrgFolder, 'apps/site/pages/en/blog/vulnerability');
const pathToBannerJson = path.resolve(nodejsOrgFolder, 'apps/site/site.json');
const preReleasePath = path.resolve(pathToBlogPosts, data.slug + '.md');
let preReleaseContent = this.findExistingPreRelease(preReleasePath);
if (!preReleaseContent) {
throw new Error(`Existing pre-release not found! Path: ${preReleasePath} `);
}
const postReleaseContent = await this.buildPostRelease(template, data, content);
// cut the part before summary
const preSummary = preReleaseContent.indexOf('# Summary');
if (preSummary !== -1) {
preReleaseContent = preReleaseContent.substring(preSummary);
}
const updatedContent = postReleaseContent + preReleaseContent;
const endDate = new Date(data.annoucementDate);
endDate.setDate(endDate.getDate() + 7);
const month = releaseDate.toLocaleString('en-US', { month: 'long' });
const capitalizedMonth = month[0].toUpperCase() + month.slice(1);
await this.updateWebsiteBanner(pathToBannerJson, {
startDate: releaseDate,
endDate,
text: `${capitalizedMonth} Security Release is available`
});
await writeSecurityFile(
cli,
preReleasePath,
updatedContent,
'This writes the post-release vulnerability announcement post.'
);
cli.ok(`Announcement file and banner has been updated. Folder: ${nodejsOrgFolder}`);
}
findExistingPreRelease(filepath) {
if (!fs.existsSync(filepath)) {
return null;
}
return fs.readFileSync(filepath, 'utf-8');
}
promptAuthor(cli) {
return cli.prompt('Who is the author of this security release? If multiple' +
' use & as separator', {
questionType: 'input',
defaultAnswer: PLACEHOLDERS.author
});
}
async updateWebsiteBanner(siteJsonPath, content) {
const siteJson = JSON.parse(fs.readFileSync(siteJsonPath));
const currentValue = siteJson.websiteBanners.index;
siteJson.websiteBanners.index = {
startDate: content.startDate ?? currentValue.startDate,
endDate: content.endDate ?? currentValue.endDate,
text: content.text ?? currentValue.text,
link: content.link ?? currentValue.link,
type: content.type ?? currentValue.type
};
await writeSecurityFile(
this.cli,
siteJsonPath,
JSON.stringify(siteJson, null, 2) + '\n',
'This writes the updated security release website banner.'
);
}
formatReleaseDate(releaseDate) {
const options = {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric'
};
return releaseDate.toLocaleDateString('en-US', options);
}
buildPreRelease(template, data) {
const {
annoucementDate,
releaseDate,
affectedVersions,
vulnerabilities,
slug,
impact
} = data;
return template.replaceAll(PLACEHOLDERS.annoucementDate, annoucementDate)
.replaceAll(PLACEHOLDERS.slug, slug)
.replaceAll(PLACEHOLDERS.affectedVersions, affectedVersions)
.replaceAll(PLACEHOLDERS.vulnerabilities, vulnerabilities)
.replaceAll(PLACEHOLDERS.releaseDate, releaseDate)
.replaceAll(PLACEHOLDERS.impact, impact);
}
async buildPostRelease(template, data, content) {
const {
annoucementDate,
releaseDate,
affectedVersions,
vulnerabilities,
slug,
impact,
author,
dependencyUpdates
} = data;
return template.replaceAll(PLACEHOLDERS.annoucementDate, annoucementDate)
.replaceAll(PLACEHOLDERS.slug, slug)
.replaceAll(PLACEHOLDERS.affectedVersions, affectedVersions)
.replaceAll(PLACEHOLDERS.vulnerabilities, vulnerabilities)
.replaceAll(PLACEHOLDERS.releaseDate, releaseDate)
.replaceAll(PLACEHOLDERS.impact, impact)
.replaceAll(PLACEHOLDERS.author, author)
.replaceAll(PLACEHOLDERS.reports, await this.getReportsTemplate(content))
.replaceAll(PLACEHOLDERS.dependencyUpdates,
this.getDependencyUpdatesTemplate(dependencyUpdates))
.replaceAll(PLACEHOLDERS.downloads, await this.getDownloadsTemplate(affectedVersions));
}
async getReportsTemplate(content) {
const reports = [...content.reports].sort((a, b) =>
this.getSeverityOrder(a) - this.getSeverityOrder(b));
let template = '';
for (const report of reports) {
const cveId = report.cveIds?.join(', ');
if (!cveId) {
throw new Error(`CVE ID for vulnerability ${report.link} ${report.title} not found`);
}
template += `## ${report.title} (${cveId}) - (${report.severity.rating})\n\n`;
if (!report.summary) {
throw new Error(`Summary missing for vulnerability ${report.link} ` +
`${report.title}. Please create it before continuing.`);
}
template += `${report.summary}\n\n`;
const releaseLines = getAffectedVersionLines(report.affectedVersions).join(', ');
template += `Impact:\n\n- This vulnerability affects all users\
in active release lines: ${releaseLines}\n\n`;
if (!report.patchAuthors) {
throw new Error(`Missing patch author for vulnerability ${report.link} ${report.title}`);
}
template += `Thank you, to ${report.reporter} for reporting this vulnerability\
and thank you ${report.patchAuthors.join(' and ')} for fixing it.\n\n`;
}
return template;
}
getSeverityOrder(report) {
const rating = report.severity?.rating?.toLowerCase();
const index = SEVERITY_ORDER.indexOf(rating);
if (index === -1) return SEVERITY_ORDER.length;
return index;
}
getDependencyUpdatesTemplate(dependencyUpdates) {
const updates = getDependencyUpdates(dependencyUpdates);
if (updates.length === 0) return '';
let template = '\nThis security release includes the following dependency' +
' updates to address public vulnerabilities:\n';
for (const update of updates) {
const title = this.formatDependencyUpdateTitle(update) || update.name;
const releaseLines = getAffectedVersionLines(update.affectedVersions);
const formattedVersions = this.formatDependencyVersions(update.versions);
const versionText = formattedVersions ? ` (${formattedVersions})` : '';
template += `- ${title}${versionText} on ${releaseLines.join(', ')}\n`;
}
return template;
}
formatDependencyUpdateVersion(version) {
if (typeof version === 'string' || typeof version === 'number') {
return String(version);
}
if (!version || typeof version !== 'object') return '';
return version.version || version.to || version.patched || version.name ||
this.formatDependencyUpdateTitle(version) || version.url || JSON.stringify(version);
}
formatDependencyVersions(versions) {
if (!Array.isArray(versions)) return '';
// The current schema carries one entry per release line, so the same
// version repeats when every line gets the same update.
return [...new Set(versions
.map((version) => this.formatDependencyUpdateVersion(version))
.filter(Boolean))]
.join(', ');
}
formatDependencyUpdateTitle(dependency) {
if (!dependency || typeof dependency !== 'object') return '';
if (!dependency.title) return dependency.name ?? '';
const titleSeparator = dependency.title.indexOf(':');
if (titleSeparator === -1) return dependency.title;
return dependency.title.substring(titleSeparator + ':'.length).trim();
}
async getDownloadsTemplate(affectedVersions) {
let template = '';
const versionsToBeReleased = (await nv('supported')).filter(
(v) => affectedVersions.split(', ').includes(`${v.major}.x`)
);
for (const version of versionsToBeReleased) {
const v = `v${version.major}.${version.minor}.${Number(version.patch) + 1}`;
template += `- [Node.js ${v}](/blog/release/${v}/)\n`;
}
return template;
}
getSlug(releaseDate) {
const month = releaseDate.toLocaleString('en-US', { month: 'long' });
const year = releaseDate.getFullYear();
return `${month.toLocaleLowerCase()}-${year}-security-releases`;
}
async getAnnouncementDate(cli) {
try {
const date = await this.promptAnnouncementDate(cli);
validateDate(date);
return new Date(date).toISOString();
} catch {
return PLACEHOLDERS.annoucementDate;
}
}
promptAnnouncementDate(cli) {
const today = new Date().toISOString().substring(0, 10).replace(/-/g, '/');
return cli.prompt('When is the security release going to be announced? ' +
'Enter in YYYY/MM/DD format:', {
questionType: 'input',
defaultAnswer: today
});
}
getImpact(content) {
const impact = new Map();
for (const report of content.reports) {
if (!report.severity?.rating) {
throw new Error(`severity.rating not found for report ${report.id}.`);
}
for (const version of getAffectedVersionLines(report.affectedVersions)) {
if (!impact.has(version)) impact.set(version, []);
impact.get(version).push(report);
}
}
const result = Array.from(impact.entries())
.sort(([a], [b]) => b.localeCompare(a)) // DESC
.map(([version, reports]) =>
getHighestSeverityAnnouncement(reports, `the ${version} release line`))
.join('\n');
return result;
}
getVulnerabilities(content) {
const severityCount = new Map();
for (const report of content.reports) {
if (!report.severity?.rating) {
throw new Error(`severity.rating not found for report ${report.id}.`);
}
const rating = report.severity.rating;
severityCount.set(rating, (severityCount.get(rating) || 0) + 1);
}
const text = [];
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) {
throw new Error(`severity.rating not found for report ${report.id}.`);
}
}
return getHighestSeverityAnnouncement(content.reports);
}
getSecurityPreReleaseTemplate() {
return fs.readFileSync(
new URL(
'./github/templates/security-pre-release.md',
import.meta.url
),
'utf-8'
);
}
getSecurityPostReleaseTemplate() {
return fs.readFileSync(
new URL(
'./github/templates/security-post-release.md',
import.meta.url
),
'utf-8'
);
}
}