diff --git a/components/git/security.js b/components/git/security.js index 6388272d..74fe1de9 100644 --- a/components/git/security.js +++ b/components/git/security.js @@ -31,6 +31,10 @@ const securityOptions = { 'notify-pre-release': { describe: 'Notify the community about the security release', type: 'boolean' + }, + 'request-cve': { + describe: 'Request CVEs for a security release', + type: 'boolean' } }; @@ -60,6 +64,11 @@ export function builder(yargs) { ).example( 'git node security --notify-pre-release' + 'Notifies the community about the security release' + ) + .example( + 'git node security --request-cve', + 'Request CVEs for a security release of Node.js based on' + + ' the next-security-release/vulnerabilities.json' ); } @@ -82,6 +91,9 @@ export function handler(argv) { if (argv['notify-pre-release']) { return notifyPreRelease(argv); } + if (argv['request-cve']) { + return requestCVEs(argv); + } yargsInstance.showHelp(); } @@ -116,7 +128,14 @@ async function createPreRelease() { return preRelease.createPreRelease(); } -async function startSecurityRelease() { +async function requestCVEs() { + const logStream = process.stdout.isTTY ? process.stdout : process.stderr; + const cli = new CLI(logStream); + const hackerOneCve = new UpdateSecurityRelease(cli); + return hackerOneCve.requestCVEs(); +} + +async function startSecurityRelease(argv) { const logStream = process.stdout.isTTY ? process.stdout : process.stderr; const cli = new CLI(logStream); const release = new SecurityReleaseSteward(cli); diff --git a/lib/prepare_security.js b/lib/prepare_security.js index 5fb357af..cfadbcdf 100644 --- a/lib/prepare_security.js +++ b/lib/prepare_security.js @@ -173,23 +173,15 @@ class PrepareSecurityRelease { for (const report of reports.data) { const { - id, attributes: { title, cve_ids, created_at }, + id, attributes: { title, cve_ids }, relationships: { severity, weakness, reporter } } = report; const link = `https://hackerone.com/reports/${id}`; - let reportSeverity = { - rating: '', - cvss_vector_string: '', - weakness_id: '' + const reportSeverity = { + rating: severity?.data?.attributes?.rating || '', + cvss_vector_string: severity?.data?.attributes?.cvss_vector_string || '', + weakness_id: weakness?.data?.id || '' }; - if (severity?.data?.attributes?.cvss_vector_string) { - const { cvss_vector_string, rating } = severity.data.attributes; - reportSeverity = { - cvss_vector_string, - rating, - weakness_id: weakness?.data?.id - }; - } cli.separator(); cli.info(`Report: ${link} - ${title} (${reportSeverity?.rating})`); @@ -209,13 +201,12 @@ class PrepareSecurityRelease { selectedReports.push({ id, title, - cve_ids, + cveIds: cve_ids, severity: reportSeverity, summary: summaryContent ?? '', affectedVersions: versions.split(',').map((v) => v.replace('v', '').trim()), link, - reporter: reporter.data.attributes.username, - created_at // when we request CVE we need to input vulnerability_discovered_at + reporter: reporter.data.attributes.username }); } return selectedReports; diff --git a/lib/request.js b/lib/request.js index 6977ea4c..4e8c830d 100644 --- a/lib/request.js +++ b/lib/request.js @@ -132,6 +132,49 @@ export default class Request { return this.json(url, options); } + async getPrograms() { + const url = 'https://api.hackerone.com/v1/me/programs'; + const options = { + method: 'GET', + headers: { + Authorization: `Basic ${this.credentials.h1}`, + 'User-Agent': 'node-core-utils', + Accept: 'application/json' + } + }; + return this.json(url, options); + } + + async requestCVE(programId, opts) { + const url = `https://api.hackerone.com/v1/programs/${programId}/cve_requests`; + const options = { + method: 'POST', + headers: { + Authorization: `Basic ${this.credentials.h1}`, + 'User-Agent': 'node-core-utils', + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + body: JSON.stringify(opts) + }; + return this.json(url, options); + } + + async updateReportCVE(reportId, opts) { + const url = `https://api.hackerone.com/v1/reports/${reportId}/cves`; + const options = { + method: 'PUT', + headers: { + Authorization: `Basic ${this.credentials.h1}`, + 'User-Agent': 'node-core-utils', + Accept: 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(opts) + }; + return this.json(url, options); + } + async getReport(reportId) { const url = `https://api.hackerone.com/v1/reports/${reportId}`; const options = { diff --git a/lib/update_security_release.js b/lib/update_security_release.js index 3d3abcbb..eaff3c9d 100644 --- a/lib/update_security_release.js +++ b/lib/update_security_release.js @@ -11,6 +11,7 @@ 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; @@ -32,7 +33,7 @@ export default class UpdateSecurityRelease { checkoutOnSecurityReleaseBranch(cli, this.repository); // update the release date in the vulnerabilities.json file - const updatedVulnerabilitiesFiles = await this.updateVulnerabilitiesJSON(releaseDate, { cli }); + const updatedVulnerabilitiesFiles = await this.updateJSONReleaseDate(releaseDate, { cli }); const commitMessage = `chore: update the release date to ${releaseDate}`; commitAndPushVulnerabilitiesJSON(updatedVulnerabilitiesFiles, @@ -56,7 +57,7 @@ export default class UpdateSecurityRelease { NEXT_SECURITY_RELEASE_FOLDER, 'vulnerabilities.json'); } - async updateVulnerabilitiesJSON(releaseDate) { + async updateJSONReleaseDate(releaseDate) { const vulnerabilitiesJSONPath = this.getVulnerabilitiesJSONPath(); const content = this.readVulnerabilitiesJSON(vulnerabilitiesJSONPath); content.releaseDate = releaseDate; @@ -80,9 +81,16 @@ export default class UpdateSecurityRelease { // get h1 report const { data: report } = await req.getReport(reportId); - const { id, attributes: { title, cve_ids }, relationships: { severity, reporter } } = report; - // if severity is not set on h1, set it to TBD - const reportLevel = severity ? severity.data.attributes.rating : 'TBD'; + 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(); @@ -97,8 +105,9 @@ export default class UpdateSecurityRelease { const entry = { id, title, - cve_ids, - severity: reportLevel, + 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 @@ -135,4 +144,163 @@ export default class UpdateSecurityRelease { commitMessage, { cli, repository: this.repository }); cli.ok('Done!'); } + + async requestCVEs() { + const credentials = await auth({ + github: true, + h1: true + }); + const vulnerabilitiesJSONPath = this.getVulnerabilitiesJSONPath(); + const content = this.readVulnerabilitiesJSON(vulnerabilitiesJSONPath); + const { reports } = content; + const req = new Request(credentials); + const programId = await this.getNodeProgramId(req); + const cves = await this.promptCVECreation(req, reports, programId); + this.assignCVEtoReport(cves, reports); + this.updateVulnerabilitiesJSON(content, vulnerabilitiesJSONPath); + this.updateHackonerReportCve(req, reports); + } + + assignCVEtoReport(cves, reports) { + for (const cve of cves) { + const report = reports.find(report => report.id === cve.reportId); + report.cveIds = [cve.cve_identifier]; + } + } + + async updateHackonerReportCve(req, reports) { + for (const report of reports) { + const { id, cveIds } = report; + this.cli.startSpinner(`Updating report ${id} with CVEs ${cveIds}..`); + const body = { + data: { + type: 'report-cves', + attributes: { + cve_ids: cveIds + } + } + }; + const response = await req.updateReportCVE(id, body); + if (response.errors) { + this.cli.error(`Error updating report ${id}`); + this.cli.error(JSON.stringify(response.errors, null, 2)); + } + this.cli.stopSpinner(`Done updating report ${id} with CVEs ${cveIds}..`); + } + } + + 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 = []; + for (const report of reports) { + const { id, summary, title, affectedVersions, cveIds, link } = report; + // skip if already has a CVE + // risky because the CVE associated might be + // mentioned in the report and not requested by Node + if (cveIds?.length) continue; + + let severity = report.severity; + + if (!severity.cvss_vector_string || !severity.weakness_id) { + try { + const h1Report = await req.getReport(id); + if (!h1Report.data.relationships.severity?.data.attributes.cvss_vector_string) { + throw new Error('No severity found'); + } + severity = { + weakness_id: h1Report.data.relationships.weakness?.data.id, + cvss_vector_string: + h1Report.data.relationships.severity?.data.attributes.cvss_vector_string, + rating: h1Report.data.relationships.severity?.data.attributes.rating + }; + } catch (error) { + this.cli.error(`Couldnt not retrieve severity from report ${id}, skipping...`); + continue; + } + } + + const { cvss_vector_string, weakness_id } = severity; + + const create = await this.cli.prompt( + `Request a CVE for: \n +Title: ${title}\n +Link: ${link}\n +Affected versions: ${affectedVersions.join(', ')}\n +Vector: ${cvss_vector_string}\n +Summary: ${summary}\n`, + { defaultAnswer: true }); + + if (!create) continue; + + const body = { + data: { + type: 'cve-request', + attributes: { + team_handle: 'nodejs-team', + versions: await this.formatAffected(affectedVersions, supportedVersions), + metrics: [ + { + vectorString: cvss_vector_string + } + ], + weakness_id: Number(weakness_id), + description: title, + vulnerability_discovered_at: new Date().toISOString() + } + } + }; + const { data } = await req.requestCVE(programId, body); + if (data.errors) { + this.cli.error(`Error requesting CVE for report ${id}`); + this.cli.error(JSON.stringify(data.errors, null, 2)); + continue; + } + const { cve_identifier } = data.attributes; + cves.push({ cve_identifier, reportId: id }); + } + return cves; + } + + async getNodeProgramId(req) { + const programs = await req.getPrograms(); + const { data } = programs; + for (const program of data) { + const { attributes } = program; + if (attributes.handle === 'nodejs') { + return program.id; + } + } + } + + async formatAffected(affectedVersions, supportedVersions) { + const result = []; + for (const affectedVersion of affectedVersions) { + const major = affectedVersion.split('.')[0]; + const latest = supportedVersions.find((v) => v.major === Number(major)).version; + const version = await this.cli.prompt( + `What is the affected version (<=) for release line ${affectedVersion}?`, + { questionType: 'input', defaultAnswer: latest }); + result.push({ + vendor: 'nodejs', + product: 'node', + func: '<=', + version, + versionType: 'semver', + affected: true + }); + } + return result; + } }