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
36 changes: 24 additions & 12 deletions docs/git-node.md
Original file line number Diff line number Diff line change
Expand Up @@ -598,22 +598,33 @@ $ ncu-config --global set h1_username $H1_TOKEN
access.
- `h1_username`: HackerOne API Token username.

`git node security --start` pushes `vulnerabilities.json` to
`nodejs-private/security-release` through the GitHub API using the token NCU
is configured with, not your local git credentials. That token needs:

- Classic token: the `repo` scope.
- Fine-grained token: access to `nodejs-private/security-release` with
**Contents: Read and write** (to create the `next-security-release` branch
and commit the file) and **Pull requests: Read and write** (to open the
pull request), plus **Pull requests: Read** on `nodejs-private/node-private`
to look up patch authors.

### `git node security --start`

This command prepares `vulnerabilities.json` and can open the Next Security
Release pull request in `nodejs-private/security-release`, following the
[Security Release Process][] document. It retrieves all pages of triaged
HackerOne reports and uses the same candidate list for exclusions and selection.
The CLI prompts for the release date, report selection, and dependency updates
before writing the draft. Commit, push, and PR creation remain separate
confirmed steps. An existing `next-security-release` branch is checked out
without resetting it. If only `origin/next-security-release` exists locally,
the command creates a tracking branch from that ref. Fetch first when remote
state may have changed.

Security release commits reject unrelated staged changes before staging their
own files. Commit or unstage those changes before continuing. `--start` refuses
to overwrite an existing draft, including one found after switching branches.
The CLI prompts for the release date, report selection, and dependency updates,
then generates the draft in memory (optionally opening it in your editor for
review; the edited file must still be a valid draft). It pushes the draft to the
`next-security-release` branch of `nodejs-private/security-release` using the
GitHub API, so it doesn't need a local clone of that repository. Branch
creation, the push, and PR creation remain separate confirmed steps.

`--start` aborts before prompting for reports if the `next-security-release`
branch already exists upstream, or if `main` already contains
`security-release/next-security-release/vulnerabilities.json`.
Use `--sync`, `--add-report`, or `--remove-report` to update that release.

#### Preparing release data without the CLI
Expand Down Expand Up @@ -652,8 +663,9 @@ After reviewing the prepared data, local tools can use
repository path. The helper validates the draft and creates
`security-release/next-security-release/vulnerabilities.json` with an exclusive
write, so an existing file cannot be overwritten. It does not change Git state
or publish anything. Review and authorization belong to the calling tool; the
CLI retains its directory and file-write confirmations.
or publish anything. Review and authorization belong to the calling tool.
`git node security --start` does not use it: it publishes through the GitHub
API instead of writing to a local checkout.

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

Expand Down
178 changes: 109 additions & 69 deletions lib/prepare_security.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import auth from './auth.js';
import Request from './request.js';
import { forceRunAsync } from './run.js';
import { getEditor } from './utils.js';
import { parsePRFromURL } from './links.js';
import {
assertNewSecurityRelease,
getSecurityReleaseDraftPath,
writeSecurityReleaseDraft
} from './security-release/draft.js';
import {
buildIncludedTriagedReport,
getReportPRURL,
Expand All @@ -18,7 +17,7 @@ import {

import {
NEXT_SECURITY_RELEASE_BRANCH,
checkoutOnSecurityReleaseBranch,
NEXT_SECURITY_RELEASE_FOLDER,
commitAndPushVulnerabilitiesJSON,
validateDate,
promptDependencies,
Expand Down Expand Up @@ -102,7 +101,6 @@ export default class PrepareSecurityRelease extends SecurityRelease {
title = 'Next Security Release';

async start() {
assertNewSecurityRelease(process.cwd());
const credentials = await auth({
github: true,
h1: true
Expand All @@ -115,25 +113,43 @@ export default class PrepareSecurityRelease extends SecurityRelease {
if (releaseDate !== 'TBD') {
validateDate(releaseDate);
}
const createVulnerabilitiesJSON = await this.promptVulnerabilitiesJSON();

const content = await this.buildDescription(releaseDate);
if (createVulnerabilitiesJSON) {
const reportSelectionMode = await this.promptReportSelectionMode();
const candidates = await listSecurityReleaseCandidates(this.req);
if (reportSelectionMode === 'review') {
const showTriaged = await this.promptShowTriagedWithoutPR();
if (showTriaged) {
excludedReports = await this.showTriagedReportsWithoutPR(candidates);
}
await this.checkBranchDoesNotExist();
const reportSelectionMode = await this.promptReportSelectionMode();
const candidates = await listSecurityReleaseCandidates(this.req);
if (reportSelectionMode === 'review') {
const showTriaged = await this.promptShowTriagedWithoutPR();
if (showTriaged) {
excludedReports = await this.showTriagedReportsWithoutPR(candidates);
}
await this.startVulnerabilitiesJSONCreation(
releaseDate, content, excludedReports, reportSelectionMode, candidates);
}
await this.startVulnerabilitiesJSONCreation(
releaseDate, content, excludedReports, reportSelectionMode, candidates);

this.cli.ok('Done!');
}

async checkBranchDoesNotExist() {
const { owner, repo } = this.repository;
const branch = NEXT_SECURITY_RELEASE_BRANCH;
this.cli.startSpinner(`Checking that ${branch} does not exist on ${owner}/${repo}...`);
if (await this.req.getBranchSHA({ owner, repo, branch })) {
this.cli.stopSpinner(`${branch} already exists on ${owner}/${repo}`,
this.cli.SPINNER_STATUS.FAILED);
throw new Error('A security release seems to be in progress. Delete the ' +
`https://github.com/${owner}/${repo}/tree/${branch} branch if it is stale.`);
}
const draft = `${NEXT_SECURITY_RELEASE_FOLDER}/vulnerabilities.json`;
if (await this.req.getFileSHA({ owner, repo, path: draft, ref: 'main' })) {
this.cli.stopSpinner(`${draft} already exists on ${owner}/${repo}@main`,
this.cli.SPINNER_STATUS.FAILED);
throw new Error(`Security release draft already exists: ${draft} on main. ` +
'Use --sync, --add-report, or --remove-report to update the existing release.');
}
this.cli.stopSpinner(`${branch} does not exist on ${owner}/${repo}`);
}

async cleanup() {
const credentials = await auth({
github: true,
Expand Down Expand Up @@ -203,55 +219,30 @@ export default class PrepareSecurityRelease extends SecurityRelease {
reportSelectionMode = 'review',
candidates
) {
assertNewSecurityRelease(process.cwd());

// choose the reports to include in the security release
const reports = reportSelectionMode === 'include-all'
? await this.includeAllTriagedReports(excludedReports, candidates)
: await this.chooseReports(excludedReports, candidates);
const deps = await this.getDependencyUpdates();
const { release } = prepareSecurityRelease({ releaseDate, reports, dependencies: deps });

// Prepare all data before changing Git state. An existing branch may contain
// a draft that was not present on the branch where this command started.
await checkoutOnSecurityReleaseBranch(this.cli, this.repository);
assertNewSecurityRelease(process.cwd());
let fileContent = this.createVulnerabilitiesJSON(release);

const filePath = await this.createVulnerabilitiesJSON(
release.reports, release.dependencies, release.releaseDate);

// review the vulnerabilities.json file
const review = await this.promptReviewVulnerabilitiesJSON();

if (!review) {
this.cli.info(`To push the vulnerabilities.json file run:
- git add ${filePath}
- git commit -m "chore: create vulnerabilities.json for next security release"
- git push -u origin ${NEXT_SECURITY_RELEASE_BRANCH}
- open a PR on ${this.repository.owner}/${this.repository.repo}`);
return;
if (review) {
fileContent = await this.editVulnerabilitiesJSON(fileContent);
}

// commit and push the vulnerabilities.json file
const commitMessage = 'chore: create vulnerabilities.json for next security release';
await commitAndPushVulnerabilitiesJSON(filePath,
commitMessage,
{ cli: this.cli, repository: this.repository });

const createPr = await this.promptCreatePR();

if (!createPr) return;
await this.pushVulnerabilitiesJSON(
fileContent,
'chore: create vulnerabilities.json for next security release',
{ branchExists: false }
);

// create pr on the security-release repo
return this.createPullRequest(content);
}

promptCreatePR() {
return this.cli.prompt(
'Create the Next Security Release PR?',
{ defaultAnswer: true });
}

async getSecurityIssueTemplate() {
const url = 'https://raw.githubusercontent.com/nodejs/node/main/doc/contributing/security-release-process.md';
try {
Expand Down Expand Up @@ -311,10 +302,37 @@ export default class PrepareSecurityRelease extends SecurityRelease {

async promptReviewVulnerabilitiesJSON() {
return this.cli.prompt(
'Please review vulnerabilities.json and press enter to proceed.',
'Do you want to review/edit vulnerabilities.json before pushing it?',
{ defaultAnswer: true });
}

async editVulnerabilitiesJSON(content) {
const editor = await getEditor({ git: true });
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ncu-security-'));
const filePath = path.join(dir, 'vulnerabilities.json');
try {
await fs.promises.writeFile(filePath, content);
for (;;) {
await forceRunAsync(editor, [`"${filePath}"`],
{ ignoreFailure: false, spawnArgs: { shell: true } });
const edited = await fs.promises.readFile(filePath, 'utf8');
try {
const { release } = prepareSecurityRelease(JSON.parse(edited));
return this.createVulnerabilitiesJSON(release);
} catch (error) {
this.cli.error(`vulnerabilities.json is not valid: ${error.message}`);
const retry = await this.cli.prompt('Edit vulnerabilities.json again?',
{ defaultAnswer: true });
if (!retry) {
throw new Error('Aborted: vulnerabilities.json is not valid.', { cause: error });
}
}
}
} finally {
await fs.promises.rm(dir, { recursive: true, force: true });
}
}

async promptShowTriagedWithoutPR() {
return this.cli.prompt(
'Do you want to see which reports are triaged but have no PR URL?',
Expand Down Expand Up @@ -451,25 +469,47 @@ export default class PrepareSecurityRelease extends SecurityRelease {
}
}

async createVulnerabilitiesJSON(reports, dependencies, releaseDate) {
const { release } = prepareSecurityRelease({ releaseDate, reports, dependencies });
const directory = process.cwd();
const fullPath = getSecurityReleaseDraftPath(directory);
assertNewSecurityRelease(directory);
await confirmSecurityStep(
this.cli,
`create directory \`${path.dirname(fullPath)}\``,
'This creates the security release folder if it does not already exist.'
);
createVulnerabilitiesJSON(release) {
return JSON.stringify(release, null, 2) + '\n';
}

async pushVulnerabilitiesJSON(content, message, { branchExists } = {}) {
const { owner, repo } = this.repository;
const branch = NEXT_SECURITY_RELEASE_BRANCH;
const filePath = `${NEXT_SECURITY_RELEASE_FOLDER}/vulnerabilities.json`;

this.cli.startSpinner(`Looking for ${branch} on ${owner}/${repo}...`);
branchExists ??= !!(await this.req.getBranchSHA({ owner, repo, branch }));
let fileSHA;
if (branchExists) {
fileSHA = await this.req.getFileSHA({ owner, repo, path: filePath, ref: branch });
this.cli.stopSpinner(`Found ${branch} on ${owner}/${repo}`);
} else {
const baseSHA = await this.req.getBranchSHA({ owner, repo, branch: 'main' });
if (!baseSHA) {
this.cli.stopSpinner(`Could not find main on ${owner}/${repo}`,
this.cli.SPINNER_STATUS.FAILED);
throw new Error(`Could not find main on ${owner}/${repo}`);
}
this.cli.stopSpinner(`Found main on ${owner}/${repo}`);
await confirmSecurityStep(
this.cli,
`create branch \`${branch}\` on \`${owner}/${repo}\``,
`This creates the ${branch} branch from main (${baseSHA}).`
);
await this.req.createBranch({ owner, repo, branch, sha: baseSHA });
}

await confirmSecurityStep(
this.cli,
`write \`${fullPath}\``,
'This creates vulnerabilities.json for the next security release.'
`push \`${filePath}\` to \`${owner}/${repo}@${branch}\``,
`This ${fileSHA ? 'replaces' : 'creates'} ${filePath} in a new commit: ${message}`
);
this.cli.startSpinner('Creating vulnerabilities.json...');
writeSecurityReleaseDraft(directory, release);
this.cli.stopSpinner(`Created ${fullPath}`);
return fullPath;
this.cli.startSpinner(`Pushing ${filePath} to ${owner}/${repo}@${branch}...`);
const { commit } = await this.req.createOrUpdateFile({
owner, repo, path: filePath, branch, content, message, sha: fileSHA
});
this.cli.stopSpinner(`Pushed ${commit?.html_url ?? message}`);
}

async createPullRequest(content) {
Expand All @@ -486,7 +526,7 @@ export default class PrepareSecurityRelease extends SecurityRelease {
owner,
repo,
base: 'main',
head: 'next-security-release'
head: NEXT_SECURITY_RELEASE_BRANCH
}

);
Expand Down
57 changes: 57 additions & 0 deletions lib/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,63 @@ export default class Request {
return res;
}

async getBranchSHA({ owner, repo, branch }) {
const url = `/repos/${owner}/${repo}/git/ref/heads/${encodeURIComponent(branch)}`;
const res = await this.fetch(url, {});
if (res.status === 404) return;
if (!res.ok) {
throw new Error(
`Failed to get ${branch} on ${owner}/${repo}: ${res.status} ${res.statusText}`);
}
const { object } = await res.json();
return object.sha;
}

async createBranch({ owner, repo, branch, sha }) {
const url = `/repos/${owner}/${repo}/git/refs`;
const res = await this.fetch(url, {
method: 'POST',
body: JSON.stringify({ ref: `refs/heads/${branch}`, sha })
});
if (!res.ok) {
throw new Error(
`Failed to create ${branch} on ${owner}/${repo}: ${res.status} ${res.statusText}`);
}
return res.json();
}

async getFileSHA({ owner, repo, path, ref }) {
const url = `/repos/${owner}/${repo}/contents/${path}?ref=${encodeURIComponent(ref)}`;
const res = await this.fetch(url, {});
if (res.status === 404) return;
if (!res.ok) {
throw new Error(
`Failed to get ${path} on ${owner}/${repo}@${ref}: ${res.status} ${res.statusText}`);
}
const { sha } = await res.json();
return sha;
}

async createOrUpdateFile({ owner, repo, path, branch, content, message, sha }) {
const url = `/repos/${owner}/${repo}/contents/${path}`;
const res = await this.fetch(url, {
method: 'PUT',
body: JSON.stringify({
message,
content: Buffer.from(content).toString('base64'),
branch,
sha
})
});
if (!res.ok) {
const text = await res.text();
throw new Error(
`Failed to push ${path} to ${owner}/${repo}@${branch}: ` +
`${res.status} ${res.statusText}${text ? `\n${text}` : ''}`);
}
return res.json();
}

async createPullRequest(title, body, { owner, repo, head, base }) {
const url = `/repos/${owner}/${repo}/pulls`;
const options = {
Expand Down
Loading
Loading