Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 210 additions & 0 deletions components/git/security.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,35 @@
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';

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'
Expand Down Expand Up @@ -59,6 +77,10 @@ export function builder(yargs) {
'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'
Expand Down Expand Up @@ -98,6 +120,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);
}
Expand Down Expand Up @@ -168,6 +193,191 @@ 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 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;
for (const { affectedVersions, prURL, title } of Object.values(dependencies).flat()) {
if (!affectedVersions.includes(`${nodeMajorVersion}.x`)) continue;
cli.separator(`Taking care of ${title}...`);
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 (!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();
Expand Down
8 changes: 7 additions & 1 deletion docs/git-node.md
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,7 @@ Manage or starts a security release process.
It's necessary to set up `.ncurc` with HackerOne keys:

```console
$ ncu-config --global set h1_token $H1_TOKEN
$ ncu-config --global set -x h1_token
$ ncu-config --global set h1_username $H1_TOKEN
```

Expand All @@ -461,6 +461,12 @@ This command creates the Next Security Issue in Node.js private repository
following the [Security Release Process][] document.
It will retrieve all the triaged HackerOne reports and add creates the `vulnerabilities.json`.

### `git node security --apply-patches`

This command fetches the list of reports and the list of PRs labelled for the
release corresponding to the CWD, and match them in pair, and run a
`git node land` session for each.

### `git node security --update-date=YYYY/MM/DD`

This command updates the `vulnerabilities.json` with target date of the security release.
Expand Down
9 changes: 4 additions & 5 deletions lib/landing_session.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,8 @@ export default class LandingSession extends Session {

// We fetched the commit that would result if we used `git merge`.
// ^1 and ^2 refer to the PR base and the PR head, respectively.
const [base, head] = await runAsync('git',
['rev-parse', 'FETCH_HEAD^1', 'FETCH_HEAD^2'],
const [base, head, rebaseHead] = await runAsync('git',
['rev-parse', 'FETCH_HEAD^1', 'FETCH_HEAD^2', 'HEAD'],
{ captureStdout: 'lines' });
const commitShas = await runAsync('git',
['rev-list', `${base}..${head}`],
Expand All @@ -136,7 +136,7 @@ export default class LandingSession extends Session {
process.exit(1);
}

const commitInfo = { base, head, shas: commitShas };
const commitInfo = { base, head, shas: commitShas, rebaseHead };
this.saveCommitInfo(commitInfo);

try {
Expand Down Expand Up @@ -233,12 +233,11 @@ export default class LandingSession extends Session {
// so that it will perform everything automatically.
cli.log(`There are ${subjects.length} commits in the PR. ` +
'Attempting autorebase.');
const { upstream, branch } = this;
const assumeYes = this.cli.assumeYes ? '--yes' : '';
const msgAmend = `-x "git node land --amend ${assumeYes}"`;
try {
await forceRunAsync('git',
['rebase', ...this.gpgSign, `${upstream}/${branch}`,
['rebase', ...this.gpgSign, commitInfo.rebaseHead,
'--no-keep-empty', '-i', '--autosquash', msgAmend],
{
ignoreFailure: false,
Expand Down
19 changes: 19 additions & 0 deletions lib/queries/ListSecurityReleasePRs.gql
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
query PR($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
pullRequests(states: OPEN, first: 2, headRefName: "next-security-release", orderBy: {field: CREATED_AT, direction: DESC}) {
nodes {
number
headRefOid
author {
login
}

files(first: 2) {
nodes {
path
}
}
}
}
}
}
5 changes: 3 additions & 2 deletions lib/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -345,8 +345,9 @@ export default class Session {
}

getStrayCommits(verbose) {
const { upstream, branch } = this;
const ref = `${upstream}/${branch}...HEAD`;
const { upstream, branch, cli } = this;
const base = cli.hasDetachedHEAD || `${upstream}/${branch}`;
const ref = `${base}...HEAD`;
const gitCmd = verbose
? ['log', '--oneline', '--reverse', ref]
: ['rev-list', '--reverse', ref];
Expand Down
1 change: 1 addition & 0 deletions lib/update-v8/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,5 @@ export async function checkCwd(ctx) {
`node-dir: ${ctx.nodeDir}`
);
}
return ctx;
};
Loading