diff --git a/components/git/security.js b/components/git/security.js index 002daf33..28a1039e 100644 --- a/components/git/security.js +++ b/components/git/security.js @@ -39,6 +39,10 @@ const securityOptions = { 'request-cve': { describe: 'Request CVEs for a security release', type: 'boolean' + }, + 'post-release': { + describe: 'Create the post-release announcement', + type: 'boolean' } }; @@ -49,7 +53,8 @@ export function builder(yargs) { return yargs.options(securityOptions) .example( 'git node security --start', - 'Prepare a security release of Node.js') + 'Prepare a security release of Node.js' + ) .example( 'git node security --sync', 'Synchronize an ongoing security release with HackerOne' @@ -57,26 +62,25 @@ export function builder(yargs) { .example( 'git node security --update-date=YYYY/MM/DD', 'Updates the target date of the security release' - ) - .example( + ).example( 'git node security --add-report=H1-ID', 'Fetches HackerOne report based on ID provided and adds it into vulnerabilities.json' - ) - .example( + ).example( 'git node security --remove-report=H1-ID', 'Removes the Hackerone report based on ID provided from vulnerabilities.json' - ) - .example( + ).example( 'git node security --pre-release', 'Create the pre-release announcement on the Nodejs.org repo' ).example( 'git node security --notify-pre-release', 'Notifies the community about the security release' - ) - .example( + ).example( 'git node security --request-cve', 'Request CVEs for a security release of Node.js based on' + ' the next-security-release/vulnerabilities.json' + ).example( + 'git node security --post-release' + + 'Create the post-release announcement on the Nodejs.org repo' ); } @@ -105,6 +109,9 @@ export function handler(argv) { if (argv['request-cve']) { return requestCVEs(argv); } + if (argv['post-release']) { + return createPostRelease(argv); + } yargsInstance.showHelp(); } @@ -146,7 +153,14 @@ async function requestCVEs() { return hackerOneCve.requestCVEs(); } -async function startSecurityRelease(argv) { +async function createPostRelease() { + const logStream = process.stdout.isTTY ? process.stdout : process.stderr; + const cli = new CLI(logStream); + const blog = new SecurityBlog(cli); + return blog.createPostRelease(); +} + +async function startSecurityRelease() { const logStream = process.stdout.isTTY ? process.stdout : process.stderr; const cli = new CLI(logStream); const release = new PrepareSecurityRelease(cli); diff --git a/lib/github/templates/security-post-release.md b/lib/github/templates/security-post-release.md new file mode 100644 index 00000000..39b15008 --- /dev/null +++ b/lib/github/templates/security-post-release.md @@ -0,0 +1,18 @@ +--- +date: %ANNOUNCEMENT_DATE% +category: vulnerability +title: %RELEASE_DATE% Security Releases +slug: %SLUG% +layout: blog-post +author: %AUTHOR% +--- + +## Security releases available + +Updates are now available for the %AFFECTED_VERSIONS% Node.js release lines for the +following issues. +%DEPENDENCY_UPDATES% +%REPORTS% +## Downloads and release details + +%DOWNLOADS% diff --git a/lib/github/templates/security-pre-release.md b/lib/github/templates/security-pre-release.md index 08299fad..2b8defe4 100644 --- a/lib/github/templates/security-pre-release.md +++ b/lib/github/templates/security-pre-release.md @@ -13,7 +13,7 @@ The Node.js project will release new versions of the %AFFECTED_VERSIONS% releases lines on or shortly after, %RELEASE_DATE% in order to address: %VULNERABILITIES% -%OPENSSL_UPDATES% + ## Impact %IMPACT% @@ -28,7 +28,7 @@ Releases will be available on, or shortly after, %RELEASE_DATE%. ## Contact and future updates -The current Node.js security policy can be found at https://nodejs.org/en/security/. -Please follow the process outlined in https://github.com/nodejs/node/blob/master/SECURITY.md if you wish to report a vulnerability in Node.js. +The current Node.js security policy can be found at . +Please follow the process outlined in if you wish to report a vulnerability in Node.js. -Subscribe to the low-volume announcement-only nodejs-sec mailing list at https://groups.google.com/forum/#!forum/nodejs-sec to stay up to date on security vulnerabilities and security-related releases of Node.js and the projects maintained in the nodejs GitHub organization. +Subscribe to the low-volume announcement-only nodejs-sec mailing list at to stay up to date on security vulnerabilities and security-related releases of Node.js and the projects maintained in the nodejs GitHub organization. diff --git a/lib/security-release/security-release.js b/lib/security-release/security-release.js index 5acba9ab..73f93cd6 100644 --- a/lib/security-release/security-release.js +++ b/lib/security-release/security-release.js @@ -20,9 +20,12 @@ export const PLACEHOLDERS = { annoucementDate: '%ANNOUNCEMENT_DATE%', slug: '%SLUG%', affectedVersions: '%AFFECTED_VERSIONS%', - openSSLUpdate: '%OPENSSL_UPDATES%', impact: '%IMPACT%', - vulnerabilities: '%VULNERABILITIES%' + vulnerabilities: '%VULNERABILITIES%', + reports: '%REPORTS%', + author: '%AUTHOR%', + dependencyUpdates: '%DEPENDENCY_UPDATES%', + downloads: '%DOWNLOADS%' }; export function checkRemote(cli, repository) { diff --git a/lib/security_blog.js b/lib/security_blog.js index 89ddeb77..217778a2 100644 --- a/lib/security_blog.js +++ b/lib/security_blog.js @@ -1,16 +1,25 @@ import fs from 'node:fs'; import path from 'node:path'; import _ from 'lodash'; +import nv from '@pkgjs/nv'; import { PLACEHOLDERS, getVulnerabilitiesJSON, checkoutOnSecurityReleaseBranch, NEXT_SECURITY_RELEASE_REPOSITORY, - validateDate + validateDate, + getSummary, + commitAndPushVulnerabilitiesJSON, + NEXT_SECURITY_RELEASE_FOLDER } from './security-release/security-release.js'; +import auth from './auth.js'; +import Request from './request.js'; + +const kChanged = Symbol('changed'); export default class SecurityBlog { repository = NEXT_SECURITY_RELEASE_REPOSITORY; + req; constructor(cli) { this.cli = cli; } @@ -40,8 +49,7 @@ export default class SecurityBlog { affectedVersions: this.getAffectedVersions(content), vulnerabilities: this.getVulnerabilities(content), slug: this.getSlug(releaseDate), - impact: this.getImpact(content), - openSSLUpdate: await this.promptOpenSSLUpdate(cli) + impact: this.getImpact(content) }; const month = releaseDate.toLocaleString('en-US', { month: 'long' }).toLowerCase(); const year = releaseDate.getFullYear(); @@ -52,9 +60,93 @@ export default class SecurityBlog { cli.ok(`Pre-release announcement file created at ${file}`); } - promptOpenSSLUpdate(cli) { - return cli.prompt('Does this security release containt OpenSSL updates?', { - defaultAnswer: true + async createPostRelease() { + const { cli } = this; + const credentials = await auth({ + github: true, + h1: true + }); + + this.req = new Request(credentials); + + // checkout on security release branch + checkoutOnSecurityReleaseBranch(cli, this.repository); + + // read vulnerabilities JSON file + const content = getVulnerabilitiesJSON(cli); + if (!content.releaseDate) { + cli.error('Release date is not set in vulnerabilities.json,' + + ' run `git node security --update-date=YYYY/MM/DD` to set the release date.'); + process.exit(1); + } + + validateDate(content.releaseDate); + const releaseDate = new Date(content.releaseDate); + const template = this.getSecurityPostReleaseTemplate(); + const data = { + annoucementDate: await this.getAnnouncementDate(cli), + releaseDate: this.formatReleaseDate(releaseDate), + affectedVersions: this.getAffectedVersions(content), + vulnerabilities: this.getVulnerabilities(content), + slug: this.getSlug(releaseDate), + author: await this.promptAuthor(cli), + dependencyUpdates: content.dependencies + }; + const postReleaseContent = await this.buildPostRelease(template, data, content); + + const pathPreRelease = await this.promptExistingPreRelease(cli); + // read the existing pre-release announcement + let preReleaseContent = fs.readFileSync(pathPreRelease, 'utf-8'); + // cut the part before summary + const preSummary = preReleaseContent.indexOf('# Summary'); + if (preSummary !== -1) { + preReleaseContent = preReleaseContent.substring(preSummary); + } + + const updatedContent = postReleaseContent + preReleaseContent; + + fs.writeFileSync(pathPreRelease, updatedContent); + cli.ok(`Post-release announcement file updated at ${pathPreRelease}`); + + // if the vulnerabilities.json has been changed, update the file + if (!content[kChanged]) return; + this.updateVulnerabilitiesJSON(content); + } + + updateVulnerabilitiesJSON(content) { + try { + this.cli.info('Updating vulnerabilities.json'); + const vulnerabilitiesJSONPath = path.join(process.cwd(), + NEXT_SECURITY_RELEASE_FOLDER, 'vulnerabilities.json'); + fs.writeFileSync(vulnerabilitiesJSONPath, JSON.stringify(content, null, 2)); + const commitMessage = 'chore: updated vulnerabilities.json'; + commitAndPushVulnerabilitiesJSON(vulnerabilitiesJSONPath, + commitMessage, + { cli: this.cli, repository: this.repository }); + } catch (error) { + this.cli.error('Error updating vulnerabilities.json'); + this.cli.error(error); + } + } + + async promptExistingPreRelease(cli) { + const pathPreRelease = await cli.prompt( + 'Please provide the path of the existing pre-release announcement:', { + questionType: 'input', + defaultAnswer: '' + }); + + if (!pathPreRelease || !fs.existsSync(path.resolve(pathPreRelease))) { + return this.promptExistingPreRelease(cli); + } + return pathPreRelease; + } + + promptAuthor(cli) { + return cli.prompt('Who is the author of this security release? If multiple' + + ' use & as separator', { + questionType: 'input', + defaultAnswer: PLACEHOLDERS.author }); } @@ -69,6 +161,23 @@ export default class SecurityBlog { } 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, @@ -76,7 +185,8 @@ export default class SecurityBlog { vulnerabilities, slug, impact, - openSSLUpdate + author, + dependencyUpdates } = data; return template.replaceAll(PLACEHOLDERS.annoucementDate, annoucementDate) .replaceAll(PLACEHOLDERS.slug, slug) @@ -84,15 +194,89 @@ export default class SecurityBlog { .replaceAll(PLACEHOLDERS.vulnerabilities, vulnerabilities) .replaceAll(PLACEHOLDERS.releaseDate, releaseDate) .replaceAll(PLACEHOLDERS.impact, impact) - .replaceAll(PLACEHOLDERS.openSSLUpdate, this.getOpenSSLUpdateTemplate(openSSLUpdate)); + .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; + let template = ''; + for (const report of reports) { + let cveId = report.cve_ids?.join(', '); + if (!cveId) { + // ask for the CVE ID + // it should have been created with the step `--request-cve` + cveId = await this.cli.prompt(`What is the CVE ID for vulnerability https://hackerone.com/reports/${report.id} ${report.title}?`, { + questionType: 'input', + defaultAnswer: 'TBD' + }); + report.cve_ids = [cveId]; + content[kChanged] = true; + } + template += `## ${report.title} (${cveId}) - (${report.severity.rating})\n\n`; + if (!report.summary) { + const fetchIt = await this.cli.prompt(`Summary missing for vulnerability https://hackerone.com/reports/${report.id} ${report.title}.\ + Do you want to try fetch it from HackerOne??`, { + questionType: 'confirm', + defaultAnswer: true + }); + + if (fetchIt) { + report.summary = await getSummary(report.id, this.req); + content[kChanged] = true; + } + + if (!report.summary) { + this.cli.error(`Summary missing for vulnerability https://hackerone.com/reports/${report.id} ${report.title}. Please create it before continuing.`); + process.exit(1); + } + } + template += `${report.summary}\n\n`; + const releaseLines = report.affectedVersions.join(', '); + template += `Impact:\n\n- This vulnerability affects all users\ + in active release lines: ${releaseLines}\n\n`; + if (!report.patchAuthors) { + const author = await this.cli.prompt(`Who fixed vulnerability https://hackerone.com/reports/${report.id} ${report.title}? If multiple use & as separator`, { + questionType: 'input', + defaultAnswer: 'TBD' + }); + report.patchAuthors = author.split('&').map((p) => p.trim()); + content[kChanged] = true; + } + template += `Thank you, to ${report.reporter} for reporting this vulnerability\ + and thank you ${report.patchAuthors.join(' and ')} for fixing it.\n\n`; + } + return template; + } + + getDependencyUpdatesTemplate(dependencyUpdates) { + if (!dependencyUpdates) return ''; + let template = 'This security release includes the following dependency' + + ' updates to address public vulnerabilities:\n\n'; + for (const dependencyUpdate of Object.values(dependencyUpdates)) { + for (const dependency of dependencyUpdate) { + const title = dependency.title.substring(dependency.title.indexOf(':') + ':'.length).trim(); + template += `- ${title}\ + on ${dependency.affectedVersions.join(', ')}\n`; + } + } + return template; } - getOpenSSLUpdateTemplate(openSSLUpdate) { - if (openSSLUpdate) { - return '\n## OpenSSL Security updates\n\n' + - 'This security release includes OpenSSL security updates\n'; + 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 ''; + + return template; } getSlug(releaseDate) { @@ -179,4 +363,14 @@ export default class SecurityBlog { 'utf-8' ); } + + getSecurityPostReleaseTemplate() { + return fs.readFileSync( + new URL( + './github/templates/security-post-release.md', + import.meta.url + ), + 'utf-8' + ); + } }