From b70e6368307156b4386129a30e07519aa5957eb3 Mon Sep 17 00:00:00 2001 From: Marco Ippolito Date: Fri, 22 Mar 2024 18:01:23 +0100 Subject: [PATCH] feat: automate pre-release blogpost creation (#773) --- components/git/security.js | 28 ++- docs/git-node.md | 13 +- lib/github/templates/security-pre-release.md | 29 +++ lib/security-release/security-release.js | 26 ++- lib/security_blog.js | 176 +++++++++++++++++++ lib/update_security_release.js | 11 +- 6 files changed, 269 insertions(+), 14 deletions(-) create mode 100644 lib/github/templates/security-pre-release.md create mode 100644 lib/security_blog.js diff --git a/components/git/security.js b/components/git/security.js index 71f3652e..2ebdee4d 100644 --- a/components/git/security.js +++ b/components/git/security.js @@ -1,6 +1,7 @@ import CLI from '../../lib/cli.js'; import SecurityReleaseSteward from '../../lib/prepare_security.js'; import UpdateSecurityRelease from '../../lib/update_security_release.js'; +import SecurityBlog from '../../lib/security_blog.js'; export const command = 'security [options]'; export const describe = 'Manage an in-progress security release or start a new one.'; @@ -21,6 +22,10 @@ const securityOptions = { 'remove-report': { describe: 'Removes a report from vulnerabilities.json', type: 'string' + }, + 'pre-release': { + describe: 'Create the pre-release announcement', + type: 'boolean' } }; @@ -28,11 +33,12 @@ let yargsInstance; export function builder(yargs) { yargsInstance = yargs; - return yargs.options(securityOptions).example( - 'git node security --start', - 'Prepare a security release of Node.js') + return yargs.options(securityOptions) + .example( + 'git node security --start', + 'Prepare a security release of Node.js') .example( - 'git node security --update-date=31/12/2023', + 'git node security --update-date=YYYY/MM/DD', 'Updates the target date of the security release' ) .example( @@ -42,6 +48,10 @@ export function builder(yargs) { .example( 'git node security --remove-report=H1-ID', 'Removes the Hackerone report based on ID provided from vulnerabilities.json' + ) + .example( + 'git node security --pre-release' + + 'Create the pre-release announcement on the Nodejs.org repo' ); } @@ -52,6 +62,9 @@ export function handler(argv) { if (argv['update-date']) { return updateReleaseDate(argv); } + if (argv['pre-release']) { + return createPreRelease(argv); + } if (argv['add-report']) { return addReport(argv); } @@ -85,6 +98,13 @@ async function updateReleaseDate(argv) { return update.updateReleaseDate(releaseDate); } +async function createPreRelease() { + const logStream = process.stdout.isTTY ? process.stdout : process.stderr; + const cli = new CLI(logStream); + const preRelease = new SecurityBlog(cli); + return preRelease.createPreRelease(); +} + async function startSecurityRelease() { const logStream = process.stdout.isTTY ? process.stdout : process.stderr; const cli = new CLI(logStream); diff --git a/docs/git-node.md b/docs/git-node.md index 31724a33..cfda2382 100644 --- a/docs/git-node.md +++ b/docs/git-node.md @@ -457,13 +457,22 @@ This command creates the Next Security Issue in Node.js private repository following the [Security Release Process][] document. It will retrieve all the triaged HackerOne reports and add creates the `vulnerabilities.json`. -### `git node security --update-date=target` +### `git node security --update-date=YYYY/MM/DD` This command updates the `vulnerabilities.json` with target date of the security release. Example: ```sh - git node security --update-date=16/12/2023 + git node security --update-date=2023/12/31 +``` + +### `git node security --pre-release` + +This command creates a pre-release announcement for the security release. +Example: + +```sh + git node security --pre-release ``` ### `git node security --add-report=report-id` diff --git a/lib/github/templates/security-pre-release.md b/lib/github/templates/security-pre-release.md new file mode 100644 index 00000000..4e1e1502 --- /dev/null +++ b/lib/github/templates/security-pre-release.md @@ -0,0 +1,29 @@ +--- +date: %ANNOUNCEMENT_DATE% +category: vulnerability +title: %RELEASE_DATE% Security Releases +slug: %SLUG% +layout: blog-post.hbs +author: The Node.js Project +--- + +# Summary + +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% + +## Release timing + +Releases will be available on, or shortly after, %RELEASE_DATE%. + +## Contact and future updates + +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 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 974c0905..b2a973dd 100644 --- a/lib/security-release/security-release.js +++ b/lib/security-release/security-release.js @@ -1,5 +1,7 @@ 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'; @@ -14,7 +16,13 @@ export const PLACEHOLDERS = { vulnerabilitiesPRURL: '%VULNERABILITIES_PR_URL%', preReleasePrivate: '%PRE_RELEASE_PRIV%', postReleasePrivate: '%POS_RELEASE_PRIV%', - affectedLines: '%AFFECTED_LINES%' + affectedLines: '%AFFECTED_LINES%', + annoucementDate: '%ANNOUNCEMENT_DATE%', + slug: '%SLUG%', + affectedVersions: '%AFFECTED_VERSIONS%', + openSSLUpdate: '%OPENSSL_UPDATES%', + impact: '%IMPACT%', + vulnerabilities: '%VULNERABILITIES%' }; export function checkRemote(cli, repository) { @@ -73,3 +81,19 @@ export async function getSummary(reportId, req) { 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 validateDate(releaseDate) { + const value = new Date(releaseDate).valueOf(); + if (Number.isNaN(value) || value < 0) { + throw new Error('Invalid date format'); + } +} diff --git a/lib/security_blog.js b/lib/security_blog.js new file mode 100644 index 00000000..a56fd7a7 --- /dev/null +++ b/lib/security_blog.js @@ -0,0 +1,176 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import _ from 'lodash'; +import { + PLACEHOLDERS, + getVulnerabilitiesJSON, + checkoutOnSecurityReleaseBranch, + NEXT_SECURITY_RELEASE_REPOSITORY, + validateDate +} from './security-release/security-release.js'; + +export default class SecurityBlog { + repository = NEXT_SECURITY_RELEASE_REPOSITORY; + constructor(cli) { + this.cli = cli; + } + + async createPreRelease() { + const { cli } = this; + + // checkout on security release branch + checkoutOnSecurityReleaseBranch(cli, this.repository); + + // read vulnerabilities JSON file + const content = getVulnerabilitiesJSON(cli); + // validate the release date read from vulnerabilities JSON + 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.getSecurityPreReleaseTemplate(); + const data = { + annoucementDate: await this.getAnnouncementDate(cli), + releaseDate: this.formatReleaseDate(releaseDate), + affectedVersions: this.getAffectedVersions(content), + vulnerabilities: this.getVulnerabilities(content), + slug: this.getSlug(releaseDate), + impact: this.getImpact(content), + openSSLUpdate: await this.promptOpenSSLUpdate(cli) + }; + const month = releaseDate.toLocaleString('en-US', { month: 'long' }).toLowerCase(); + const year = releaseDate.getFullYear(); + const fileName = `${month}-${year}-security-releases.md`; + const preRelease = this.buildPreRelease(template, data); + const file = path.join(process.cwd(), fileName); + fs.writeFileSync(file, preRelease); + cli.ok(`Pre-release announcement file created at ${file}`); + } + + promptOpenSSLUpdate(cli) { + return cli.prompt('Does this security release containt OpenSSL updates?', { + defaultAnswer: true + }); + } + + 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, + openSSLUpdate + } = 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.openSSLUpdate, this.getOpenSSLUpdateTemplate(openSSLUpdate)); + } + + getOpenSSLUpdateTemplate(openSSLUpdate) { + if (openSSLUpdate) { + return '\n## OpenSSL Security updates\n\n' + + 'This security release includes OpenSSL security updates\n'; + } + return ''; + } + + 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 (error) { + return PLACEHOLDERS.annoucementDate; + } + } + + promptAnnouncementDate(cli) { + return cli.prompt('When is the security release going to be announced? ' + + 'Enter in YYYY-MM-DD format:', { + questionType: 'input', + defaultAnswer: PLACEHOLDERS.annoucementDate + }); + } + + getImpact(content) { + const impact = content.reports.reduce((acc, report) => { + for (const affectedVersion of report.affectedVersions) { + if (acc[affectedVersion]) { + acc[affectedVersion].push(report); + } else { + acc[affectedVersion] = [report]; + } + } + return acc; + }, {}); + + const impactText = []; + for (const [key, value] of Object.entries(impact)) { + const groupedByRating = Object.values(_.groupBy(value, 'severity.rating')) + .map(severity => { + const firstSeverityRating = severity[0].severity.rating.toLocaleLowerCase(); + return `${severity.length} ${firstSeverityRating} severity issues`; + }).join(', '); + + impactText.push(`The ${key} release line of Node.js is vulnerable to ${groupedByRating}.`); + } + + return impactText.join('\n'); + } + + getVulnerabilities(content) { + const grouped = _.groupBy(content.reports, 'severity.rating'); + const text = []; + for (const [key, value] of Object.entries(grouped)) { + text.push(`* ${value.length} ${key.toLocaleLowerCase()} severity issues.`); + } + return text.join('\n'); + } + + getAffectedVersions(content) { + const affectedVersions = new Set(); + for (const report of Object.values(content.reports)) { + for (const affectedVersion of report.affectedVersions) { + affectedVersions.add(affectedVersion); + } + } + return Array.from(affectedVersions).join(', '); + } + + getSecurityPreReleaseTemplate() { + return fs.readFileSync( + new URL( + './github/templates/security-pre-release.md', + import.meta.url + ), + 'utf-8' + ); + } +} diff --git a/lib/update_security_release.js b/lib/update_security_release.js index f32d4d0c..3d3abcbb 100644 --- a/lib/update_security_release.js +++ b/lib/update_security_release.js @@ -4,7 +4,8 @@ import { checkoutOnSecurityReleaseBranch, commitAndPushVulnerabilitiesJSON, getSupportedVersions, - getSummary + getSummary, + validateDate } from './security-release/security-release.js'; import fs from 'node:fs'; import path from 'node:path'; @@ -21,13 +22,9 @@ export default class UpdateSecurityRelease { const { cli } = this; try { - const [day, month, year] = releaseDate.split('/'); - const value = new Date(`${month}/${day}/${year}`).valueOf(); - if (Number.isNaN(value) || value < 0) { - throw new Error('Invalid date format'); - } + validateDate(releaseDate); } catch (error) { - cli.error('Invalid date format. Please use the format dd/mm/yyyy.'); + cli.error('Invalid date format. Please use the format yyyy/mm/dd.'); process.exit(1); }