From 871a16f7968d112468f751ab019ca575151745d2 Mon Sep 17 00:00:00 2001 From: Rafael Gonzaga Date: Wed, 14 Aug 2024 15:28:53 -0300 Subject: [PATCH] feat: add git node security --cleanup (#833) * feat: add git node security --finish feat: use SecurityRelease as base class * fixup! rename to cleanup --- components/git/security.js | 17 ++++++ lib/prepare_security.js | 69 ++++++++++++++++++++---- lib/request.js | 59 ++++++++++++++++++++ lib/security-release/security-release.js | 53 ++++++++++++++++++ lib/security_blog.js | 46 ++-------------- lib/update_security_release.js | 47 +++------------- 6 files changed, 199 insertions(+), 92 deletions(-) diff --git a/components/git/security.js b/components/git/security.js index cdb4771a..bd306f1d 100644 --- a/components/git/security.js +++ b/components/git/security.js @@ -43,6 +43,10 @@ const securityOptions = { 'post-release': { describe: 'Create the post-release announcement', type: 'boolean' + }, + cleanup: { + describe: 'cleanup the security release.', + type: 'boolean' } }; @@ -81,6 +85,9 @@ export function builder(yargs) { ).example( 'git node security --post-release', 'Create the post-release announcement on the Nodejs.org repo' + ).example( + 'git node security --cleanup', + 'Cleanup the security release. Merge the PR and close H1 reports' ); } @@ -112,6 +119,9 @@ export function handler(argv) { if (argv['post-release']) { return createPostRelease(argv); } + if (argv.cleanup) { + return cleanupSecurityRelease(argv); + } yargsInstance.showHelp(); } @@ -167,6 +177,13 @@ async function startSecurityRelease() { return release.start(); } +async function cleanupSecurityRelease() { + const logStream = process.stdout.isTTY ? process.stdout : process.stderr; + const cli = new CLI(logStream); + const release = new PrepareSecurityRelease(cli); + return release.cleanup(); +} + async function syncSecurityRelease(argv) { const logStream = process.stdout.isTTY ? process.stdout : process.stderr; const cli = new CLI(logStream); diff --git a/lib/prepare_security.js b/lib/prepare_security.js index 4ffb90fe..003c4840 100644 --- a/lib/prepare_security.js +++ b/lib/prepare_security.js @@ -5,22 +5,18 @@ import Request from './request.js'; import { NEXT_SECURITY_RELEASE_BRANCH, NEXT_SECURITY_RELEASE_FOLDER, - NEXT_SECURITY_RELEASE_REPOSITORY, checkoutOnSecurityReleaseBranch, commitAndPushVulnerabilitiesJSON, validateDate, promptDependencies, getSupportedVersions, - pickReport + pickReport, + SecurityRelease } from './security-release/security-release.js'; import _ from 'lodash'; -export default class PrepareSecurityRelease { - repository = NEXT_SECURITY_RELEASE_REPOSITORY; +export default class PrepareSecurityRelease extends SecurityRelease { title = 'Next Security Release'; - constructor(cli) { - this.cli = cli; - } async start() { const credentials = await auth({ @@ -44,6 +40,27 @@ export default class PrepareSecurityRelease { this.cli.ok('Done!'); } + async cleanup() { + const credentials = await auth({ + github: true, + h1: true + }); + + this.req = new Request(credentials); + const vulnerabilityJSON = this.readVulnerabilitiesJSON(); + this.cli.info('Closing and request disclosure to HackerOne reports'); + await this.closeAndRequestDisclosure(vulnerabilityJSON.reports); + + this.cli.info('Closing pull requests'); + // For now, close the ones with vN.x label + await this.closePRWithLabel(this.getAffectedVersions(vulnerabilityJSON)); + this.cli.info(`Merge pull request with: + - git checkout main + - git merge --squash ${NEXT_SECURITY_RELEASE_BRANCH} + - git push origin main`); + this.cli.ok('Done!'); + } + async startVulnerabilitiesJSONCreation(releaseDate, content) { // checkout on the next-security-release branch checkoutOnSecurityReleaseBranch(this.cli, this.repository); @@ -163,9 +180,9 @@ export default class PrepareSecurityRelease { const folderPath = path.join(process.cwd(), NEXT_SECURITY_RELEASE_FOLDER); try { - await fs.accessSync(folderPath); + fs.accessSync(folderPath); } catch (error) { - await fs.mkdirSync(folderPath, { recursive: true }); + fs.mkdirSync(folderPath, { recursive: true }); } const fullPath = path.join(folderPath, 'vulnerabilities.json'); @@ -254,4 +271,38 @@ export default class PrepareSecurityRelease { } return deps; } + + async closeAndRequestDisclosure(jsonReports) { + this.cli.startSpinner('Closing HackerOne reports'); + for (const report of jsonReports) { + this.cli.updateSpinner(`Closing report ${report.id}...`); + await this.req.updateReportState( + report.id, + 'resolved', + 'Closing as resolved' + ); + + this.cli.updateSpinner(`Requesting disclosure to report ${report.id}...`); + await this.req.requestDisclosure(report.id); + } + this.cli.stopSpinner('Done closing H1 Reports and requesting disclosure'); + } + + async closePRWithLabel(labels) { + if (typeof labels === 'string') { + labels = [labels]; + } + + const url = 'https://github.com/nodejs-private/node-private/pulls'; + this.cli.startSpinner('Closing GitHub Pull Requests...'); + // At this point, GitHub does not provide filters through their REST API + const prs = this.req.getPullRequest(url); + for (const pr of prs) { + if (pr.labels.some((l) => labels.includes(l))) { + this.cli.updateSpinner(`Closing Pull Request: ${pr.id}`); + await this.req.closePullRequest(pr.id); + } + } + this.cli.startSpinner('Closed GitHub Pull Requests.'); + } } diff --git a/lib/request.js b/lib/request.js index 553322f3..a4b43586 100644 --- a/lib/request.js +++ b/lib/request.js @@ -109,6 +109,22 @@ export default class Request { return this.json(url, options); } + async closePullRequest({ owner, repo }) { + const url = `https://api.github.com/repos/${owner}/${repo}/pulls`; + const options = { + method: 'POST', + headers: { + Authorization: `Basic ${this.credentials.github}`, + 'User-Agent': 'node-core-utils', + Accept: 'application/vnd.github+json' + }, + body: JSON.stringify({ + state: 'closed' + }) + }; + return this.json(url, options); + } + async gql(name, variables, path) { const query = this.loadQuery(name); if (path) { @@ -201,6 +217,49 @@ export default class Request { return this.json(url, options); } + async updateReportState(reportId, state, message) { + const url = `https://api.hackerone.com/v1/reports/${reportId}/state_changes`; + const options = { + method: 'POST', + headers: { + Authorization: `Basic ${this.credentials.h1}`, + 'User-Agent': 'node-core-utils', + Accept: 'application/json' + }, + body: JSON.stringify({ + data: { + type: 'state-change', + attributes: { + message, + state + } + } + }) + }; + return this.json(url, options); + } + + async requestDisclosure(reportId) { + const url = `https://api.hackerone.com/v1/reports/${reportId}/disclosure_requests`; + const options = { + method: 'POST', + headers: { + Authorization: `Basic ${this.credentials.h1}`, + 'User-Agent': 'node-core-utils', + Accept: 'application/json' + }, + body: JSON.stringify({ + data: { + attributes: { + // default to limited version + substate: 'no-content' + } + } + }) + }; + return this.json(url, options); + } + // This is for github v4 API queries, for other types of queries // use .text or .json async query(query, variables) { diff --git a/lib/security-release/security-release.js b/lib/security-release/security-release.js index 73f93cd6..d8308a71 100644 --- a/lib/security-release/security-release.js +++ b/lib/security-release/security-release.js @@ -210,3 +210,56 @@ export async function pickReport(report, { cli, req }) { 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'); + } + + updateVulnerabilitiesJSON(content) { + try { + const vulnerabilitiesJSONPath = this.getVulnerabilitiesJSONPath(); + this.cli.startSpinner(`Updating vulnerabilities.json from ${vulnerabilitiesJSONPath}...`); + fs.writeFileSync(vulnerabilitiesJSONPath, JSON.stringify(content, null, 2)); + commitAndPushVulnerabilitiesJSON(vulnerabilitiesJSONPath, + 'chore: updated vulnerabilities.json', + { cli: this.cli, repository: this.repository }); + this.cli.stopSpinner(`Done updating vulnerabilities.json from ${vulnerabilitiesJSONPath}`); + } catch (error) { + this.cli.error('Error updating vulnerabilities.json'); + this.cli.error(error); + } + } + + getAffectedVersions(content) { + const affectedVersions = new Set(); + for (const report of Object.values(content.reports)) { + for (const affectedVersion of 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(', '); + } +} diff --git a/lib/security_blog.js b/lib/security_blog.js index c0987bfe..34251d4d 100644 --- a/lib/security_blog.js +++ b/lib/security_blog.js @@ -4,24 +4,17 @@ import _ from 'lodash'; import nv from '@pkgjs/nv'; import { PLACEHOLDERS, - getVulnerabilitiesJSON, checkoutOnSecurityReleaseBranch, - NEXT_SECURITY_RELEASE_REPOSITORY, validateDate, - commitAndPushVulnerabilitiesJSON, - NEXT_SECURITY_RELEASE_FOLDER + SecurityRelease } 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; +export default class SecurityBlog extends SecurityRelease { req; - constructor(cli) { - this.cli = cli; - } async createPreRelease() { const { cli } = this; @@ -30,7 +23,7 @@ export default class SecurityBlog { checkoutOnSecurityReleaseBranch(cli, this.repository); // read vulnerabilities JSON file - const content = getVulnerabilitiesJSON(cli); + const content = this.readVulnerabilitiesJSON(); // validate the release date read from vulnerabilities JSON if (!content.releaseDate) { cli.error('Release date is not set in vulnerabilities.json,' + @@ -72,7 +65,7 @@ export default class SecurityBlog { checkoutOnSecurityReleaseBranch(cli, this.repository); // read vulnerabilities JSON file - const content = getVulnerabilitiesJSON(cli); + const content = this.readVulnerabilitiesJSON(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.'); @@ -113,22 +106,6 @@ export default class SecurityBlog { 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:', { @@ -324,21 +301,6 @@ export default class SecurityBlog { 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); - } - } - const parseToNumber = str => +(str.match(/[\d.]+/g)[0]); - return Array.from(affectedVersions) - .sort((a, b) => { - return parseToNumber(a) > parseToNumber(b) ? -1 : 1; - }) - .join(', '); - } - getSecurityPreReleaseTemplate() { return fs.readFileSync( new URL( diff --git a/lib/update_security_release.js b/lib/update_security_release.js index c9ae2dd4..66e2c162 100644 --- a/lib/update_security_release.js +++ b/lib/update_security_release.js @@ -1,31 +1,23 @@ import { - NEXT_SECURITY_RELEASE_FOLDER, - NEXT_SECURITY_RELEASE_REPOSITORY, checkoutOnSecurityReleaseBranch, checkRemote, commitAndPushVulnerabilitiesJSON, validateDate, pickReport, getReportSeverity, - getSummary + getSummary, + SecurityRelease } from './security-release/security-release.js'; import fs from 'node:fs'; -import path from 'node:path'; import auth from './auth.js'; import Request from './request.js'; import nv from '@pkgjs/nv'; -export default class UpdateSecurityRelease { - repository = NEXT_SECURITY_RELEASE_REPOSITORY; - constructor(cli) { - this.cli = cli; - } - +export default class UpdateSecurityRelease extends SecurityRelease { async sync() { checkRemote(this.cli, this.repository); - const vulnerabilitiesJSONPath = this.getVulnerabilitiesJSONPath(); - const content = this.readVulnerabilitiesJSON(vulnerabilitiesJSONPath); + const content = this.readVulnerabilitiesJSON(); const credentials = await auth({ github: true, h1: true @@ -52,6 +44,7 @@ export default class UpdateSecurityRelease { prURL }; } + const vulnerabilitiesJSONPath = this.getVulnerabilitiesJSONPath(); fs.writeFileSync(vulnerabilitiesJSONPath, JSON.stringify(content, null, 2)); this.cli.ok('Synced vulnerabilities.json with HackerOne'); } @@ -78,22 +71,6 @@ export default class UpdateSecurityRelease { cli.ok('Done!'); } - readVulnerabilitiesJSON(vulnerabilitiesJSONPath) { - 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 updateJSONReleaseDate(releaseDate) { const vulnerabilitiesJSONPath = this.getVulnerabilitiesJSONPath(); const content = this.readVulnerabilitiesJSON(vulnerabilitiesJSONPath); @@ -163,7 +140,7 @@ export default class UpdateSecurityRelease { const programId = await this.getNodeProgramId(req); const cves = await this.promptCVECreation(req, reports, programId); this.assignCVEtoReport(cves, reports); - this.updateVulnerabilitiesJSON(content, vulnerabilitiesJSONPath); + this.updateVulnerabilitiesJSON(content); this.updateHackonerReportCve(req, reports); } @@ -195,18 +172,6 @@ export default class UpdateSecurityRelease { } } - updateVulnerabilitiesJSON(content, vulnerabilitiesJSONPath) { - this.cli.startSpinner(`Updating vulnerabilities.json from\ - ${vulnerabilitiesJSONPath}..`); - const filePath = path.resolve(vulnerabilitiesJSONPath); - fs.writeFileSync(filePath, JSON.stringify(content, null, 2)); - // push the changes to the repository - commitAndPushVulnerabilitiesJSON(filePath, - 'chore: updated vulnerabilities.json with CVEs', - { cli: this.cli, repository: this.repository }); - this.cli.stopSpinner(`Done updating vulnerabilities.json from ${filePath}`); - } - async promptCVECreation(req, reports, programId) { const supportedVersions = (await nv('supported')); const cves = [];