|
| 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'; |
1 | 5 | import CLI from '../../lib/cli.js'; |
| 6 | +import { getMetadata } from '../metadata.js'; |
| 7 | +import { checkCwd } from '../../lib/update-v8/common.js'; |
2 | 8 | import PrepareSecurityRelease from '../../lib/prepare_security.js'; |
3 | 9 | import UpdateSecurityRelease from '../../lib/update_security_release.js'; |
4 | 10 | import SecurityBlog from '../../lib/security_blog.js'; |
5 | 11 | import SecurityAnnouncement from '../../lib/security-announcement.js'; |
| 12 | +import { forceRunAsync } from '../../lib/run.js'; |
6 | 13 |
|
7 | 14 | export const command = 'security [options]'; |
8 | 15 | export const describe = 'Manage an in-progress security release or start a new one.'; |
9 | 16 |
|
| 17 | +const SECURITY_REPO = { |
| 18 | + owner: 'nodejs-private', |
| 19 | + repo: 'node-private', |
| 20 | +}; |
| 21 | + |
10 | 22 | const securityOptions = { |
11 | 23 | start: { |
12 | 24 | describe: 'Start security release process', |
13 | 25 | type: 'boolean' |
14 | 26 | }, |
| 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 | + }, |
15 | 32 | sync: { |
16 | 33 | describe: 'Synchronize an ongoing security release with HackerOne', |
17 | 34 | type: 'boolean' |
@@ -59,6 +76,10 @@ export function builder(yargs) { |
59 | 76 | 'git node security --start', |
60 | 77 | 'Prepare a security release of Node.js' |
61 | 78 | ) |
| 79 | + .example( |
| 80 | + 'git node security --prepare-local-branch', |
| 81 | + 'Fetch all the patches for an upcoming security release' |
| 82 | + ) |
62 | 83 | .example( |
63 | 84 | 'git node security --sync', |
64 | 85 | 'Synchronize an ongoing security release with HackerOne' |
@@ -98,6 +119,9 @@ export function handler(argv) { |
98 | 119 | if (argv.start) { |
99 | 120 | return startSecurityRelease(cli, argv); |
100 | 121 | } |
| 122 | + if (argv['apply-patches']) { |
| 123 | + return applySecurityPatches(cli, argv); |
| 124 | + } |
101 | 125 | if (argv.sync) { |
102 | 126 | return syncSecurityRelease(cli, argv); |
103 | 127 | } |
@@ -168,6 +192,140 @@ async function startSecurityRelease(cli) { |
168 | 192 | return release.start(); |
169 | 193 | } |
170 | 194 |
|
| 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 | + |
171 | 329 | async function cleanupSecurityRelease(cli) { |
172 | 330 | const release = new PrepareSecurityRelease(cli); |
173 | 331 | return release.cleanup(); |
|
0 commit comments