From 692434b6189964f2b22a304b69263bc08b55dad9 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Sun, 22 Mar 2026 16:43:08 +0100 Subject: [PATCH] feat(git-node): add `git node security --prepare-local-branch` --- components/git/security.js | 158 +++++++++++++++++++++++++ lib/queries/ListSecurityReleasePRs.gql | 19 +++ lib/update-v8/common.js | 1 + 3 files changed, 178 insertions(+) create mode 100644 lib/queries/ListSecurityReleasePRs.gql diff --git a/components/git/security.js b/components/git/security.js index a73b3d6a..d6f4c474 100644 --- a/components/git/security.js +++ b/components/git/security.js @@ -1,17 +1,34 @@ +import auth from '../../lib/auth.js'; +import Request from '../../lib/request.js'; +import LandingSession from '../../lib/landing_session.js'; +import Session from '../../lib/session.js'; import CLI from '../../lib/cli.js'; +import { getMetadata } from '../metadata.js'; +import { checkCwd } from '../../lib/update-v8/common.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'; +import { forceRunAsync } from '../../lib/run.js'; export const command = 'security [options]'; export const describe = 'Manage an in-progress security release or start a new one.'; +const SECURITY_REPO = { + owner: 'nodejs-private', + repo: 'node-private', +}; + const securityOptions = { start: { describe: 'Start security release process', type: 'boolean' }, + 'apply-patches': { + describe: 'Start an interactive session to make local HEAD ready to create ' + + 'a security release proposal', + type: 'boolean' + }, sync: { describe: 'Synchronize an ongoing security release with HackerOne', type: 'boolean' @@ -59,6 +76,10 @@ export function builder(yargs) { 'git node security --start', 'Prepare a security release of Node.js' ) + .example( + 'git node security --prepare-local-branch', + 'Fetch all the patches for an upcoming security release' + ) .example( 'git node security --sync', 'Synchronize an ongoing security release with HackerOne' @@ -98,6 +119,9 @@ export function handler(argv) { if (argv.start) { return startSecurityRelease(cli, argv); } + if (argv['apply-patches']) { + return applySecurityPatches(cli, argv); + } if (argv.sync) { return syncSecurityRelease(cli, argv); } @@ -168,6 +192,140 @@ async function startSecurityRelease(cli) { return release.start(); } +async function fetchVulnerabilitiesDotJSON(cli, req) { + const { owner } = SECURITY_REPO; + const repo = 'security-release'; + + cli.startSpinner(`Looking for Security Release PR on ${owner}/${repo}`); + const { repository: { pullRequests: { nodes: { length, 0: pr } } } } = + await req.gql('ListSecurityReleasePRs', { owner, repo }); + if (length !== 1) { + cli.stopSpinner('Expected exactly one open Pull Request on the ' + + `${owner}/${repo} repository, found ${length}`, + cli.SPINNER_STATUS.FAILED); + cli.setExitCode(1); + return; + } + if (pr.files.nodes.length !== 1 || !pr.files.nodes[0].path.endsWith('vulnerabilities.json')) { + cli.stopSpinner( + `${owner}/${repo}#${pr.number} does not contain only vulnerabilities.json`, + cli.SPINNER_STATUS.FAILED + ); + cli.setExitCode(1); + return; + } + cli.stopSpinner(`Found ${owner}/${repo}#${pr.number} by @${pr.author.login}`); + cli.startSpinner('Fetching vulnerabilities.json...'); + const result = await req.json( + `/repos/${owner}/${repo}/contents/${pr.files.nodes[0].path}?ref=${pr.headRefOid}`, + { headers: { Accept: 'application/vnd.github.raw+json' } } + ); + cli.stopSpinner('Fetched vulnerabilities.json'); + return result; +} +async function applySecurityPatches(cli) { + const { nodeMajorVersion } = await checkCwd({ nodeDir: process.cwd() }); + const credentials = await auth({ + github: true + }); + const req = new Request(credentials); + + cli.info('N.B.: if there are commits on the staging branch that need to be included in the ' + + 'security release, please rebase them manually and answer no to the following question'); + // Try reset to the public upstream + await new Session(cli, process.cwd()).tryResetBranch(); + + const { owner, repo } = SECURITY_REPO; + const { releaseDate, reports } = await fetchVulnerabilitiesDotJSON(cli, req); + cli.startSpinner(`Fetching open PRs on ${owner}/${repo}...`); + const { repository: { pullRequests: { nodes } } } = await req.gql('PRs', { + owner, repo, labels: [`v${nodeMajorVersion}.x`], + }); + cli.stopSpinner(`Fetched all PRs labeled for v${nodeMajorVersion}.x`); + let patchedVersion; + let hasDetachedHEAD = false; + for (const { affectedVersions, prURL, cveIds, patchedVersions } of reports) { + if (!affectedVersions.includes(`${nodeMajorVersion}.x`)) continue; + patchedVersion ??= patchedVersions?.find(v => v.startsWith(`${nodeMajorVersion}.`)); + cli.separator(`Taking care of ${cveIds.join(', ')}...`); + + const existingCommit = await forceRunAsync('git', + ['--no-pager', 'log', 'HEAD', '--grep', `^PR-URL: ${prURL}$`, '--format=%h %s'], + { ignoreFailure: false, captureStdout: true }); + if (existingCommit.trim()) { + cli.info(`${prURL} seems to already be on the current tree: ${existingCommit}`); + const response = await cli.prompt('Do you want to skip it?', { defaultAnswer: true }); + if (response) continue; + } + + let pr = nodes.find(({ url }) => url === prURL); + if (!pr) { + cli.info( + `${prURL} is not labelled for v${nodeMajorVersion}.x, there might be a backport PR.` + ); + + cli.startSpinner('Fetching PR title to find a match...'); + const { title } = await req.getPullRequest(prURL); + pr = nodes.find((pr) => pr.title.endsWith(title)); + if (pr) { + cli.stopSpinner(`Found ${pr.url}`); + } else { + cli.stopSpinner(`Did not find a match for "${title}"`, cli.SPINNER_STATUS.WARN); + const prID = await cli.prompt( + 'Please enter the PR number to use:', + { questionType: cli.QUESTION_TYPE.NUMBER, defaultAnswer: NaN } + ); + pr = nodes.find(({ number }) => number === prID); + if (!pr) { + cli.error(`${prID} is not in the list of PRs labelled for v${nodeMajorVersion}.x`); + cli.info('The list of labelled PRs and vulnerabilities.json are fetched ' + + 'once at the start of the session; to refresh those, start a new NCU session'); + const response = await cli.prompt('Do you want to skip that CVE?', + { defaultAnswer: false }); + if (response) continue; + throw new Error(`Found no patch for ${cveIds}`); + } + } + } + cli.ok(`${pr.url} is labelled for v${nodeMajorVersion}.x.`); + const response = await cli.prompt('Do you want to land it on the current HEAD?', + { defaultAnswer: true }); + if (!response) { + cli.info('Skipping'); + cli.warn('The resulting HEAD will not be ready for a release proposal'); + continue; + } + const backport = prURL !== pr.url; + + if (!hasDetachedHEAD) { + // Moving to a detached HEAD, we don't want the security patches to be pushed to the public repo + await forceRunAsync('git', ['checkout', '--detach'], { ignoreFailure: false }); + hasDetachedHEAD = true; + } + + const session = new LandingSession(cli, req, process.cwd(), { + prid: pr.number, backport, autorebase: true, oneCommitMax: false, + ...SECURITY_REPO + }); + Object.defineProperty(session, 'tryResetBranch', { + __proto__: null, + value: Function.prototype, + configurable: true, + }); + const metadata = await getMetadata(session.argv, true, cli); + if (backport) { + metadata.metadata += `PR-URL: ${prURL}\n`; + } + metadata.metadata += cveIds.map(cve => `CVE-ID: ${cve}\n`).join(''); + await session.start(metadata); + } + cli.ok('All patches are on the local HEAD!'); + cli.info('You can now build and test, and create a proposal with the following commands:'); + cli.info(`git switch -C v${nodeMajorVersion}.x HEAD`); + cli.info(`git node release --prepare --security --newVersion=${patchedVersion} ` + + `--releaseDate=${releaseDate.replaceAll('/', '-')} --skipBranchDiff`); +} + async function cleanupSecurityRelease(cli) { const release = new PrepareSecurityRelease(cli); return release.cleanup(); diff --git a/lib/queries/ListSecurityReleasePRs.gql b/lib/queries/ListSecurityReleasePRs.gql new file mode 100644 index 00000000..93cc8c75 --- /dev/null +++ b/lib/queries/ListSecurityReleasePRs.gql @@ -0,0 +1,19 @@ +query PR($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + pullRequests(states: OPEN, first: 2, orderBy: {field: CREATED_AT, direction: DESC}) { + nodes { + number + headRefOid + author { + login + } + + files(first: 2) { + nodes { + path + } + } + } + } + } +} diff --git a/lib/update-v8/common.js b/lib/update-v8/common.js index ca421b3c..22213494 100644 --- a/lib/update-v8/common.js +++ b/lib/update-v8/common.js @@ -32,4 +32,5 @@ export async function checkCwd(ctx) { `node-dir: ${ctx.nodeDir}` ); } + return ctx; };