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
57 changes: 54 additions & 3 deletions docs/git-node.md
Original file line number Diff line number Diff line change
Expand Up @@ -494,9 +494,60 @@ $ ncu-config --global set h1_username $H1_TOKEN

### `git node security --start`

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`.
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.
Use `--sync`, `--add-report`, or `--remove-report` to update that release.

#### Preparing release data without the CLI

`lib/security-release/preparation.js` exposes helpers that can be used by other
local tools:

- `listSecurityReleaseCandidates(request)` accepts an authenticated NCU
`Request` and returns the triaged HackerOne report objects. A failed page
rejects the operation rather than returning an incomplete candidate list.
It does not fetch extra report history or make report-selection decisions.
- `buildIncludedTriagedReport(report, options)` converts a HackerOne report to
release metadata. Supply affected lines and patch authors explicitly; it
does not discover them or fetch a patch.
- `prepareSecurityRelease({ releaseDate, reports, dependencies })` accepts the
selected release report entries and dependency updates. It returns
`{ release, missingInformation }`, without requests, prompts, filesystem
writes, Git operations, or publication.

The preparation helper accepts `TBD`, `YYYY/MM/DD`, or `YYYY-MM-DD` dates and
normalizes defined dates to `YYYY-MM-DD`. It accepts legacy affected-line arrays
and strings as well as PR maps, and writes maps keyed by release line. Existing
map URLs are preserved. A report's canonical PR is not automatically assigned
to every affected line: unknown backport URLs remain empty. Legacy dependency
updates retain their explicit association between their PR and affected lines.

The result is an independent copy of the input. Duplicate report IDs, invalid
dates, and conflicting release-line mappings are rejected. Missing report
metadata is listed for follow-up; a draft with no reports can still contain
dependency updates. These checks prepare a draft, not a final-release approval.
The caller owns selection, human review, persistence, and publication.

After reviewing the prepared data, local tools can use
`writeSecurityReleaseDraft(directory, release)` from
`lib/security-release/draft.js`. The directory is an explicit security-release
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.

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

Expand Down
163 changes: 56 additions & 107 deletions lib/prepare_security.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,40 @@
import fs from 'node:fs';
import path from 'node:path';
import auth from './auth.js';
import Request from './request.js';
import { parsePRFromURL } from './links.js';
import {
assertNewSecurityRelease,
getSecurityReleaseDraftPath,
writeSecurityReleaseDraft
} from './security-release/draft.js';
import {
buildIncludedTriagedReport,
getReportPRURL,
getMissingReportInformation,
groupMissingReportInformation,
listSecurityReleaseCandidates,
prepareSecurityRelease
} from './security-release/preparation.js';

import {
NEXT_SECURITY_RELEASE_BRANCH,
NEXT_SECURITY_RELEASE_FOLDER,
checkoutOnSecurityReleaseBranch,
commitAndPushVulnerabilitiesJSON,
validateDate,
promptDependencies,
getSupportedVersions,
getReportSeverity,
getSummary,
pickReport,
confirmSecurityStep,
writeSecurityFile,
SecurityRelease
} from './security-release/security-release.js';

export {
buildIncludedTriagedReport,
getMissingReportInformation,
groupMissingReportInformation
} from './security-release/preparation.js';

function relativeDate(date) {
const days = Math.floor((Date.now() - date) / (1000 * 60 * 60 * 24));
if (days < 30) return days === 1 ? '1 day ago' : `${days} days ago`;
Expand Down Expand Up @@ -82,79 +98,11 @@ export function getNextTuesdayReleaseDateChoices(fromDate = new Date(), count =
return choices;
}

function getReportPRURL(report) {
const customFieldValues = report.relationships.custom_field_values?.data ?? [];
return customFieldValues[0]?.attributes?.value ?? '';
}

export function buildIncludedTriagedReport(report, options = {}) {
const {
affectedVersions = '',
patchAuthors = [],
prURL = getReportPRURL(report)
} = options;
const {
id,
attributes: { title, cve_ids = [] },
relationships: { reporter }
} = report;
const link = `https://hackerone.com/reports/${id}`;
const summaryContent = getSummary(report);

return {
id,
title,
cveIds: cve_ids,
severity: getReportSeverity(report),
summary: summaryContent ?? '',
patchAuthors,
prURL,
affectedVersions: affectedVersions
.split(',')
.map((v) => v.replace('v', '').trim())
.filter(Boolean),
link,
reporter: reporter?.data?.attributes?.username ?? ''
};
}

export function getMissingReportInformation(report) {
const missing = [];

if (!report.severity?.rating) missing.push('severity rating');
if (!report.severity?.cvss_vector_string) missing.push('CVSS vector');
if (!report.severity?.weakness_id) missing.push('weakness ID');
if (!report.summary) missing.push('team summary');
if (!report.prURL) missing.push('PR URL');
if (!report.patchAuthors?.length) missing.push('patch authors');
if (!report.affectedVersions?.length) missing.push('affected versions');

return missing;
}

export function groupMissingReportInformation(reports) {
const grouped = new Map();

for (const report of reports) {
for (const field of report.missing) {
const current = grouped.get(field) ?? [];
current.push(report);
grouped.set(field, current);
}
}

return Array.from(grouped.entries())
.map(([field, fieldReports]) => ({
field,
reports: fieldReports
}))
.sort((a, b) => b.reports.length - a.reports.length);
}

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 @@ -172,14 +120,15 @@ export default class PrepareSecurityRelease extends SecurityRelease {
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();
excludedReports = await this.showTriagedReportsWithoutPR(candidates);
}
}
await this.startVulnerabilitiesJSONCreation(
releaseDate, content, excludedReports, reportSelectionMode);
releaseDate, content, excludedReports, reportSelectionMode, candidates);
}

this.cli.ok('Done!');
Expand Down Expand Up @@ -251,19 +200,25 @@ export default class PrepareSecurityRelease extends SecurityRelease {
releaseDate,
content,
excludedReports = [],
reportSelectionMode = 'review'
reportSelectionMode = 'review',
candidates
) {
// checkout on the next-security-release branch
await checkoutOnSecurityReleaseBranch(this.cli, this.repository);
assertNewSecurityRelease(process.cwd());

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

// create the vulnerabilities.json file in the security-release repo
const filePath = await this.createVulnerabilitiesJSON(reports, deps, releaseDate);
// 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());

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

// review the vulnerabilities.json file
const review = await this.promptReviewVulnerabilitiesJSON();
Expand Down Expand Up @@ -366,11 +321,11 @@ export default class PrepareSecurityRelease extends SecurityRelease {
{ defaultAnswer: true });
}

async showTriagedReportsWithoutPR() {
async showTriagedReportsWithoutPR(candidates) {
this.cli.info('Fetching triaged reports without PR URL...');
const reports = await this.req.getTriagedReports();
const reportsWithoutPR = reports.data.filter(
(report) => !report.relationships.custom_field_values.data.length
const reports = candidates ?? await listSecurityReleaseCandidates(this.req);
const reportsWithoutPR = reports.filter(
(report) => !getReportPRURL(report)
);
if (!reportsWithoutPR.length) {
this.cli.ok('All triaged reports have a PR URL.');
Expand Down Expand Up @@ -408,12 +363,12 @@ export default class PrepareSecurityRelease extends SecurityRelease {
return template;
}

async chooseReports(excludedReports = []) {
async chooseReports(excludedReports = [], candidates) {
this.cli.info('Getting triaged H1 reports...');
const reports = await this.req.getTriagedReports();
const reports = candidates ?? await listSecurityReleaseCandidates(this.req);
const selectedReports = [];

for (const report of reports.data) {
for (const report of reports) {
if (excludedReports.includes(report.id)) continue;
const rep = await pickReport(report, { cli: this.cli, req: this.req });
if (!rep) continue;
Expand All @@ -422,14 +377,14 @@ export default class PrepareSecurityRelease extends SecurityRelease {
return selectedReports;
}

async includeAllTriagedReports(excludedReports = []) {
async includeAllTriagedReports(excludedReports = [], candidates) {
this.cli.info('Getting triaged H1 reports...');
const reports = await this.req.getTriagedReports();
const reports = candidates ?? await listSecurityReleaseCandidates(this.req);
const supportedVersions = await getSupportedVersions();
const selectedReports = [];
const missingInformation = [];

for (const report of reports.data) {
for (const report of reports) {
if (excludedReports.includes(report.id)) continue;

const reportData = await this.buildIncludedTriagedReport(
Expand Down Expand Up @@ -497,29 +452,23 @@ export default class PrepareSecurityRelease extends SecurityRelease {
}

async createVulnerabilitiesJSON(reports, dependencies, releaseDate) {
this.cli.startSpinner('Creating vulnerabilities.json...');
const fileContent = JSON.stringify({
releaseDate,
reports,
dependencies
}, null, 2) + '\n';

const folderPath = path.resolve(NEXT_SECURITY_RELEASE_FOLDER);
const fullPath = path.join(folderPath, 'vulnerabilities.json');
const { release } = prepareSecurityRelease({ releaseDate, reports, dependencies });
const directory = process.cwd();
const fullPath = getSecurityReleaseDraftPath(directory);
assertNewSecurityRelease(directory);
await confirmSecurityStep(
this.cli,
`create directory \`${folderPath}\``,
`create directory \`${path.dirname(fullPath)}\``,
'This creates the security release folder if it does not already exist.'
);
await fs.promises.mkdir(folderPath, { recursive: true });
await writeSecurityFile(
await confirmSecurityStep(
this.cli,
fullPath,
fileContent,
`write \`${fullPath}\``,
'This creates vulnerabilities.json for the next security release.'
);
this.cli.startSpinner('Creating vulnerabilities.json...');
writeSecurityReleaseDraft(directory, release);
this.cli.stopSpinner(`Created ${fullPath}`);

return fullPath;
}

Expand Down
44 changes: 37 additions & 7 deletions lib/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,22 +193,52 @@ export default class Request {
}

async getTriagedReports() {
const url = 'https://api.hackerone.com/v1/reports?filter[program][]=nodejs&filter[state][]=triaged';
let url = 'https://api.hackerone.com/v1/reports?filter[program][]=nodejs&filter[state][]=triaged';
const options = {
method: 'GET',
redirect: 'error',
headers: {
Authorization: `Basic ${this.credentials.h1}`,
'User-Agent': 'node-core-utils',
Accept: 'application/json'
}
};
const data = await this.json(url, options);
if (data?.errors) {
throw new Error(
`Request to fetch triaged reports failed with: ${JSON.stringify(data.errors)}`
);
const reports = [];
const reportIds = new Set();
const pages = new Set();
let result;

while (url) {
const pageUrl = new URL(url);
if (pageUrl.origin !== 'https://api.hackerone.com' ||
pageUrl.pathname !== '/v1/reports' || pageUrl.username || pageUrl.password) {
throw new Error('Invalid HackerOne reports pagination URL');
}
if (pages.has(pageUrl.href)) {
throw new Error('Repeated HackerOne reports pagination URL');
}
pages.add(pageUrl.href);

result = await this.json(url, options);
if (result?.errors?.length) {
throw new Error(
`Request to fetch triaged reports failed with: ${JSON.stringify(result.errors)}`
);
}
if (!Array.isArray(result?.data)) {
throw new Error('Invalid HackerOne reports response: expected a data array');
}
for (const report of result.data) {
if (reportIds.has(report.id)) continue;
reportIds.add(report.id);
reports.push(report);
}

const next = result.links?.next;
url = next ? new URL(next, pageUrl).href : null;
}
return data;

return { ...result, data: reports };
}

async getPrograms() {
Expand Down
Loading
Loading