Skip to content
Open
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
206 changes: 206 additions & 0 deletions components/git/security.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,36 @@
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 PRData from '../../lib/pr_data.js';

Check failure on line 14 in components/git/security.js

View workflow job for this annotation

GitHub Actions / Lint using ESLint

'PRData' is defined but never used

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 +78,10 @@
'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 +121,9 @@
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 +194,186 @@
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();

Check failure on line 252 in components/git/security.js

View workflow job for this annotation

GitHub Actions / Lint using ESLint

This line has a length of 130. Maximum allowed is 100

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 })

Check failure on line 283 in components/git/security.js

View workflow job for this annotation

GitHub Actions / Lint using ESLint

Missing semicolon
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;

Check failure on line 294 in components/git/security.js

View workflow job for this annotation

GitHub Actions / Lint using ESLint

Trailing spaces not allowed
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'],

Check failure on line 302 in components/git/security.js

View workflow job for this annotation

GitHub Actions / Lint using ESLint

This line has a length of 135. Maximum allowed is 100
{ 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.')

Check failure on line 306 in components/git/security.js

View workflow job for this annotation

GitHub Actions / Lint using ESLint

Missing semicolon
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')}`)

Check failure on line 311 in components/git/security.js

View workflow job for this annotation

GitHub Actions / Lint using ESLint

Missing semicolon

Check failure on line 311 in components/git/security.js

View workflow job for this annotation

GitHub Actions / Lint using ESLint

This line has a length of 171. Maximum allowed is 100
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
8 changes: 4 additions & 4 deletions lib/landing_session.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,8 @@

// 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 @@
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,12 @@
// so that it will perform everything automatically.
cli.log(`There are ${subjects.length} commits in the PR. ` +
'Attempting autorebase.');
const { upstream, branch } = this;

Check failure on line 236 in lib/landing_session.js

View workflow job for this annotation

GitHub Actions / Lint using ESLint

'branch' is assigned a value but never used

Check failure on line 236 in lib/landing_session.js

View workflow job for this annotation

GitHub Actions / Lint using ESLint

'upstream' is assigned a value but never used
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 || `${this.upstream}/${this.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