current commit
260723.2 · feat(git-node): select security release PRs from affectedVersions (#1124)
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 { parsePRFromURL } from '../../lib/links.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';
import { getAffectedVersionLines } from '../../lib/security-release/security-release.js';
import { getPullRequestURLForLine } from '../../lib/prepare_release.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'
  },
  'update-date': {
    describe: 'Updates the target date of the security release',
    type: 'string'
  },
  'add-report': {
    describe: 'Extracts data from HackerOne report and adds it into vulnerabilities.json',
    type: 'string'
  },
  'remove-report': {
    describe: 'Removes a report from vulnerabilities.json',
    type: 'string'
  },
  'pre-release': {
    describe: 'Create the pre-release announcement to the given nodejs.org folder',
    type: 'string'
  },
  'notify-pre-release': {
    describe: 'Notify the community about the security release',
    type: 'boolean'
  },
  'request-cve': {
    describe: 'Request CVEs for a security release',
    type: 'boolean'
  },
  'post-release': {
    describe: 'Create the post-release announcement to the given nodejs.org folder',
    type: 'string'
  },
  cleanup: {
    describe: 'cleanup the security release.',
    type: 'boolean'
  }
};

let yargsInstance;

function isPromptInterrupted(error) {
  return error?.name === 'ExitPromptError' ||
    error?.name === 'AbortPromptError' ||
    error?.message?.includes('User force closed');
}

function runSecurityAction(cli, action) {
  return Promise.resolve(action).catch((error) => {
    if (!isPromptInterrupted(error)) {
      throw error;
    }

    if (cli.spinner.isSpinning) {
      cli.spinner.stop();
    }
    cli.warn('Aborted.');
    cli.setExitCode(1);
  });
}

export function builder(yargs) {
  yargsInstance = yargs;
  return yargs.options(securityOptions)
    .example(
      'git node security --start',
      'Prepare a security release of Node.js'
    )
    .example(
      'git node security --apply-patches',
      'Fetch all the patches for an upcoming security release'
    )
    .example(
      'git node security --sync',
      'Synchronize an ongoing security release with HackerOne'
    )
    .example(
      'git node security --update-date=YYYY/MM/DD',
      'Updates the target date of the security release'
    ).example(
      'git node security --add-report=H1-ID',
      'Fetches HackerOne report based on ID provided and adds it into vulnerabilities.json'
    ).example(
      'git node security --remove-report=H1-ID',
      'Removes the Hackerone report based on ID provided from vulnerabilities.json'
    ).example(
      'git node security --pre-release="../nodejs.org/"',
      'Create the pre-release announcement on the Nodejs.org repo'
    ).example(
      'git node security --notify-pre-release',
      'Notifies the community about the security release'
    ).example(
      'git node security --request-cve',
      'Request CVEs for a security release of Node.js based on' +
      ' the next-security-release/vulnerabilities.json'
    ).example(
      'git node security --post-release="../nodejs.org/"',
      'Create the post-release announcement on the Nodejs.org repo'
    ).example(
      'git node security --cleanup',
      'Cleanup the security release. Merge the PR and close H1 reports'
    );
}

export function handler(argv) {
  const logStream = process.stdout.isTTY ? process.stdout : process.stderr;
  const cli = new CLI(logStream);
  let action;

  if (argv.start) {
    action = startSecurityRelease(cli, argv);
  } else if (argv['apply-patches']) {
    action = applySecurityPatches(cli, argv);
  } else if (argv.sync) {
    action = syncSecurityRelease(cli, argv);
  } else if (argv['update-date']) {
    action = updateReleaseDate(cli, argv);
  } else if (argv['pre-release']) {
    action = createPreRelease(cli, argv);
  } else if (argv['add-report']) {
    action = addReport(cli, argv);
  } else if (argv['remove-report']) {
    action = removeReport(cli, argv);
  } else if (argv['notify-pre-release']) {
    action = notifyPreRelease(cli, argv);
  } else if (argv['request-cve']) {
    action = requestCVEs(cli, argv);
  } else if (argv['post-release']) {
    action = createPostRelease(cli, argv);
  } else if (argv.cleanup) {
    action = cleanupSecurityRelease(cli, argv);
  }

  if (action) {
    return runSecurityAction(cli, action);
  }

  yargsInstance.showHelp();
}

async function removeReport(cli, argv) {
  const reportId = argv['remove-report'];
  const update = new UpdateSecurityRelease(cli);
  return update.removeReport(reportId);
}

async function addReport(cli, argv) {
  const reportId = argv['add-report'];
  const update = new UpdateSecurityRelease(cli);
  return update.addReport(reportId);
}

async function updateReleaseDate(cli, argv) {
  const releaseDate = argv['update-date'];
  const update = new UpdateSecurityRelease(cli);
  return update.updateReleaseDate(releaseDate);
}

async function createPreRelease(cli, argv) {
  const nodejsOrgFolder = argv['pre-release'];
  const preRelease = new SecurityBlog(cli);
  return preRelease.createPreRelease(nodejsOrgFolder);
}

async function requestCVEs(cli) {
  const hackerOneCve = new UpdateSecurityRelease(cli);
  return hackerOneCve.requestCVEs();
}

async function createPostRelease(cli, argv) {
  const nodejsOrgFolder = argv['post-release'];
  const blog = new SecurityBlog(cli);
  return blog.createPostRelease(nodejsOrgFolder);
}

async function startSecurityRelease(cli) {
  const release = new PrepareSecurityRelease(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 skipIfExisting(cli, prURL) {
  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}`);
    return await cli.prompt('Do you want to skip it?', { defaultAnswer: true });
  }
  return false;
}
async function landingSession(cli, req, prURL, argv, cveIds = undefined) {
  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');
    return true;
  }

  if (!cli.hasDetachedHEAD) {
    // Moving to a detached HEAD, we don't want security patches to be pushed to the public repo.
    await forceRunAsync('git', ['checkout', '--detach'], { ignoreFailure: false });
  }
  cli.hasDetachedHEAD = (await forceRunAsync(
    'git', ['rev-parse', 'HEAD'],
    { ignoreFailure: false, captureStdout: true })).trim();

  const session = new LandingSession(cli, req, process.cwd(), {
    autorebase: true, oneCommitMax: false, ...argv
  });
  Object.defineProperty(session, 'tryResetBranch', {
    __proto__: null,
    value: Function.prototype,
    configurable: true,
  });
  const metadata = await getMetadata(session.argv, true, cli);
  if (argv?.backport) {
    metadata.metadata += `PR-URL: ${prURL}\n`;
  }
  if (cveIds?.length) {
    metadata.metadata += cveIds.map(cve => `CVE-ID: ${cve}\n`).join('');
  }
  await session.start(metadata);
  return false;
}
async function applySecurityPatches(cli) {
  const { nodeMajorVersion } = await checkCwd({ nodeDir: process.cwd() });
  const branch = `v${nodeMajorVersion}.x`;
  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
  const session = new Session(cli, process.cwd(), undefined, { branch });
  await session.tryResetBranch();

  const { owner, repo } = SECURITY_REPO;
  const { releaseDate, reports, dependencies } = await fetchVulnerabilitiesDotJSON(cli, req);

  let patchedVersion;
  const releaseLine = `${nodeMajorVersion}.x`;
  for (const [name, dep] of Object.entries(dependencies ?? {})) {
    const prURL = getPullRequestURLForLine(
      dep.affectedVersions, releaseLine, dep.prURL);
    if (!prURL) continue;
    cli.separator(`Taking care of ${name}...`);
    if (await skipIfExisting(cli, prURL)) continue;

    const argv = parsePRFromURL(prURL);
    if (argv.owner === SECURITY_REPO.owner && argv.repo === SECURITY_REPO.repo) {
      await landingSession(cli, req, prURL, argv);
      continue;
    }

    const existingCommits = (await forceRunAsync('git',
      ['--no-pager', 'log', `${session.upstream}/v${nodeMajorVersion}.x-staging`,
        '--grep', `^PR-URL: ${prURL}$`,
        '--format=%H %h %s'],
      { ignoreFailure: false, captureStdout: true })).trim().split('\n');
    if (existingCommits[0] === '') {
      cli.error(`${prURL} was not found on ${session.upstream}/v${nodeMajorVersion}.x-staging.`);
      cli.info('Please cherry-pick the adequate commits to the public staging branch.');
      cli.info('Skipping');
      cli.warn('The resulting HEAD will not be ready for a release proposal');
      continue;
    }
    cli.info(`The following commit(s) have been found on ${session.upstream}/v${nodeMajorVersion
      }.x-staging:\n${existingCommits.map(c => ` - ${c.slice(41)}`).join('\n')}`);
    if (!await cli.prompt('Cherry-pick those for the current proposal?')) {
      cli.info('Skipping');
      cli.warn('The resulting HEAD will not be ready for a release proposal');
      continue;
    }
    await forceRunAsync('git',
      ['cherry-pick', ...existingCommits.map(c => c.slice(0, 40))],
      { ignoreFailure: false });
  }

  cli.startSpinner(`Fetching open PRs on ${owner}/${repo}...`);
  const { repository: { pullRequests: { nodes } } } = await req.gql('PRs', {
    owner, repo, labels: [branch],
  });
  cli.stopSpinner(`Fetched all PRs labeled for v${nodeMajorVersion}.x`);

  for (const { affectedVersions, prURL, cveIds, patchedVersions } of reports) {
    if (!getAffectedVersionLines(affectedVersions).includes(`${nodeMajorVersion}.x`)) continue;
    patchedVersion ??= patchedVersions?.find(v => v.startsWith(`${nodeMajorVersion}.`));
    cli.separator(`Taking care of ${cveIds.join(', ')}...`);

    if (await skipIfExisting(cli, prURL)) 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 backport = prURL !== pr.url;

    await landingSession(cli, req, prURL, { prid: pr.number, backport, ...SECURITY_REPO }, cveIds);
  }
  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();
}

async function syncSecurityRelease(cli) {
  const release = new UpdateSecurityRelease(cli);
  return release.sync();
}

async function notifyPreRelease(cli) {
  const preRelease = new SecurityAnnouncement(cli);
  return preRelease.notifyPreRelease();
}