@node-core/utils 7.2.2 → 7.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/ncu-ci.js +52 -6
- package/lib/ci/ci_failure_parser.js +132 -117
- package/lib/ci/failure_file_scanner.js +153 -0
- package/lib/ci/resume_ci.js +150 -0
- package/lib/ci/run_ci.js +14 -4
- package/lib/cli.js +7 -1
- package/lib/prepare_security.js +56 -107
- package/lib/request.js +52 -8
- package/lib/security-release/draft.js +32 -0
- package/lib/security-release/preparation.js +168 -0
- package/lib/security-release/security-release.js +32 -2
- package/package.json +1 -1
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { JobParser, CI_TYPES_KEYS } from './ci_type_parser.js';
|
|
2
|
+
import { CI_CRUMB_URL } from './run_ci.js';
|
|
3
|
+
import { PRBuild } from './build-types/pr_build.js';
|
|
4
|
+
import { getPrURL } from '../links.js';
|
|
5
|
+
import { debuglog } from '../verbosity.js';
|
|
6
|
+
import { FailureFileScanner } from './failure_file_scanner.js';
|
|
7
|
+
|
|
8
|
+
export class ResumePRJob {
|
|
9
|
+
constructor(cli, request, owner, repo, prid) {
|
|
10
|
+
this.cli = cli;
|
|
11
|
+
this.request = request;
|
|
12
|
+
this.owner = owner;
|
|
13
|
+
this.repo = repo;
|
|
14
|
+
this.prid = prid;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async checkFailures(jobid) {
|
|
18
|
+
const { cli, request, owner, repo, prid } = this;
|
|
19
|
+
const filenames = new Set();
|
|
20
|
+
for await (const file of request.getPullRequestFiles({ owner, repo, prid })) {
|
|
21
|
+
for (const filename of [file.filename, file.previous_filename].filter(Boolean)) {
|
|
22
|
+
filenames.add(filename);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const overlaps = new Set();
|
|
26
|
+
const buildRequest = Object.create(request);
|
|
27
|
+
buildRequest.json = async(...args) => {
|
|
28
|
+
const data = await request.json(...args);
|
|
29
|
+
// Inspect failed descendants even when their parent job was aborted.
|
|
30
|
+
return data?.result === 'ABORTED' ? { ...data, result: 'FAILURE' } : data;
|
|
31
|
+
};
|
|
32
|
+
// Reuse the build traversal, but consume console responses directly instead
|
|
33
|
+
// of handing whole logs to its summary parser. Serialize downloads so a
|
|
34
|
+
// match cancels this response and prevents queued logs from being opened.
|
|
35
|
+
let pending = Promise.resolve();
|
|
36
|
+
buildRequest.text = (url) => {
|
|
37
|
+
const scan = pending.then(async() => {
|
|
38
|
+
if (!filenames.size || overlaps.size) return '';
|
|
39
|
+
const scanner = new FailureFileScanner(filenames);
|
|
40
|
+
const match = await scanner.scan(request.stream(url));
|
|
41
|
+
if (match) overlaps.add(match);
|
|
42
|
+
return '';
|
|
43
|
+
});
|
|
44
|
+
pending = scan.catch(debuglog);
|
|
45
|
+
return scan;
|
|
46
|
+
};
|
|
47
|
+
const build = new PRBuild(cli, buildRequest, jobid, Infinity);
|
|
48
|
+
try {
|
|
49
|
+
await build.getResults();
|
|
50
|
+
} catch (err) {
|
|
51
|
+
debuglog(err);
|
|
52
|
+
}
|
|
53
|
+
await pending;
|
|
54
|
+
if (overlaps.size) {
|
|
55
|
+
for (const filename of [...overlaps].sort()) {
|
|
56
|
+
cli.error(filename);
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async resume() {
|
|
64
|
+
const { cli, request, prid } = this;
|
|
65
|
+
let crumb;
|
|
66
|
+
cli.startSpinner('Validating Jenkins credentials');
|
|
67
|
+
try {
|
|
68
|
+
({ crumb } = await request.json(CI_CRUMB_URL));
|
|
69
|
+
if (!crumb) {
|
|
70
|
+
throw new Error('Missing Jenkins crumb');
|
|
71
|
+
}
|
|
72
|
+
} catch (err) {
|
|
73
|
+
debuglog(err);
|
|
74
|
+
cli.stopSpinner('Jenkins credentials invalid', cli.SPINNER_STATUS.FAILED);
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
cli.stopSpinner('Jenkins credentials valid');
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
cli.startSpinner(`Looking for CI runs for pull request ${prid}`);
|
|
81
|
+
const parser = await JobParser.fromPR(getPrURL(this), cli, request);
|
|
82
|
+
const job = parser.parse().get(CI_TYPES_KEYS.PR);
|
|
83
|
+
if (!job) {
|
|
84
|
+
cli.stopSpinner(`No CI run detected from pull request ${prid}`,
|
|
85
|
+
cli.SPINNER_STATUS.FAILED);
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
cli.stopSpinner(`Found PR CI job ${job.jobid}`);
|
|
89
|
+
|
|
90
|
+
const build = new PRBuild(cli, request, job.jobid, undefined,
|
|
91
|
+
'result,building,actions[_class,parameters[name,value]]');
|
|
92
|
+
const { result, building, actions = [] } = await build.getBuildData();
|
|
93
|
+
if (building || (result !== 'FAILURE' && result !== 'ABORTED')) {
|
|
94
|
+
const status = building ? 'RUNNING' : result ?? 'RUNNING';
|
|
95
|
+
cli.error(`CI job ${job.jobid} is in status ${status}, skipping resume`);
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
if (!actions.some(action =>
|
|
99
|
+
action._class === 'com.tikal.jenkins.plugins.multijob.MultiJobResumeBuild')) {
|
|
100
|
+
cli.error(`CI job ${job.jobid} is not resumable`);
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const approvedSHAs = new Set(actions.flatMap(action => action.parameters ?? [])
|
|
105
|
+
.filter(parameter => parameter.name === 'COMMIT_SHA_CHECK')
|
|
106
|
+
.map(parameter => parameter.value));
|
|
107
|
+
const [approvedSHA] = approvedSHAs;
|
|
108
|
+
if (approvedSHAs.size !== 1 || typeof approvedSHA !== 'string' || !approvedSHA) {
|
|
109
|
+
cli.error(`Refusing to resume CI job ${job.jobid}: cannot determine its approved commit`);
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
cli.startSpinner('Checking failures against changed PR files');
|
|
114
|
+
if (!(await this.checkFailures(job.jobid))) {
|
|
115
|
+
cli.stopSpinner('Refusing to resume CI: failures reference files changed by this PR',
|
|
116
|
+
cli.SPINNER_STATUS.FAILED);
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
cli.stopSpinner('No changed PR files found in available failure details');
|
|
120
|
+
|
|
121
|
+
// Read HEAD after inspecting failures so the comparison is fresh when resuming.
|
|
122
|
+
const pr = await request.getPullRequest(getPrURL(this));
|
|
123
|
+
if (pr.head?.sha !== approvedSHA) {
|
|
124
|
+
cli.error(`Refusing to resume CI job ${job.jobid}: ` +
|
|
125
|
+
'its approved commit does not match the current PR HEAD');
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
cli.startSpinner(`Resuming PR CI job ${job.jobid}`);
|
|
130
|
+
const response = await request.fetch(`${build.jobUrl}resume`, {
|
|
131
|
+
method: 'POST',
|
|
132
|
+
headers: {
|
|
133
|
+
'Jenkins-Crumb': crumb
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
if (response.status !== 200) {
|
|
137
|
+
cli.stopSpinner(
|
|
138
|
+
`Failed to resume PR CI: ${response.status} ${response.statusText}`,
|
|
139
|
+
cli.SPINNER_STATUS.FAILED);
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
cli.stopSpinner('PR CI job successfully resumed');
|
|
143
|
+
} catch (err) {
|
|
144
|
+
debuglog(err);
|
|
145
|
+
cli.stopSpinner('Failed to resume CI', cli.SPINNER_STATUS.FAILED);
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
}
|
package/lib/ci/run_ci.js
CHANGED
|
@@ -94,10 +94,20 @@ export class RunPRJob {
|
|
|
94
94
|
if (checkForDuplicates) {
|
|
95
95
|
await this.prData.getComments();
|
|
96
96
|
const { jobid, link } = new JobParser(this.prData.comments).parse().get('PR') ?? {};
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
97
|
+
let actions;
|
|
98
|
+
try {
|
|
99
|
+
({ actions } = jobid
|
|
100
|
+
? (await new PRBuild(cli, request, jobid, undefined, 'actions[parameters[name,value]]')
|
|
101
|
+
.getBuildData())
|
|
102
|
+
: {});
|
|
103
|
+
} catch (err) {
|
|
104
|
+
// Jenkins may no longer have the build (e.g. the record was lost) or
|
|
105
|
+
// may answer with an HTML error page instead of JSON. Do not let that
|
|
106
|
+
// block starting a new CI run.
|
|
107
|
+
cli.stopSpinner(`Could not query existing CI run ${link}: ${err.message}`,
|
|
108
|
+
cli.SPINNER_STATUS.WARN);
|
|
109
|
+
cli.warn('Skipping the duplicate CI check');
|
|
110
|
+
}
|
|
101
111
|
const { parameters } = actions?.find(a => 'parameters' in a) ?? {};
|
|
102
112
|
if (parameters?.find(c => c.name === 'COMMIT_SHA_CHECK')?.value === certifySafe) {
|
|
103
113
|
cli.info('Existing CI run found: ' + link);
|
package/lib/cli.js
CHANGED
|
@@ -154,7 +154,13 @@ export default class CLI {
|
|
|
154
154
|
case INFO:
|
|
155
155
|
symbol = info;
|
|
156
156
|
}
|
|
157
|
-
|
|
157
|
+
const text = `${symbol} ${rawText}`;
|
|
158
|
+
if (this.spinner.isSpinning) {
|
|
159
|
+
this.spinner.stop(text);
|
|
160
|
+
} else {
|
|
161
|
+
// A nested operation may already have stopped the shared spinner.
|
|
162
|
+
this.log(text);
|
|
163
|
+
}
|
|
158
164
|
}
|
|
159
165
|
|
|
160
166
|
write(text) {
|
package/lib/prepare_security.js
CHANGED
|
@@ -1,24 +1,40 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
1
|
import path from 'node:path';
|
|
3
2
|
import auth from './auth.js';
|
|
4
3
|
import Request from './request.js';
|
|
5
4
|
import { parsePRFromURL } from './links.js';
|
|
5
|
+
import {
|
|
6
|
+
assertNewSecurityRelease,
|
|
7
|
+
getSecurityReleaseDraftPath,
|
|
8
|
+
writeSecurityReleaseDraft
|
|
9
|
+
} from './security-release/draft.js';
|
|
10
|
+
import {
|
|
11
|
+
buildIncludedTriagedReport,
|
|
12
|
+
getReportPRURL,
|
|
13
|
+
getMissingReportInformation,
|
|
14
|
+
groupMissingReportInformation,
|
|
15
|
+
listSecurityReleaseCandidates,
|
|
16
|
+
prepareSecurityRelease
|
|
17
|
+
} from './security-release/preparation.js';
|
|
18
|
+
|
|
6
19
|
import {
|
|
7
20
|
NEXT_SECURITY_RELEASE_BRANCH,
|
|
8
|
-
NEXT_SECURITY_RELEASE_FOLDER,
|
|
9
21
|
checkoutOnSecurityReleaseBranch,
|
|
10
22
|
commitAndPushVulnerabilitiesJSON,
|
|
11
23
|
validateDate,
|
|
12
24
|
promptDependencies,
|
|
13
25
|
getSupportedVersions,
|
|
14
26
|
getReportSeverity,
|
|
15
|
-
getSummary,
|
|
16
27
|
pickReport,
|
|
17
28
|
confirmSecurityStep,
|
|
18
|
-
writeSecurityFile,
|
|
19
29
|
SecurityRelease
|
|
20
30
|
} from './security-release/security-release.js';
|
|
21
31
|
|
|
32
|
+
export {
|
|
33
|
+
buildIncludedTriagedReport,
|
|
34
|
+
getMissingReportInformation,
|
|
35
|
+
groupMissingReportInformation
|
|
36
|
+
} from './security-release/preparation.js';
|
|
37
|
+
|
|
22
38
|
function relativeDate(date) {
|
|
23
39
|
const days = Math.floor((Date.now() - date) / (1000 * 60 * 60 * 24));
|
|
24
40
|
if (days < 30) return days === 1 ? '1 day ago' : `${days} days ago`;
|
|
@@ -82,79 +98,11 @@ export function getNextTuesdayReleaseDateChoices(fromDate = new Date(), count =
|
|
|
82
98
|
return choices;
|
|
83
99
|
}
|
|
84
100
|
|
|
85
|
-
function getReportPRURL(report) {
|
|
86
|
-
const customFieldValues = report.relationships.custom_field_values?.data ?? [];
|
|
87
|
-
return customFieldValues[0]?.attributes?.value ?? '';
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export function buildIncludedTriagedReport(report, options = {}) {
|
|
91
|
-
const {
|
|
92
|
-
affectedVersions = '',
|
|
93
|
-
patchAuthors = [],
|
|
94
|
-
prURL = getReportPRURL(report)
|
|
95
|
-
} = options;
|
|
96
|
-
const {
|
|
97
|
-
id,
|
|
98
|
-
attributes: { title, cve_ids = [] },
|
|
99
|
-
relationships: { reporter }
|
|
100
|
-
} = report;
|
|
101
|
-
const link = `https://hackerone.com/reports/${id}`;
|
|
102
|
-
const summaryContent = getSummary(report);
|
|
103
|
-
|
|
104
|
-
return {
|
|
105
|
-
id,
|
|
106
|
-
title,
|
|
107
|
-
cveIds: cve_ids,
|
|
108
|
-
severity: getReportSeverity(report),
|
|
109
|
-
summary: summaryContent ?? '',
|
|
110
|
-
patchAuthors,
|
|
111
|
-
prURL,
|
|
112
|
-
affectedVersions: affectedVersions
|
|
113
|
-
.split(',')
|
|
114
|
-
.map((v) => v.replace('v', '').trim())
|
|
115
|
-
.filter(Boolean),
|
|
116
|
-
link,
|
|
117
|
-
reporter: reporter?.data?.attributes?.username ?? ''
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export function getMissingReportInformation(report) {
|
|
122
|
-
const missing = [];
|
|
123
|
-
|
|
124
|
-
if (!report.severity?.rating) missing.push('severity rating');
|
|
125
|
-
if (!report.severity?.cvss_vector_string) missing.push('CVSS vector');
|
|
126
|
-
if (!report.severity?.weakness_id) missing.push('weakness ID');
|
|
127
|
-
if (!report.summary) missing.push('team summary');
|
|
128
|
-
if (!report.prURL) missing.push('PR URL');
|
|
129
|
-
if (!report.patchAuthors?.length) missing.push('patch authors');
|
|
130
|
-
if (!report.affectedVersions?.length) missing.push('affected versions');
|
|
131
|
-
|
|
132
|
-
return missing;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
export function groupMissingReportInformation(reports) {
|
|
136
|
-
const grouped = new Map();
|
|
137
|
-
|
|
138
|
-
for (const report of reports) {
|
|
139
|
-
for (const field of report.missing) {
|
|
140
|
-
const current = grouped.get(field) ?? [];
|
|
141
|
-
current.push(report);
|
|
142
|
-
grouped.set(field, current);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
return Array.from(grouped.entries())
|
|
147
|
-
.map(([field, fieldReports]) => ({
|
|
148
|
-
field,
|
|
149
|
-
reports: fieldReports
|
|
150
|
-
}))
|
|
151
|
-
.sort((a, b) => b.reports.length - a.reports.length);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
101
|
export default class PrepareSecurityRelease extends SecurityRelease {
|
|
155
102
|
title = 'Next Security Release';
|
|
156
103
|
|
|
157
104
|
async start() {
|
|
105
|
+
assertNewSecurityRelease(process.cwd());
|
|
158
106
|
const credentials = await auth({
|
|
159
107
|
github: true,
|
|
160
108
|
h1: true
|
|
@@ -172,14 +120,15 @@ export default class PrepareSecurityRelease extends SecurityRelease {
|
|
|
172
120
|
const content = await this.buildDescription(releaseDate);
|
|
173
121
|
if (createVulnerabilitiesJSON) {
|
|
174
122
|
const reportSelectionMode = await this.promptReportSelectionMode();
|
|
123
|
+
const candidates = await listSecurityReleaseCandidates(this.req);
|
|
175
124
|
if (reportSelectionMode === 'review') {
|
|
176
125
|
const showTriaged = await this.promptShowTriagedWithoutPR();
|
|
177
126
|
if (showTriaged) {
|
|
178
|
-
excludedReports = await this.showTriagedReportsWithoutPR();
|
|
127
|
+
excludedReports = await this.showTriagedReportsWithoutPR(candidates);
|
|
179
128
|
}
|
|
180
129
|
}
|
|
181
130
|
await this.startVulnerabilitiesJSONCreation(
|
|
182
|
-
releaseDate, content, excludedReports, reportSelectionMode);
|
|
131
|
+
releaseDate, content, excludedReports, reportSelectionMode, candidates);
|
|
183
132
|
}
|
|
184
133
|
|
|
185
134
|
this.cli.ok('Done!');
|
|
@@ -251,19 +200,25 @@ export default class PrepareSecurityRelease extends SecurityRelease {
|
|
|
251
200
|
releaseDate,
|
|
252
201
|
content,
|
|
253
202
|
excludedReports = [],
|
|
254
|
-
reportSelectionMode = 'review'
|
|
203
|
+
reportSelectionMode = 'review',
|
|
204
|
+
candidates
|
|
255
205
|
) {
|
|
256
|
-
|
|
257
|
-
await checkoutOnSecurityReleaseBranch(this.cli, this.repository);
|
|
206
|
+
assertNewSecurityRelease(process.cwd());
|
|
258
207
|
|
|
259
208
|
// choose the reports to include in the security release
|
|
260
209
|
const reports = reportSelectionMode === 'include-all'
|
|
261
|
-
? await this.includeAllTriagedReports(excludedReports)
|
|
262
|
-
: await this.chooseReports(excludedReports);
|
|
210
|
+
? await this.includeAllTriagedReports(excludedReports, candidates)
|
|
211
|
+
: await this.chooseReports(excludedReports, candidates);
|
|
263
212
|
const deps = await this.getDependencyUpdates();
|
|
213
|
+
const { release } = prepareSecurityRelease({ releaseDate, reports, dependencies: deps });
|
|
264
214
|
|
|
265
|
-
//
|
|
266
|
-
|
|
215
|
+
// Prepare all data before changing Git state. An existing branch may contain
|
|
216
|
+
// a draft that was not present on the branch where this command started.
|
|
217
|
+
await checkoutOnSecurityReleaseBranch(this.cli, this.repository);
|
|
218
|
+
assertNewSecurityRelease(process.cwd());
|
|
219
|
+
|
|
220
|
+
const filePath = await this.createVulnerabilitiesJSON(
|
|
221
|
+
release.reports, release.dependencies, release.releaseDate);
|
|
267
222
|
|
|
268
223
|
// review the vulnerabilities.json file
|
|
269
224
|
const review = await this.promptReviewVulnerabilitiesJSON();
|
|
@@ -366,11 +321,11 @@ export default class PrepareSecurityRelease extends SecurityRelease {
|
|
|
366
321
|
{ defaultAnswer: true });
|
|
367
322
|
}
|
|
368
323
|
|
|
369
|
-
async showTriagedReportsWithoutPR() {
|
|
324
|
+
async showTriagedReportsWithoutPR(candidates) {
|
|
370
325
|
this.cli.info('Fetching triaged reports without PR URL...');
|
|
371
|
-
const reports = await this.req
|
|
372
|
-
const reportsWithoutPR = reports.
|
|
373
|
-
(report) => !report
|
|
326
|
+
const reports = candidates ?? await listSecurityReleaseCandidates(this.req);
|
|
327
|
+
const reportsWithoutPR = reports.filter(
|
|
328
|
+
(report) => !getReportPRURL(report)
|
|
374
329
|
);
|
|
375
330
|
if (!reportsWithoutPR.length) {
|
|
376
331
|
this.cli.ok('All triaged reports have a PR URL.');
|
|
@@ -408,12 +363,12 @@ export default class PrepareSecurityRelease extends SecurityRelease {
|
|
|
408
363
|
return template;
|
|
409
364
|
}
|
|
410
365
|
|
|
411
|
-
async chooseReports(excludedReports = []) {
|
|
366
|
+
async chooseReports(excludedReports = [], candidates) {
|
|
412
367
|
this.cli.info('Getting triaged H1 reports...');
|
|
413
|
-
const reports = await this.req
|
|
368
|
+
const reports = candidates ?? await listSecurityReleaseCandidates(this.req);
|
|
414
369
|
const selectedReports = [];
|
|
415
370
|
|
|
416
|
-
for (const report of reports
|
|
371
|
+
for (const report of reports) {
|
|
417
372
|
if (excludedReports.includes(report.id)) continue;
|
|
418
373
|
const rep = await pickReport(report, { cli: this.cli, req: this.req });
|
|
419
374
|
if (!rep) continue;
|
|
@@ -422,14 +377,14 @@ export default class PrepareSecurityRelease extends SecurityRelease {
|
|
|
422
377
|
return selectedReports;
|
|
423
378
|
}
|
|
424
379
|
|
|
425
|
-
async includeAllTriagedReports(excludedReports = []) {
|
|
380
|
+
async includeAllTriagedReports(excludedReports = [], candidates) {
|
|
426
381
|
this.cli.info('Getting triaged H1 reports...');
|
|
427
|
-
const reports = await this.req
|
|
382
|
+
const reports = candidates ?? await listSecurityReleaseCandidates(this.req);
|
|
428
383
|
const supportedVersions = await getSupportedVersions();
|
|
429
384
|
const selectedReports = [];
|
|
430
385
|
const missingInformation = [];
|
|
431
386
|
|
|
432
|
-
for (const report of reports
|
|
387
|
+
for (const report of reports) {
|
|
433
388
|
if (excludedReports.includes(report.id)) continue;
|
|
434
389
|
|
|
435
390
|
const reportData = await this.buildIncludedTriagedReport(
|
|
@@ -497,29 +452,23 @@ export default class PrepareSecurityRelease extends SecurityRelease {
|
|
|
497
452
|
}
|
|
498
453
|
|
|
499
454
|
async createVulnerabilitiesJSON(reports, dependencies, releaseDate) {
|
|
500
|
-
|
|
501
|
-
const
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
dependencies
|
|
505
|
-
}, null, 2) + '\n';
|
|
506
|
-
|
|
507
|
-
const folderPath = path.resolve(NEXT_SECURITY_RELEASE_FOLDER);
|
|
508
|
-
const fullPath = path.join(folderPath, 'vulnerabilities.json');
|
|
455
|
+
const { release } = prepareSecurityRelease({ releaseDate, reports, dependencies });
|
|
456
|
+
const directory = process.cwd();
|
|
457
|
+
const fullPath = getSecurityReleaseDraftPath(directory);
|
|
458
|
+
assertNewSecurityRelease(directory);
|
|
509
459
|
await confirmSecurityStep(
|
|
510
460
|
this.cli,
|
|
511
|
-
`create directory \`${
|
|
461
|
+
`create directory \`${path.dirname(fullPath)}\``,
|
|
512
462
|
'This creates the security release folder if it does not already exist.'
|
|
513
463
|
);
|
|
514
|
-
await
|
|
515
|
-
await writeSecurityFile(
|
|
464
|
+
await confirmSecurityStep(
|
|
516
465
|
this.cli,
|
|
517
|
-
fullPath
|
|
518
|
-
fileContent,
|
|
466
|
+
`write \`${fullPath}\``,
|
|
519
467
|
'This creates vulnerabilities.json for the next security release.'
|
|
520
468
|
);
|
|
469
|
+
this.cli.startSpinner('Creating vulnerabilities.json...');
|
|
470
|
+
writeSecurityReleaseDraft(directory, release);
|
|
521
471
|
this.cli.stopSpinner(`Created ${fullPath}`);
|
|
522
|
-
|
|
523
472
|
return fullPath;
|
|
524
473
|
}
|
|
525
474
|
|
package/lib/request.js
CHANGED
|
@@ -58,6 +58,17 @@ export default class Request {
|
|
|
58
58
|
return res.text();
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
async * stream(url, options = {}) {
|
|
62
|
+
const res = await this.fetch(url, options);
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
await res.body?.cancel();
|
|
65
|
+
throw new Error(`Unable to stream ${url}: ${res.status} ${res.statusText}`);
|
|
66
|
+
}
|
|
67
|
+
// Undici decompresses HTTP content encodings as it reads. Returning early
|
|
68
|
+
// from this iterator also cancels the response body and its HTTP download.
|
|
69
|
+
yield * res.body;
|
|
70
|
+
}
|
|
71
|
+
|
|
61
72
|
async json(url, options = {}) {
|
|
62
73
|
options.headers = options.headers || {};
|
|
63
74
|
const text = await this.text(url, options);
|
|
@@ -106,7 +117,10 @@ export default class Request {
|
|
|
106
117
|
const url =
|
|
107
118
|
`/repos/${owner}/${repo}/pulls/${prid}/files?per_page=100&page=${page}`;
|
|
108
119
|
const batch = await this.json(url);
|
|
109
|
-
if (!Array.isArray(batch)
|
|
120
|
+
if (!Array.isArray(batch)) {
|
|
121
|
+
throw new Error('Unable to retrieve pull request files');
|
|
122
|
+
}
|
|
123
|
+
if (batch.length === 0) {
|
|
110
124
|
break;
|
|
111
125
|
}
|
|
112
126
|
yield * batch;
|
|
@@ -193,22 +207,52 @@ export default class Request {
|
|
|
193
207
|
}
|
|
194
208
|
|
|
195
209
|
async getTriagedReports() {
|
|
196
|
-
|
|
210
|
+
let url = 'https://api.hackerone.com/v1/reports?filter[program][]=nodejs&filter[state][]=triaged';
|
|
197
211
|
const options = {
|
|
198
212
|
method: 'GET',
|
|
213
|
+
redirect: 'error',
|
|
199
214
|
headers: {
|
|
200
215
|
Authorization: `Basic ${this.credentials.h1}`,
|
|
201
216
|
'User-Agent': 'node-core-utils',
|
|
202
217
|
Accept: 'application/json'
|
|
203
218
|
}
|
|
204
219
|
};
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
220
|
+
const reports = [];
|
|
221
|
+
const reportIds = new Set();
|
|
222
|
+
const pages = new Set();
|
|
223
|
+
let result;
|
|
224
|
+
|
|
225
|
+
while (url) {
|
|
226
|
+
const pageUrl = new URL(url);
|
|
227
|
+
if (pageUrl.origin !== 'https://api.hackerone.com' ||
|
|
228
|
+
pageUrl.pathname !== '/v1/reports' || pageUrl.username || pageUrl.password) {
|
|
229
|
+
throw new Error('Invalid HackerOne reports pagination URL');
|
|
230
|
+
}
|
|
231
|
+
if (pages.has(pageUrl.href)) {
|
|
232
|
+
throw new Error('Repeated HackerOne reports pagination URL');
|
|
233
|
+
}
|
|
234
|
+
pages.add(pageUrl.href);
|
|
235
|
+
|
|
236
|
+
result = await this.json(url, options);
|
|
237
|
+
if (result?.errors?.length) {
|
|
238
|
+
throw new Error(
|
|
239
|
+
`Request to fetch triaged reports failed with: ${JSON.stringify(result.errors)}`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
if (!Array.isArray(result?.data)) {
|
|
243
|
+
throw new Error('Invalid HackerOne reports response: expected a data array');
|
|
244
|
+
}
|
|
245
|
+
for (const report of result.data) {
|
|
246
|
+
if (reportIds.has(report.id)) continue;
|
|
247
|
+
reportIds.add(report.id);
|
|
248
|
+
reports.push(report);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const next = result.links?.next;
|
|
252
|
+
url = next ? new URL(next, pageUrl).href : null;
|
|
210
253
|
}
|
|
211
|
-
|
|
254
|
+
|
|
255
|
+
return { ...result, data: reports };
|
|
212
256
|
}
|
|
213
257
|
|
|
214
258
|
async getPrograms() {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { NEXT_SECURITY_RELEASE_FOLDER } from './security-release.js';
|
|
5
|
+
import { prepareSecurityRelease } from './preparation.js';
|
|
6
|
+
|
|
7
|
+
export function getSecurityReleaseDraftPath(directory) {
|
|
8
|
+
if (typeof directory !== 'string' || !directory.trim()) {
|
|
9
|
+
throw new Error('Security release repository directory is required');
|
|
10
|
+
}
|
|
11
|
+
return path.resolve(directory, NEXT_SECURITY_RELEASE_FOLDER, 'vulnerabilities.json');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function assertNewSecurityRelease(directory) {
|
|
15
|
+
const file = getSecurityReleaseDraftPath(directory);
|
|
16
|
+
if (fs.existsSync(file)) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
`Security release draft already exists: ${file}. ` +
|
|
19
|
+
'Use --sync, --add-report, or --remove-report to update the existing release.'
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Local persistence only. The caller handles review, Git, and publication.
|
|
25
|
+
export function writeSecurityReleaseDraft(directory, draft) {
|
|
26
|
+
const { release } = prepareSecurityRelease(draft);
|
|
27
|
+
const file = getSecurityReleaseDraftPath(directory);
|
|
28
|
+
assertNewSecurityRelease(directory);
|
|
29
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
30
|
+
fs.writeFileSync(file, JSON.stringify(release, null, 2) + '\n', { flag: 'wx' });
|
|
31
|
+
return file;
|
|
32
|
+
}
|