|
| 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'; |
| 8 | +import { parsePRFromURL } from '../../lib/links.js'; |
2 | 9 | import PrepareSecurityRelease from '../../lib/prepare_security.js'; |
3 | 10 | import UpdateSecurityRelease from '../../lib/update_security_release.js'; |
4 | 11 | import SecurityBlog from '../../lib/security_blog.js'; |
5 | 12 | import SecurityAnnouncement from '../../lib/security-announcement.js'; |
| 13 | +import { forceRunAsync } from '../../lib/run.js'; |
6 | 14 |
|
7 | 15 | export const command = 'security [options]'; |
8 | 16 | export const describe = 'Manage an in-progress security release or start a new one.'; |
9 | 17 |
|
| 18 | +const SECURITY_REPO = { |
| 19 | + owner: 'nodejs-private', |
| 20 | + repo: 'node-private', |
| 21 | +}; |
| 22 | + |
10 | 23 | const securityOptions = { |
11 | 24 | start: { |
12 | 25 | describe: 'Start security release process', |
13 | 26 | type: 'boolean' |
14 | 27 | }, |
| 28 | + 'apply-patches': { |
| 29 | + describe: 'Start an interactive session to make local HEAD ready to create ' + |
| 30 | + 'a security release proposal', |
| 31 | + type: 'boolean' |
| 32 | + }, |
15 | 33 | sync: { |
16 | 34 | describe: 'Synchronize an ongoing security release with HackerOne', |
17 | 35 | type: 'boolean' |
@@ -59,6 +77,10 @@ export function builder(yargs) { |
59 | 77 | 'git node security --start', |
60 | 78 | 'Prepare a security release of Node.js' |
61 | 79 | ) |
| 80 | + .example( |
| 81 | + 'git node security --apply-patches', |
| 82 | + 'Fetch all the patches for an upcoming security release' |
| 83 | + ) |
62 | 84 | .example( |
63 | 85 | 'git node security --sync', |
64 | 86 | 'Synchronize an ongoing security release with HackerOne' |
@@ -98,6 +120,9 @@ export function handler(argv) { |
98 | 120 | if (argv.start) { |
99 | 121 | return startSecurityRelease(cli, argv); |
100 | 122 | } |
| 123 | + if (argv['apply-patches']) { |
| 124 | + return applySecurityPatches(cli, argv); |
| 125 | + } |
101 | 126 | if (argv.sync) { |
102 | 127 | return syncSecurityRelease(cli, argv); |
103 | 128 | } |
@@ -168,6 +193,191 @@ async function startSecurityRelease(cli) { |
168 | 193 | return release.start(); |
169 | 194 | } |
170 | 195 |
|
| 196 | +async function fetchVulnerabilitiesDotJSON(cli, req) { |
| 197 | + const { owner } = SECURITY_REPO; |
| 198 | + const repo = 'security-release'; |
| 199 | + |
| 200 | + cli.startSpinner(`Looking for Security Release PR on ${owner}/${repo}`); |
| 201 | + const { repository: { pullRequests: { nodes: { length, 0: pr } } } } = |
| 202 | + await req.gql('ListSecurityReleasePRs', { owner, repo }); |
| 203 | + if (length !== 1) { |
| 204 | + cli.stopSpinner('Expected exactly one open Pull Request on the ' + |
| 205 | + `${owner}/${repo} repository, found ${length}`, |
| 206 | + cli.SPINNER_STATUS.FAILED); |
| 207 | + cli.setExitCode(1); |
| 208 | + return; |
| 209 | + } |
| 210 | + if (pr.files.nodes.length !== 1 || !pr.files.nodes[0].path.endsWith('vulnerabilities.json')) { |
| 211 | + cli.stopSpinner( |
| 212 | + `${owner}/${repo}#${pr.number} does not contain only vulnerabilities.json`, |
| 213 | + cli.SPINNER_STATUS.FAILED |
| 214 | + ); |
| 215 | + cli.setExitCode(1); |
| 216 | + return; |
| 217 | + } |
| 218 | + cli.stopSpinner(`Found ${owner}/${repo}#${pr.number} by @${pr.author.login}`); |
| 219 | + cli.startSpinner('Fetching vulnerabilities.json...'); |
| 220 | + const result = await req.json( |
| 221 | + `/repos/${owner}/${repo}/contents/${pr.files.nodes[0].path}?ref=${pr.headRefOid}`, |
| 222 | + { headers: { Accept: 'application/vnd.github.raw+json' } } |
| 223 | + ); |
| 224 | + cli.stopSpinner('Fetched vulnerabilities.json'); |
| 225 | + return result; |
| 226 | +} |
| 227 | + |
| 228 | +async function skipIfExisting(cli, prURL) { |
| 229 | + const existingCommit = await forceRunAsync('git', |
| 230 | + ['--no-pager', 'log', 'HEAD', '--grep', `^PR-URL: ${prURL}$`, '--format=%h %s'], |
| 231 | + { ignoreFailure: false, captureStdout: true }); |
| 232 | + if (existingCommit.trim()) { |
| 233 | + cli.info(`${prURL} seems to already be on the current tree: ${existingCommit}`); |
| 234 | + return await cli.prompt('Do you want to skip it?', { defaultAnswer: true }); |
| 235 | + } |
| 236 | + return false; |
| 237 | +} |
| 238 | +async function landingSession(cli, req, prURL, argv, cveIds = undefined) { |
| 239 | + const response = await cli.prompt('Do you want to land it on the current HEAD?', |
| 240 | + { defaultAnswer: true }); |
| 241 | + if (!response) { |
| 242 | + cli.info('Skipping'); |
| 243 | + cli.warn('The resulting HEAD will not be ready for a release proposal'); |
| 244 | + return true; |
| 245 | + } |
| 246 | + |
| 247 | + if (!cli.hasDetachedHEAD) { |
| 248 | + // Moving to a detached HEAD, we don't want security patches to be pushed to the public repo. |
| 249 | + await forceRunAsync('git', ['checkout', '--detach'], { ignoreFailure: false }); |
| 250 | + } |
| 251 | + cli.hasDetachedHEAD = (await forceRunAsync( |
| 252 | + 'git', ['rev-parse', 'HEAD'], |
| 253 | + { ignoreFailure: false, captureStdout: true })).trim(); |
| 254 | + |
| 255 | + const session = new LandingSession(cli, req, process.cwd(), { |
| 256 | + autorebase: true, oneCommitMax: false, ...argv |
| 257 | + }); |
| 258 | + Object.defineProperty(session, 'tryResetBranch', { |
| 259 | + __proto__: null, |
| 260 | + value: Function.prototype, |
| 261 | + configurable: true, |
| 262 | + }); |
| 263 | + const metadata = await getMetadata(session.argv, true, cli); |
| 264 | + if (argv?.backport) { |
| 265 | + metadata.metadata += `PR-URL: ${prURL}\n`; |
| 266 | + } |
| 267 | + if (cveIds?.length) { |
| 268 | + metadata.metadata += cveIds.map(cve => `CVE-ID: ${cve}\n`).join(''); |
| 269 | + } |
| 270 | + await session.start(metadata); |
| 271 | + return false; |
| 272 | +} |
| 273 | +async function applySecurityPatches(cli) { |
| 274 | + const { nodeMajorVersion } = await checkCwd({ nodeDir: process.cwd() }); |
| 275 | + const branch = `v${nodeMajorVersion}.x`; |
| 276 | + const credentials = await auth({ |
| 277 | + github: true |
| 278 | + }); |
| 279 | + const req = new Request(credentials); |
| 280 | + |
| 281 | + cli.info('N.B.: if there are commits on the staging branch that need to be included in the ' + |
| 282 | + 'security release, please rebase them manually and answer no to the following question'); |
| 283 | + // Try reset to the public upstream |
| 284 | + const session = new Session(cli, process.cwd(), undefined, { branch }); |
| 285 | + await session.tryResetBranch(); |
| 286 | + |
| 287 | + const { owner, repo } = SECURITY_REPO; |
| 288 | + const { releaseDate, reports, dependencies } = await fetchVulnerabilitiesDotJSON(cli, req); |
| 289 | + |
| 290 | + let patchedVersion; |
| 291 | + for (const { affectedVersions, prURL, title } of Object.values(dependencies).flat()) { |
| 292 | + if (!affectedVersions.includes(`${nodeMajorVersion}.x`)) continue; |
| 293 | + cli.separator(`Taking care of ${title}...`); |
| 294 | + if (await skipIfExisting(cli, prURL)) continue; |
| 295 | + |
| 296 | + const argv = parsePRFromURL(prURL); |
| 297 | + if (argv.owner === SECURITY_REPO.owner && argv.repo === SECURITY_REPO.repo) { |
| 298 | + await landingSession(cli, req, prURL, argv); |
| 299 | + continue; |
| 300 | + } |
| 301 | + |
| 302 | + const existingCommits = (await forceRunAsync('git', |
| 303 | + ['--no-pager', 'log', `${session.upstream}/v${nodeMajorVersion}.x-staging`, |
| 304 | + '--grep', `^PR-URL: ${prURL}$`, |
| 305 | + '--format=%H %h %s'], |
| 306 | + { ignoreFailure: false, captureStdout: true })).trim().split('\n'); |
| 307 | + if (existingCommits[0] === '') { |
| 308 | + cli.error(`${prURL} was not found on ${session.upstream}/v${nodeMajorVersion}.x-staging.`); |
| 309 | + cli.info('Please cherry-pick the adequate commits to the public staging branch.'); |
| 310 | + cli.info('Skipping'); |
| 311 | + cli.warn('The resulting HEAD will not be ready for a release proposal'); |
| 312 | + continue; |
| 313 | + } |
| 314 | + cli.info(`The following commit(s) have been found on ${session.upstream}/v${nodeMajorVersion |
| 315 | + }.x-staging:\n${existingCommits.map(c => ` - ${c.slice(41)}`).join('\n')}`); |
| 316 | + if (!await cli.prompt('Cherry-pick those for the current proposal?')) { |
| 317 | + cli.info('Skipping'); |
| 318 | + cli.warn('The resulting HEAD will not be ready for a release proposal'); |
| 319 | + continue; |
| 320 | + } |
| 321 | + await forceRunAsync('git', |
| 322 | + ['cherry-pick', ...existingCommits.map(c => c.slice(0, 40))], |
| 323 | + { ignoreFailure: false }); |
| 324 | + } |
| 325 | + |
| 326 | + cli.startSpinner(`Fetching open PRs on ${owner}/${repo}...`); |
| 327 | + const { repository: { pullRequests: { nodes } } } = await req.gql('PRs', { |
| 328 | + owner, repo, labels: [branch], |
| 329 | + }); |
| 330 | + cli.stopSpinner(`Fetched all PRs labeled for v${nodeMajorVersion}.x`); |
| 331 | + |
| 332 | + for (const { affectedVersions, prURL, cveIds, patchedVersions } of reports) { |
| 333 | + if (!affectedVersions.includes(`${nodeMajorVersion}.x`)) continue; |
| 334 | + patchedVersion ??= patchedVersions?.find(v => v.startsWith(`${nodeMajorVersion}.`)); |
| 335 | + cli.separator(`Taking care of ${cveIds.join(', ')}...`); |
| 336 | + |
| 337 | + if (await skipIfExisting(cli, prURL)) continue; |
| 338 | + |
| 339 | + let pr = nodes.find(({ url }) => url === prURL); |
| 340 | + if (!pr) { |
| 341 | + cli.info( |
| 342 | + `${prURL} is not labelled for v${nodeMajorVersion}.x, there might be a backport PR.` |
| 343 | + ); |
| 344 | + |
| 345 | + cli.startSpinner('Fetching PR title to find a match...'); |
| 346 | + const { title } = await req.getPullRequest(prURL); |
| 347 | + pr = nodes.find((pr) => pr.title.endsWith(title)); |
| 348 | + if (pr) { |
| 349 | + cli.stopSpinner(`Found ${pr.url}`); |
| 350 | + } else { |
| 351 | + cli.stopSpinner(`Did not find a match for "${title}"`, cli.SPINNER_STATUS.WARN); |
| 352 | + const prID = await cli.prompt( |
| 353 | + 'Please enter the PR number to use:', |
| 354 | + { questionType: cli.QUESTION_TYPE.NUMBER, defaultAnswer: NaN } |
| 355 | + ); |
| 356 | + pr = nodes.find(({ number }) => number === prID); |
| 357 | + if (!pr) { |
| 358 | + cli.error(`${prID} is not in the list of PRs labelled for v${nodeMajorVersion}.x`); |
| 359 | + cli.info('The list of labelled PRs and vulnerabilities.json are fetched ' + |
| 360 | + 'once at the start of the session; to refresh those, start a new NCU session'); |
| 361 | + const response = await cli.prompt('Do you want to skip that CVE?', |
| 362 | + { defaultAnswer: false }); |
| 363 | + if (response) continue; |
| 364 | + throw new Error(`Found no patch for ${cveIds}`); |
| 365 | + } |
| 366 | + } |
| 367 | + } |
| 368 | + cli.ok(`${pr.url} is labelled for v${nodeMajorVersion}.x.`); |
| 369 | + |
| 370 | + const backport = prURL !== pr.url; |
| 371 | + |
| 372 | + await landingSession(cli, req, prURL, { prid: pr.number, backport, ...SECURITY_REPO }, cveIds); |
| 373 | + } |
| 374 | + cli.ok('All patches are on the local HEAD!'); |
| 375 | + cli.info('You can now build and test, and create a proposal with the following commands:'); |
| 376 | + cli.info(`git switch -C v${nodeMajorVersion}.x HEAD`); |
| 377 | + cli.info(`git node release --prepare --security --newVersion=${patchedVersion} ` + |
| 378 | + `--releaseDate=${releaseDate.replaceAll('/', '-')} --skipBranchDiff`); |
| 379 | +} |
| 380 | + |
171 | 381 | async function cleanupSecurityRelease(cli) { |
172 | 382 | const release = new PrepareSecurityRelease(cli); |
173 | 383 | return release.cleanup(); |
|
0 commit comments