Skip to content

Commit 7f89da3

Browse files
feat: automate security release issue creation (#771)
* feat: automate security release issue creation * fixup! address comments in pr * fixup! adress comments
1 parent bd87b37 commit 7f89da3

File tree

3 files changed

+248
-51
lines changed

3 files changed

+248
-51
lines changed

lib/github/templates/next-security-release.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@
33
* [X] Open an [issue](https://github.com/nodejs-private/node-private) titled
44
`Next Security Release`, and put this checklist in the description.
55

6-
* [ ] Get agreement on the list of vulnerabilities to be addressed:
7-
%REPORTS%
6+
* [ ] Get agreement on the [list of vulnerabilities](%VULNERABILITIES_PR_URL%) to be addressed.
87

98
* [ ] PR release announcements in [private](https://github.com/nodejs-private/nodejs.org-private):
109
* [ ] pre-release: %PRE_RELEASE_PRIV%

lib/prepare_security.js

Lines changed: 215 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@ import nv from '@pkgjs/nv';
22
import auth from './auth.js';
33
import Request from './request.js';
44
import fs from 'node:fs';
5+
import { runSync } from './run.js';
6+
import path from 'node:path';
7+
8+
export const PLACEHOLDERS = {
9+
releaseDate: '%RELEASE_DATE%',
10+
vulnerabilitiesPRURL: '%VULNERABILITIES_PR_URL%',
11+
preReleasePrivate: '%PRE_RELEASE_PRIV%',
12+
postReleasePrivate: '%POS_RELEASE_PRIV%',
13+
affectedLines: '%AFFECTED_LINES%'
14+
};
515

616
export default class SecurityReleaseSteward {
717
constructor(cli) {
@@ -16,31 +26,100 @@ export default class SecurityReleaseSteward {
1626
});
1727

1828
const req = new Request(credentials);
19-
const create = await cli.prompt(
20-
'Create the Next Security Release issue?',
21-
{ defaultAnswer: true });
22-
if (create) {
23-
const issue = new SecurityReleaseIssue(req);
24-
const content = await issue.buildIssue(cli);
25-
const data = await req.createIssue('Next Security Release', content, {
26-
owner: 'nodejs-private',
27-
repo: 'node-private'
28-
});
29-
if (data.html_url) {
30-
cli.ok('Created: ' + data.html_url);
31-
} else {
32-
cli.error(data);
33-
}
29+
const release = new PrepareSecurityRelease(req);
30+
const releaseDate = await release.promptReleaseDate(cli);
31+
let securityReleasePRUrl = PLACEHOLDERS.vulnerabilitiesPRURL;
32+
33+
const createVulnerabilitiesJSON = await release.promptVulnerabilitiesJSON(cli);
34+
if (createVulnerabilitiesJSON) {
35+
securityReleasePRUrl = await this.createVulnerabilitiesJSON(req, release, { cli });
3436
}
37+
38+
const createIssue = await release.promptCreateRelaseIssue(cli);
39+
40+
if (createIssue) {
41+
const { content } = release.buildIssue(releaseDate, securityReleasePRUrl);
42+
await release.createIssue(content, { cli });
43+
};
44+
45+
cli.ok('Done!');
46+
}
47+
48+
async createVulnerabilitiesJSON(req, release, { cli }) {
49+
// checkout on the next-security-release branch
50+
release.checkoutOnSecurityReleaseBranch(cli);
51+
52+
// choose the reports to include in the security release
53+
const reports = await release.chooseReports(cli);
54+
55+
// create the vulnerabilities.json file in the security-release repo
56+
const filePath = await release.createVulnerabilitiesJSON(reports, { cli });
57+
58+
// review the vulnerabilities.json file
59+
const review = await release.promptReviewVulnerabilitiesJSON(cli);
60+
61+
if (!review) {
62+
cli.info(`To push the vulnerabilities.json file run:
63+
- git add ${filePath}
64+
- git commit -m "chore: create vulnerabilities.json for next security release"
65+
- git push -u origin next-security-release
66+
- open a PR on ${release.repository.owner}/${release.repository.repo}`);
67+
return;
68+
};
69+
70+
// commit and push the vulnerabilities.json file
71+
release.commitAndPushVulnerabilitiesJSON(filePath, cli);
72+
73+
const createPr = await release.promptCreatePR(cli);
74+
75+
if (!createPr) return;
76+
77+
// create pr on the security-release repo
78+
return release.createPullRequest(req, { cli });
3579
}
3680
}
3781

38-
class SecurityReleaseIssue {
39-
constructor(req) {
82+
class PrepareSecurityRelease {
83+
repository = {
84+
owner: 'nodejs-private',
85+
repo: 'security-release'
86+
};
87+
88+
title = 'Next Security Release';
89+
nextSecurityReleaseBranch = 'next-security-release';
90+
91+
constructor(req, repository) {
4092
this.req = req;
41-
this.content = '';
42-
this.title = 'Next Security Release';
43-
this.affectedLines = {};
93+
if (repository) {
94+
this.repository = repository;
95+
}
96+
}
97+
98+
promptCreatePR(cli) {
99+
return cli.prompt(
100+
'Create the Next Security Release PR?',
101+
{ defaultAnswer: true });
102+
}
103+
104+
checkRemote(cli) {
105+
const remote = runSync('git', ['ls-remote', '--get-url', 'origin']).trim();
106+
const { owner, repo } = this.repository;
107+
const securityReleaseOrigin = `https://github.com/${owner}/${repo}.git`;
108+
109+
if (remote !== securityReleaseOrigin) {
110+
cli.error(`Wrong repository! It should be ${securityReleaseOrigin}`);
111+
process.exit(1);
112+
}
113+
}
114+
115+
commitAndPushVulnerabilitiesJSON(filePath, cli) {
116+
this.checkRemote(cli);
117+
118+
runSync('git', ['add', filePath]);
119+
const commitMessage = 'chore: create vulnerabilities.json for next security release';
120+
runSync('git', ['commit', '-m', commitMessage]);
121+
runSync('git', ['push', '-u', 'origin', 'next-security-release']);
122+
cli.ok(`Pushed commit: ${commitMessage} to ${this.nextSecurityReleaseBranch}`);
44123
}
45124

46125
getSecurityIssueTemplate() {
@@ -53,31 +132,58 @@ class SecurityReleaseIssue {
53132
);
54133
}
55134

56-
async buildIssue(cli) {
57-
this.content = this.getSecurityIssueTemplate();
58-
cli.info('Getting triaged H1 reports...');
59-
const reports = await this.req.getTriagedReports();
60-
await this.fillReports(cli, reports);
61-
62-
this.fillAffectedLines(Object.keys(this.affectedLines));
63-
64-
const target = await cli.prompt('Enter target date in YYYY-MM-DD format:', {
135+
async promptReleaseDate(cli) {
136+
return cli.prompt('Enter target release date in YYYY-MM-DD format:', {
65137
questionType: 'input',
66138
defaultAnswer: 'TBD'
67139
});
68-
this.fillTargetDate(target);
140+
}
141+
142+
async promptVulnerabilitiesJSON(cli) {
143+
return cli.prompt(
144+
'Create the vulnerabilities.json?',
145+
{ defaultAnswer: true });
146+
}
147+
148+
async promptCreateRelaseIssue(cli) {
149+
return cli.prompt(
150+
'Create the Next Security Release issue?',
151+
{ defaultAnswer: true });
152+
}
69153

70-
return this.content;
154+
async promptReviewVulnerabilitiesJSON(cli) {
155+
return cli.prompt(
156+
'Please review vulnerabilities.json and press enter to proceed.',
157+
{ defaultAnswer: true });
71158
}
72159

73-
async fillReports(cli, reports) {
160+
buildIssue(releaseDate, securityReleasePRUrl) {
161+
const template = this.getSecurityIssueTemplate();
162+
const content = template.replace(PLACEHOLDERS.releaseDate, releaseDate)
163+
.replace(PLACEHOLDERS.vulnerabilitiesPRURL, securityReleasePRUrl);
164+
return { releaseDate, content, securityReleasePRUrl };
165+
}
166+
167+
async createIssue(content, { cli }) {
168+
const data = await this.req.createIssue(this.title, content, this.repository);
169+
if (data.html_url) {
170+
cli.ok('Created: ' + data.html_url);
171+
} else {
172+
cli.error(data);
173+
process.exit(1);
174+
}
175+
}
176+
177+
async chooseReports(cli) {
178+
cli.info('Getting triaged H1 reports...');
179+
const reports = await this.req.getTriagedReports();
74180
const supportedVersions = (await nv('supported'))
75181
.map((v) => v.versionName + '.x')
76182
.join(',');
183+
const selectedReports = [];
77184

78-
let reportsContent = '';
79185
for (const report of reports.data) {
80-
const { id, attributes: { title }, relationships: { severity } } = report;
186+
const { id, attributes: { title, cve_ids }, relationships: { severity } } = report;
81187
const reportLevel = severity ? severity.data.attributes.rating : 'TBD';
82188
cli.separator();
83189
cli.info(`Report: ${id} - ${title} (${reportLevel})`);
@@ -88,30 +194,90 @@ class SecurityReleaseIssue {
88194
continue;
89195
}
90196

91-
reportsContent +=
92-
` * **[${id}](https://hackerone.com/bugs?subject=nodejs&report_id=${id}) - ${title} (TBD) - (${reportLevel})**\n`;
93197
const versions = await cli.prompt('Which active release lines this report affects?', {
94198
questionType: 'input',
95199
defaultAnswer: supportedVersions
96200
});
97-
for (const v of versions.split(',')) {
98-
if (!this.affectedLines[v]) this.affectedLines[v] = true;
99-
reportsContent += ` * ${v} - TBD\n`;
100-
}
201+
const summaryContent = await this.getSummary(id);
202+
203+
selectedReports.push({
204+
id,
205+
title,
206+
cve_ids,
207+
severity: reportLevel,
208+
summary: summaryContent ?? '',
209+
affectedVersions: versions.split(',').map((v) => v.replace('v', '').trim())
210+
});
101211
}
102-
this.content = this.content.replace('%REPORTS%', reportsContent);
212+
return selectedReports;
213+
}
214+
215+
async getSummary(reportId) {
216+
const { data } = await this.req.getReport(reportId);
217+
const summaryList = data?.relationships?.summaries?.data;
218+
if (!summaryList?.length) return;
219+
const summaries = summaryList.filter((summary) => summary?.attributes?.category === 'team');
220+
if (!summaries?.length) return;
221+
return summaries?.[0].attributes?.content;
103222
}
104223

105-
fillAffectedLines(affectedLines) {
106-
let affected = '';
107-
for (const line of affectedLines) {
108-
affected += ` * ${line} - TBD\n`;
224+
checkoutOnSecurityReleaseBranch(cli) {
225+
this.checkRemote(cli);
226+
const currentBranch = runSync('git', ['branch', '--show-current']).trim();
227+
cli.info(`Current branch: ${currentBranch} `);
228+
229+
if (currentBranch !== this.nextSecurityReleaseBranch) {
230+
runSync('git', ['checkout', '-B', this.nextSecurityReleaseBranch]);
231+
cli.ok(`Checkout on branch: ${this.nextSecurityReleaseBranch} `);
232+
};
233+
}
234+
235+
async createVulnerabilitiesJSON(reports, { cli }) {
236+
cli.separator('Creating vulnerabilities.json...');
237+
const file = JSON.stringify({
238+
reports
239+
}, null, 2);
240+
241+
const folderPath = path.join(process.cwd(), 'security-release', 'next-security-release');
242+
try {
243+
await fs.accessSync(folderPath);
244+
} catch (error) {
245+
await fs.mkdirSync(folderPath, { recursive: true });
109246
}
110-
this.content =
111-
this.content.replace('%AFFECTED_LINES%', affected);
247+
248+
const fullPath = path.join(folderPath, 'vulnerabilities.json');
249+
fs.writeFileSync(fullPath, file);
250+
cli.ok(`Created ${fullPath} `);
251+
252+
return fullPath;
112253
}
113254

114-
fillTargetDate(date) {
115-
this.content = this.content.replace('%RELEASE_DATE%', date);
255+
async createPullRequest(req, { cli }) {
256+
const { owner, repo } = this.repository;
257+
const response = await req.createPullRequest(
258+
this.title,
259+
'List of vulnerabilities to be included in the next security release',
260+
{
261+
owner,
262+
repo,
263+
base: 'main',
264+
head: 'next-security-release'
265+
}
266+
267+
);
268+
const url = response?.html_url;
269+
if (url) {
270+
cli.ok('Created: ' + url);
271+
return url;
272+
} else {
273+
if (response?.errors) {
274+
for (const error of response.errors) {
275+
cli.error(error.message);
276+
}
277+
} else {
278+
cli.error(response);
279+
}
280+
process.exit(1);
281+
}
116282
}
117283
}

lib/request.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,25 @@ export default class Request {
7777
return this.json(url, options);
7878
}
7979

80+
async createPullRequest(title, body, { owner, repo, head, base }) {
81+
const url = `https://api.github.com/repos/${owner}/${repo}/pulls`;
82+
const options = {
83+
method: 'POST',
84+
headers: {
85+
Authorization: `Basic ${this.credentials.github}`,
86+
'User-Agent': 'node-core-utils',
87+
Accept: 'application/vnd.github+json'
88+
},
89+
body: JSON.stringify({
90+
title,
91+
body,
92+
head,
93+
base
94+
})
95+
};
96+
return this.json(url, options);
97+
}
98+
8099
async gql(name, variables, path) {
81100
const query = this.loadQuery(name);
82101
if (path) {
@@ -113,6 +132,19 @@ export default class Request {
113132
return this.json(url, options);
114133
}
115134

135+
async getReport(reportId) {
136+
const url = `https://api.hackerone.com/v1/reports/${reportId}`;
137+
const options = {
138+
method: 'GET',
139+
headers: {
140+
Authorization: `Basic ${this.credentials.h1}`,
141+
'User-Agent': 'node-core-utils',
142+
Accept: 'application/json'
143+
}
144+
};
145+
return this.json(url, options);
146+
}
147+
116148
// This is for github v4 API queries, for other types of queries
117149
// use .text or .json
118150
async query(query, variables) {

0 commit comments

Comments
 (0)