Skip to content

Commit

Permalink
feat: automate pre-release blogpost creation (#773)
Browse files Browse the repository at this point in the history
  • Loading branch information
marco-ippolito committed Mar 22, 2024
1 parent 648918b commit b70e636
Show file tree
Hide file tree
Showing 6 changed files with 269 additions and 14 deletions.
28 changes: 24 additions & 4 deletions 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.';
Expand All @@ -21,18 +22,23 @@ const securityOptions = {
'remove-report': {
describe: 'Removes a report from vulnerabilities.json',
type: 'string'
},
'pre-release': {
describe: 'Create the pre-release announcement',
type: 'boolean'
}
};

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(
Expand All @@ -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'
);
}

Expand All @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down
13 changes: 11 additions & 2 deletions docs/git-node.md
Expand Up @@ -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`
Expand Down
29 changes: 29 additions & 0 deletions 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 <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.

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.
26 changes: 25 additions & 1 deletion 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';
Expand All @@ -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) {
Expand Down Expand Up @@ -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');
}
}
176 changes: 176 additions & 0 deletions 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'
);
}
}
11 changes: 4 additions & 7 deletions lib/update_security_release.js
Expand Up @@ -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';
Expand All @@ -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);
}

Expand Down

0 comments on commit b70e636

Please sign in to comment.