Skip to content

Commit 692434b

Browse files
committed
feat(git-node): add git node security --prepare-local-branch
1 parent 3a427ee commit 692434b

File tree

3 files changed

+178
-0
lines changed

3 files changed

+178
-0
lines changed

components/git/security.js

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,34 @@
1+
import auth from '../../lib/auth.js';
2+
import Request from '../../lib/request.js';
3+
import LandingSession from '../../lib/landing_session.js';
4+
import Session from '../../lib/session.js';
15
import CLI from '../../lib/cli.js';
6+
import { getMetadata } from '../metadata.js';
7+
import { checkCwd } from '../../lib/update-v8/common.js';
28
import PrepareSecurityRelease from '../../lib/prepare_security.js';
39
import UpdateSecurityRelease from '../../lib/update_security_release.js';
410
import SecurityBlog from '../../lib/security_blog.js';
511
import SecurityAnnouncement from '../../lib/security-announcement.js';
12+
import { forceRunAsync } from '../../lib/run.js';
613

714
export const command = 'security [options]';
815
export const describe = 'Manage an in-progress security release or start a new one.';
916

17+
const SECURITY_REPO = {
18+
owner: 'nodejs-private',
19+
repo: 'node-private',
20+
};
21+
1022
const securityOptions = {
1123
start: {
1224
describe: 'Start security release process',
1325
type: 'boolean'
1426
},
27+
'apply-patches': {
28+
describe: 'Start an interactive session to make local HEAD ready to create ' +
29+
'a security release proposal',
30+
type: 'boolean'
31+
},
1532
sync: {
1633
describe: 'Synchronize an ongoing security release with HackerOne',
1734
type: 'boolean'
@@ -59,6 +76,10 @@ export function builder(yargs) {
5976
'git node security --start',
6077
'Prepare a security release of Node.js'
6178
)
79+
.example(
80+
'git node security --prepare-local-branch',
81+
'Fetch all the patches for an upcoming security release'
82+
)
6283
.example(
6384
'git node security --sync',
6485
'Synchronize an ongoing security release with HackerOne'
@@ -98,6 +119,9 @@ export function handler(argv) {
98119
if (argv.start) {
99120
return startSecurityRelease(cli, argv);
100121
}
122+
if (argv['apply-patches']) {
123+
return applySecurityPatches(cli, argv);
124+
}
101125
if (argv.sync) {
102126
return syncSecurityRelease(cli, argv);
103127
}
@@ -168,6 +192,140 @@ async function startSecurityRelease(cli) {
168192
return release.start();
169193
}
170194

195+
async function fetchVulnerabilitiesDotJSON(cli, req) {
196+
const { owner } = SECURITY_REPO;
197+
const repo = 'security-release';
198+
199+
cli.startSpinner(`Looking for Security Release PR on ${owner}/${repo}`);
200+
const { repository: { pullRequests: { nodes: { length, 0: pr } } } } =
201+
await req.gql('ListSecurityReleasePRs', { owner, repo });
202+
if (length !== 1) {
203+
cli.stopSpinner('Expected exactly one open Pull Request on the ' +
204+
`${owner}/${repo} repository, found ${length}`,
205+
cli.SPINNER_STATUS.FAILED);
206+
cli.setExitCode(1);
207+
return;
208+
}
209+
if (pr.files.nodes.length !== 1 || !pr.files.nodes[0].path.endsWith('vulnerabilities.json')) {
210+
cli.stopSpinner(
211+
`${owner}/${repo}#${pr.number} does not contain only vulnerabilities.json`,
212+
cli.SPINNER_STATUS.FAILED
213+
);
214+
cli.setExitCode(1);
215+
return;
216+
}
217+
cli.stopSpinner(`Found ${owner}/${repo}#${pr.number} by @${pr.author.login}`);
218+
cli.startSpinner('Fetching vulnerabilities.json...');
219+
const result = await req.json(
220+
`/repos/${owner}/${repo}/contents/${pr.files.nodes[0].path}?ref=${pr.headRefOid}`,
221+
{ headers: { Accept: 'application/vnd.github.raw+json' } }
222+
);
223+
cli.stopSpinner('Fetched vulnerabilities.json');
224+
return result;
225+
}
226+
async function applySecurityPatches(cli) {
227+
const { nodeMajorVersion } = await checkCwd({ nodeDir: process.cwd() });
228+
const credentials = await auth({
229+
github: true
230+
});
231+
const req = new Request(credentials);
232+
233+
cli.info('N.B.: if there are commits on the staging branch that need to be included in the ' +
234+
'security release, please rebase them manually and answer no to the following question');
235+
// Try reset to the public upstream
236+
await new Session(cli, process.cwd()).tryResetBranch();
237+
238+
const { owner, repo } = SECURITY_REPO;
239+
const { releaseDate, reports } = await fetchVulnerabilitiesDotJSON(cli, req);
240+
cli.startSpinner(`Fetching open PRs on ${owner}/${repo}...`);
241+
const { repository: { pullRequests: { nodes } } } = await req.gql('PRs', {
242+
owner, repo, labels: [`v${nodeMajorVersion}.x`],
243+
});
244+
cli.stopSpinner(`Fetched all PRs labeled for v${nodeMajorVersion}.x`);
245+
let patchedVersion;
246+
let hasDetachedHEAD = false;
247+
for (const { affectedVersions, prURL, cveIds, patchedVersions } of reports) {
248+
if (!affectedVersions.includes(`${nodeMajorVersion}.x`)) continue;
249+
patchedVersion ??= patchedVersions?.find(v => v.startsWith(`${nodeMajorVersion}.`));
250+
cli.separator(`Taking care of ${cveIds.join(', ')}...`);
251+
252+
const existingCommit = await forceRunAsync('git',
253+
['--no-pager', 'log', 'HEAD', '--grep', `^PR-URL: ${prURL}$`, '--format=%h %s'],
254+
{ ignoreFailure: false, captureStdout: true });
255+
if (existingCommit.trim()) {
256+
cli.info(`${prURL} seems to already be on the current tree: ${existingCommit}`);
257+
const response = await cli.prompt('Do you want to skip it?', { defaultAnswer: true });
258+
if (response) continue;
259+
}
260+
261+
let pr = nodes.find(({ url }) => url === prURL);
262+
if (!pr) {
263+
cli.info(
264+
`${prURL} is not labelled for v${nodeMajorVersion}.x, there might be a backport PR.`
265+
);
266+
267+
cli.startSpinner('Fetching PR title to find a match...');
268+
const { title } = await req.getPullRequest(prURL);
269+
pr = nodes.find((pr) => pr.title.endsWith(title));
270+
if (pr) {
271+
cli.stopSpinner(`Found ${pr.url}`);
272+
} else {
273+
cli.stopSpinner(`Did not find a match for "${title}"`, cli.SPINNER_STATUS.WARN);
274+
const prID = await cli.prompt(
275+
'Please enter the PR number to use:',
276+
{ questionType: cli.QUESTION_TYPE.NUMBER, defaultAnswer: NaN }
277+
);
278+
pr = nodes.find(({ number }) => number === prID);
279+
if (!pr) {
280+
cli.error(`${prID} is not in the list of PRs labelled for v${nodeMajorVersion}.x`);
281+
cli.info('The list of labelled PRs and vulnerabilities.json are fetched ' +
282+
'once at the start of the session; to refresh those, start a new NCU session');
283+
const response = await cli.prompt('Do you want to skip that CVE?',
284+
{ defaultAnswer: false });
285+
if (response) continue;
286+
throw new Error(`Found no patch for ${cveIds}`);
287+
}
288+
}
289+
}
290+
cli.ok(`${pr.url} is labelled for v${nodeMajorVersion}.x.`);
291+
const response = await cli.prompt('Do you want to land it on the current HEAD?',
292+
{ defaultAnswer: true });
293+
if (!response) {
294+
cli.info('Skipping');
295+
cli.warn('The resulting HEAD will not be ready for a release proposal');
296+
continue;
297+
}
298+
const backport = prURL !== pr.url;
299+
300+
if (!hasDetachedHEAD) {
301+
// Moving to a detached HEAD, we don't want the security patches to be pushed to the public repo
302+
await forceRunAsync('git', ['checkout', '--detach'], { ignoreFailure: false });
303+
hasDetachedHEAD = true;
304+
}
305+
306+
const session = new LandingSession(cli, req, process.cwd(), {
307+
prid: pr.number, backport, autorebase: true, oneCommitMax: false,
308+
...SECURITY_REPO
309+
});
310+
Object.defineProperty(session, 'tryResetBranch', {
311+
__proto__: null,
312+
value: Function.prototype,
313+
configurable: true,
314+
});
315+
const metadata = await getMetadata(session.argv, true, cli);
316+
if (backport) {
317+
metadata.metadata += `PR-URL: ${prURL}\n`;
318+
}
319+
metadata.metadata += cveIds.map(cve => `CVE-ID: ${cve}\n`).join('');
320+
await session.start(metadata);
321+
}
322+
cli.ok('All patches are on the local HEAD!');
323+
cli.info('You can now build and test, and create a proposal with the following commands:');
324+
cli.info(`git switch -C v${nodeMajorVersion}.x HEAD`);
325+
cli.info(`git node release --prepare --security --newVersion=${patchedVersion} ` +
326+
`--releaseDate=${releaseDate.replaceAll('/', '-')} --skipBranchDiff`);
327+
}
328+
171329
async function cleanupSecurityRelease(cli) {
172330
const release = new PrepareSecurityRelease(cli);
173331
return release.cleanup();
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
query PR($owner: String!, $repo: String!) {
2+
repository(owner: $owner, name: $repo) {
3+
pullRequests(states: OPEN, first: 2, orderBy: {field: CREATED_AT, direction: DESC}) {
4+
nodes {
5+
number
6+
headRefOid
7+
author {
8+
login
9+
}
10+
11+
files(first: 2) {
12+
nodes {
13+
path
14+
}
15+
}
16+
}
17+
}
18+
}
19+
}

lib/update-v8/common.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,5 @@ export async function checkCwd(ctx) {
3232
`node-dir: ${ctx.nodeDir}`
3333
);
3434
}
35+
return ctx;
3536
};

0 commit comments

Comments
 (0)