diff --git a/components/git/security.js b/components/git/security.js index 74fe1de9..300f33c6 100644 --- a/components/git/security.js +++ b/components/git/security.js @@ -1,5 +1,5 @@ import CLI from '../../lib/cli.js'; -import SecurityReleaseSteward from '../../lib/prepare_security.js'; +import PrepareSecurityRelease from '../../lib/prepare_security.js'; import UpdateSecurityRelease from '../../lib/update_security_release.js'; import SecurityBlog from '../../lib/security_blog.js'; import SecurityAnnouncement from '../../lib/security-announcement.js'; @@ -138,7 +138,7 @@ async function requestCVEs() { async function startSecurityRelease(argv) { const logStream = process.stdout.isTTY ? process.stdout : process.stderr; const cli = new CLI(logStream); - const release = new SecurityReleaseSteward(cli); + const release = new PrepareSecurityRelease(cli); return release.start(); } diff --git a/lib/prepare_security.js b/lib/prepare_security.js index f103f578..bd0f180a 100644 --- a/lib/prepare_security.js +++ b/lib/prepare_security.js @@ -1,4 +1,3 @@ -import nv from '@pkgjs/nv'; import fs from 'node:fs'; import path from 'node:path'; import auth from './auth.js'; @@ -10,100 +9,89 @@ import { PLACEHOLDERS, checkoutOnSecurityReleaseBranch, commitAndPushVulnerabilitiesJSON, - getSummary, validateDate, promptDependencies, - getSupportedVersions + getSupportedVersions, + pickReport } from './security-release/security-release.js'; import _ from 'lodash'; -export default class SecurityReleaseSteward { +export default class PrepareSecurityRelease { repository = NEXT_SECURITY_RELEASE_REPOSITORY; + title = 'Next Security Release'; constructor(cli) { this.cli = cli; } async start() { - const { cli } = this; const credentials = await auth({ github: true, h1: true }); - const req = new Request(credentials); - const release = new PrepareSecurityRelease(req); - const releaseDate = await release.promptReleaseDate(cli); + this.req = new Request(credentials); + const releaseDate = await this.promptReleaseDate(); if (releaseDate !== 'TBD') { validateDate(releaseDate); } - - const createVulnerabilitiesJSON = await release.promptVulnerabilitiesJSON(cli); + const createVulnerabilitiesJSON = await this.promptVulnerabilitiesJSON(); let securityReleasePRUrl; if (createVulnerabilitiesJSON) { - securityReleasePRUrl = await this.createVulnerabilitiesJSON( - req, release, releaseDate, { cli }); + securityReleasePRUrl = await this.startVulnerabilitiesJSONCreation(releaseDate); } - const createIssue = await release.promptCreateRelaseIssue(cli); + const createIssue = await this.promptCreateRelaseIssue(); if (createIssue) { - const content = await release.buildIssue(releaseDate, securityReleasePRUrl); - await release.createIssue(content, { cli }); + const content = await this.buildIssue(releaseDate, securityReleasePRUrl); + await createIssue( + this.title, content, this.repository, { cli: this.cli, repository: this.repository }); }; - cli.ok('Done!'); + this.cli.ok('Done!'); } - async createVulnerabilitiesJSON(req, release, releaseDate, { cli }) { + async startVulnerabilitiesJSONCreation(releaseDate) { // checkout on the next-security-release branch - checkoutOnSecurityReleaseBranch(cli, this.repository); + checkoutOnSecurityReleaseBranch(this.cli, this.repository); // choose the reports to include in the security release - const reports = await release.chooseReports(cli); - const depUpdates = await release.getDependencyUpdates({ cli }); + const reports = await this.chooseReports(); + const depUpdates = await this.getDependencyUpdates(); const deps = _.groupBy(depUpdates, 'name'); // create the vulnerabilities.json file in the security-release repo - const filePath = await release.createVulnerabilitiesJSON(reports, deps, releaseDate, { cli }); + const filePath = await this.createVulnerabilitiesJSON(reports, deps, releaseDate); + // review the vulnerabilities.json file - const review = await release.promptReviewVulnerabilitiesJSON(cli); + const review = await this.promptReviewVulnerabilitiesJSON(); if (!review) { - cli.info(`To push the vulnerabilities.json file run: + this.cli.info(`To push the vulnerabilities.json file run: - git add ${filePath} - git commit -m "chore: create vulnerabilities.json for next security release" - git push -u origin ${NEXT_SECURITY_RELEASE_BRANCH} - - open a PR on ${release.repository.owner}/${release.repository.repo}`); + - open a PR on ${this.repository.owner}/${this.repository.repo}`); return; }; // commit and push the vulnerabilities.json file const commitMessage = 'chore: create vulnerabilities.json for next security release'; - commitAndPushVulnerabilitiesJSON(filePath, commitMessage, { cli, repository: this.repository }); + commitAndPushVulnerabilitiesJSON(filePath, + commitMessage, + { cli: this.cli, repository: this.repository }); - const createPr = await release.promptCreatePR(cli); + const createPr = await this.promptCreatePR(); if (!createPr) return; // create pr on the security-release repo - return release.createPullRequest(req, { cli }); + return this.createPullRequest(); } -} -class PrepareSecurityRelease { - repository = NEXT_SECURITY_RELEASE_REPOSITORY; - title = 'Next Security Release'; - - constructor(req, repository) { - this.req = req; - if (repository) { - this.repository = repository; - } - } - - promptCreatePR(cli) { - return cli.prompt( + promptCreatePR() { + return this.cli.prompt( 'Create the Next Security Release PR?', { defaultAnswer: true }); } @@ -125,31 +113,32 @@ class PrepareSecurityRelease { } } - async promptReleaseDate(cli) { + async promptReleaseDate() { const nextWeekDate = new Date(); nextWeekDate.setDate(nextWeekDate.getDate() + 7); // Format the date as YYYY/MM/DD const formattedDate = nextWeekDate.toISOString().slice(0, 10).replace(/-/g, '/'); - return cli.prompt('Enter target release date in YYYY/MM/DD format (TBD if not defined yet):', { - questionType: 'input', - defaultAnswer: formattedDate - }); + return this.cli.prompt( + 'Enter target release date in YYYY/MM/DD format (TBD if not defined yet):', { + questionType: 'input', + defaultAnswer: formattedDate + }); } - async promptVulnerabilitiesJSON(cli) { - return cli.prompt( + async promptVulnerabilitiesJSON() { + return this.cli.prompt( 'Create the vulnerabilities.json?', { defaultAnswer: true }); } - async promptCreateRelaseIssue(cli) { - return cli.prompt( + async promptCreateRelaseIssue() { + return this.cli.prompt( 'Create the Next Security Release issue?', { defaultAnswer: true }); } - async promptReviewVulnerabilitiesJSON(cli) { - return cli.prompt( + async promptReviewVulnerabilitiesJSON() { + return this.cli.prompt( 'Please review vulnerabilities.json and press enter to proceed.', { defaultAnswer: true }); } @@ -161,67 +150,21 @@ class PrepareSecurityRelease { return content; } - async createIssue(content, { cli }) { - const data = await this.req.createIssue(this.title, content, this.repository); - if (data.html_url) { - cli.ok(`Created: ${data.html_url}`); - } else { - cli.error(data); - process.exit(1); - } - } - - async chooseReports(cli) { - cli.info('Getting triaged H1 reports...'); + async chooseReports() { + this.cli.info('Getting triaged H1 reports...'); const reports = await this.req.getTriagedReports(); - const supportedVersions = (await nv('supported')) - .map((v) => `${v.versionName}.x`) - .join(','); const selectedReports = []; for (const report of reports.data) { - const { - id, attributes: { title, cve_ids }, - relationships: { severity, weakness, reporter } - } = report; - const link = `https://hackerone.com/reports/${id}`; - const reportSeverity = { - rating: severity?.data?.attributes?.rating || '', - cvss_vector_string: severity?.data?.attributes?.cvss_vector_string || '', - weakness_id: weakness?.data?.id || '' - }; - - cli.separator(); - cli.info(`Report: ${link} - ${title} (${reportSeverity?.rating})`); - const include = await cli.prompt( - 'Would you like to include this report to the next security release?', - { defaultAnswer: true }); - if (!include) { - continue; - } - - const versions = await cli.prompt('Which active release lines this report affects?', { - questionType: 'input', - defaultAnswer: supportedVersions - }); - const summaryContent = await getSummary(id, this.req); - - selectedReports.push({ - id, - title, - cveIds: cve_ids, - severity: reportSeverity, - summary: summaryContent ?? '', - affectedVersions: versions.split(',').map((v) => v.replace('v', '').trim()), - link, - reporter: reporter.data.attributes.username - }); + const rep = await pickReport(report, { cli: this.cli, req: this.req }); + if (!rep) continue; + selectedReports.push(rep); } return selectedReports; } - async createVulnerabilitiesJSON(reports, dependencies, releaseDate, { cli }) { - cli.separator('Creating vulnerabilities.json...'); + async createVulnerabilitiesJSON(reports, dependencies, releaseDate) { + this.cli.separator('Creating vulnerabilities.json...'); const file = JSON.stringify({ releaseDate, reports, @@ -237,14 +180,14 @@ class PrepareSecurityRelease { const fullPath = path.join(folderPath, 'vulnerabilities.json'); fs.writeFileSync(fullPath, file); - cli.ok(`Created ${fullPath} `); + this.cli.ok(`Created ${fullPath} `); return fullPath; } - async createPullRequest(req, { cli }) { + async createPullRequest() { const { owner, repo } = this.repository; - const response = await req.createPullRequest( + const response = await this.req.createPullRequest( this.title, 'List of vulnerabilities to be included in the next security release', { @@ -257,27 +200,28 @@ class PrepareSecurityRelease { ); const url = response?.html_url; if (url) { - cli.ok(`Created: ${url}`); + this.cli.ok(`Created: ${url}`); return url; } if (response?.errors) { for (const error of response.errors) { - cli.error(error.message); + this.cli.error(error.message); } } else { - cli.error(response); + this.cli.error(response); } process.exit(1); } - async getDependencyUpdates({ cli }) { + async getDependencyUpdates() { const deps = []; - cli.log('\n'); - cli.separator('Dependency Updates'); - const updates = await cli.prompt('Are there dependency updates in this security release?', { - defaultAnswer: true, - questionType: 'confirm' - }); + this.cli.log('\n'); + this.cli.separator('Dependency Updates'); + const updates = await this.cli.prompt('Are there dependency updates in this security release?', + { + defaultAnswer: true, + questionType: 'confirm' + }); if (!updates) return deps; @@ -285,21 +229,23 @@ class PrepareSecurityRelease { let asking = true; while (asking) { - const dep = await promptDependencies(cli); + const dep = await promptDependencies(this.cli); if (!dep) { asking = false; break; } - const name = await cli.prompt('What is the name of the dependency that has been updated?', { - defaultAnswer: '', - questionType: 'input' - }); + const name = await this.cli.prompt( + 'What is the name of the dependency that has been updated?', { + defaultAnswer: '', + questionType: 'input' + }); - const versions = await cli.prompt('Which release line does this dependency update affect?', { - defaultAnswer: supportedVersions, - questionType: 'input' - }); + const versions = await this.cli.prompt( + 'Which release line does this dependency update affect?', { + defaultAnswer: supportedVersions, + questionType: 'input' + }); try { const prUrl = dep.replace('https://github.com/', 'https://api.github.com/repos/').replace('pull', 'pulls'); @@ -311,7 +257,7 @@ class PrepareSecurityRelease { title, affectedVersions: versions.split(',').map((v) => v.replace('v', '').trim()) }); - cli.separator(); + this.cli.separator(); } catch (error) { this.cli.error('Invalid PR url. Please provide a valid PR url.'); this.cli.error(error); diff --git a/lib/security-announcement.js b/lib/security-announcement.js index 3d2cfa3e..b7de5dcd 100644 --- a/lib/security-announcement.js +++ b/lib/security-announcement.js @@ -3,7 +3,8 @@ import { checkoutOnSecurityReleaseBranch, getVulnerabilitiesJSON, validateDate, - formatDateToYYYYMMDD + formatDateToYYYYMMDD, + createIssue } from './security-release/security-release.js'; import auth from './auth.js'; import Request from './request.js'; @@ -39,8 +40,10 @@ export default class SecurityAnnouncement { validateDate(content.releaseDate); const releaseDate = new Date(content.releaseDate); - await Promise.all([this.createDockerNodeIssue(releaseDate), - this.createBuildWGIssue(releaseDate)]); + await Promise.all([ + this.createDockerNodeIssue(releaseDate), + this.createBuildWGIssue(releaseDate) + ]); } async createBuildWGIssue(releaseDate) { @@ -56,8 +59,8 @@ export default class SecurityAnnouncement { createPreleaseAnnouncementIssue(releaseDate, team) { const title = `[NEXT-SECURITY-RELEASE] Heads up on upcoming Node.js\ security release ${formatDateToYYYYMMDD(releaseDate)}`; - const content = 'As per security release workflow,' + - ` creating issue to give the ${team} team a heads up.`; + const content = `As per security release workflow,\ + creating issue to give the ${team} team a heads up.`; return { title, content }; } @@ -68,16 +71,6 @@ export default class SecurityAnnouncement { }; const { title, content } = this.createPreleaseAnnouncementIssue(releaseDate, 'docker'); - await this.createIssue(title, content, repository); - } - - async createIssue(title, content, repository) { - const data = await this.req.createIssue(title, content, repository); - if (data.html_url) { - this.cli.ok(`Created: ${data.html_url}`); - } else { - this.cli.error(data); - process.exit(1); - } + await createIssue(title, content, repository, { cli: this.cli, repository: this.repository }); } } diff --git a/lib/security-release/security-release.js b/lib/security-release/security-release.js index 00e6ddc3..eb2d89cb 100644 --- a/lib/security-release/security-release.js +++ b/lib/security-release/security-release.js @@ -61,8 +61,22 @@ export function commitAndPushVulnerabilitiesJSON(filePath, commitMessage, { cli, runSync('git', ['add', filePath]); } + const staged = runSync('git', ['diff', '--name-only', '--cached']).trim(); + if (!staged) { + cli.ok('No changes to commit'); + return; + } + runSync('git', ['commit', '-m', commitMessage]); - runSync('git', ['push', '-u', 'origin', NEXT_SECURITY_RELEASE_BRANCH]); + + try { + runSync('git', ['push', '-u', 'origin', NEXT_SECURITY_RELEASE_BRANCH]); + } catch (error) { + cli.warn('Rebasing...'); + // try to pull rebase and push again + runSync('git', ['pull', 'origin', NEXT_SECURITY_RELEASE_BRANCH, '--rebase']); + runSync('git', ['push', '-u', 'origin', NEXT_SECURITY_RELEASE_BRANCH]); + } cli.ok(`Pushed commit: ${commitMessage} to ${NEXT_SECURITY_RELEASE_BRANCH}`); } @@ -114,3 +128,66 @@ export function promptDependencies(cli) { questionType: 'input' }); } + +export async function createIssue(title, content, repository, { cli, req }) { + const data = await req.createIssue(title, content, repository); + if (data.html_url) { + cli.ok(`Created: ${data.html_url}`); + } else { + cli.error(data); + process.exit(1); + } +} + +export async function pickReport(report, { cli, req }) { + const { + id, attributes: { title, cve_ids }, + relationships: { severity, weakness, reporter } + } = report; + const link = `https://hackerone.com/reports/${id}`; + const reportSeverity = { + rating: severity?.data?.attributes?.rating || '', + cvss_vector_string: severity?.data?.attributes?.cvss_vector_string || '', + weakness_id: weakness?.data?.id || '' + }; + + cli.separator(); + cli.info(`Report: ${link} - ${title} (${reportSeverity?.rating})`); + const include = await cli.prompt( + 'Would you like to include this report to the next security release?', + { defaultAnswer: true }); + if (!include) { + return; + } + + const versions = await cli.prompt('Which active release lines this report affects?', { + questionType: 'input', + defaultAnswer: await getSupportedVersions() + }); + + let patchAuthors = await cli.prompt( + 'Add github username of the authors of the patch (split by comma if multiple)', { + questionType: 'input', + defaultAnswer: '' + }); + + if (!patchAuthors) { + patchAuthors = []; + } else { + patchAuthors = patchAuthors.split(',').map((p) => p.trim()); + } + + const summaryContent = await getSummary(id, req); + + return { + id, + title, + cveIds: cve_ids, + severity: reportSeverity, + summary: summaryContent ?? '', + patchAuthors, + affectedVersions: versions.split(',').map((v) => v.replace('v', '').trim()), + link, + reporter: reporter.data.attributes.username + }; +} diff --git a/lib/update_security_release.js b/lib/update_security_release.js index eaff3c9d..977464f8 100644 --- a/lib/update_security_release.js +++ b/lib/update_security_release.js @@ -3,9 +3,8 @@ import { NEXT_SECURITY_RELEASE_REPOSITORY, checkoutOnSecurityReleaseBranch, commitAndPushVulnerabilitiesJSON, - getSupportedVersions, - getSummary, - validateDate + validateDate, + pickReport } from './security-release/security-release.js'; import fs from 'node:fs'; import path from 'node:path'; @@ -69,7 +68,6 @@ export default class UpdateSecurityRelease { } async addReport(reportId) { - const { cli } = this; const credentials = await auth({ github: true, h1: true @@ -77,51 +75,21 @@ export default class UpdateSecurityRelease { const req = new Request(credentials); // checkout on the next-security-release branch - checkoutOnSecurityReleaseBranch(cli, this.repository); + checkoutOnSecurityReleaseBranch(this.cli, this.repository); // get h1 report const { data: report } = await req.getReport(reportId); - const { - id, attributes: { title, cve_ids }, - relationships: { severity, reporter, weakness } - } = report; - - const reportSeverity = { - rating: severity?.data?.attributes?.rating || '', - cvss_vector_string: severity?.data?.attributes?.cvss_vector_string || '', - weakness_id: weakness?.data?.id || '' - }; - - // get the affected versions - const supportedVersions = await getSupportedVersions(); - const versions = await cli.prompt('Which active release lines this report affects?', { - questionType: 'input', - defaultAnswer: supportedVersions - }); - - // get the team summary from h1 report - const summaryContent = await getSummary(id, req); - - const entry = { - id, - title, - link: `https://hackerone.com/reports/${id}`, - cveIds: cve_ids, - severity: reportSeverity, - summary: summaryContent ?? '', - affectedVersions: versions.split(',').map((v) => v.replace('v', '').trim()), - reporter: reporter.data.attributes.username - }; + const entry = await pickReport(report, { cli: this.cli, req }); const vulnerabilitiesJSONPath = this.getVulnerabilitiesJSONPath(); const content = this.readVulnerabilitiesJSON(vulnerabilitiesJSONPath); content.reports.push(entry); fs.writeFileSync(vulnerabilitiesJSONPath, JSON.stringify(content, null, 2)); - this.cli.ok(`Updated vulnerabilities.json with the report: ${id}`); - const commitMessage = `chore: added report ${id} to vulnerabilities.json`; + this.cli.ok(`Updated vulnerabilities.json with the report: ${entry.id}`); + const commitMessage = `chore: added report ${entry.id} to vulnerabilities.json`; commitAndPushVulnerabilitiesJSON(vulnerabilitiesJSONPath, - commitMessage, { cli, repository: this.repository }); - cli.ok('Done!'); + commitMessage, { cli: this.cli, repository: this.repository }); + this.cli.ok('Done!'); } removeReport(reportId) {