import fs from 'node:fs';
import { fetch } from 'undici';
import { CI_DOMAIN } from './ci/ci_type_parser.js';
import proxy from './proxy.js';
import {
isDebugVerbosity,
debuglog
} from './verbosity.js';
function wrappedFetch(url, options, ...args) {
if (isDebugVerbosity()) {
debuglog('[fetch]', url);
}
return fetch(url, options, ...args);
}
export default class Request {
constructor(credentials) {
this.credentials = credentials;
this.proxyAgent = proxy();
}
loadQuery(file) {
const filePath = new URL(`./queries/${file}.gql`, import.meta.url);
return fs.readFileSync(filePath, 'utf8');
}
async fetch(url, options) {
options.agent = this.proxyAgent;
if (url.startsWith(`https://${CI_DOMAIN}`)) {
options.headers = options.headers || {};
Object.assign(options.headers, this.getJenkinsHeaders());
} else if (!url.startsWith('https://')) {
url = new URL(url, 'https://api.github.com/').href;
options.headers = {
Authorization: `Basic ${this.credentials.github}`,
'User-Agent': 'node-core-utils',
Accept: 'application/vnd.github+json',
...options.headers
};
}
return wrappedFetch(url, options);
}
async buffer(url, options = {}) {
const res = await this.fetch(url, options);
const buffer = await res.arrayBuffer();
return Buffer.from(buffer);
}
async text(url, options = {}) {
const res = await this.fetch(url, options);
if (isDebugVerbosity()) {
debuglog('[Request] Got response from', url, ':\n', res.status, ' ', res.statusText);
}
return res.text();
}
async * stream(url, options = {}) {
const res = await this.fetch(url, options);
if (!res.ok) {
await res.body?.cancel();
throw new Error(`Unable to stream ${url}: ${res.status} ${res.statusText}`);
}
// Undici decompresses HTTP content encodings as it reads. Returning early
// from this iterator also cancels the response body and its HTTP download.
yield * res.body;
}
async json(url, options = {}) {
options.headers = options.headers || {};
const text = await this.text(url, options);
try {
return JSON.parse(text);
} catch (e) {
if (isDebugVerbosity()) {
debuglog('[Request] Cannot parse JSON response from',
url, ':\n', text);
}
throw e;
}
}
async createIssue(title, body, { owner, repo }) {
const url = `/repos/${owner}/${repo}/issues`;
const options = {
method: 'POST',
body: JSON.stringify({
title,
body
})
};
return this.json(url, options);
}
async commentIssue(fullUrl, comment) {
const commentUrl = fullUrl.replace('https://github.com/', '/repos/') + '/comments';
const options = {
method: 'POST',
body: JSON.stringify({
body: comment,
})
};
return this.json(commentUrl, options);
}
async getPullRequest(fullUrl) {
const prUrl = fullUrl.replace('https://github.com/', '/repos/').replace('pull', 'pulls');
return this.json(prUrl);
}
async * getPullRequestFiles({ owner, repo, prid }) {
let page = 1;
for (;;) {
const url =
`/repos/${owner}/${repo}/pulls/${prid}/files?per_page=100&page=${page}`;
const batch = await this.json(url);
if (!Array.isArray(batch)) {
throw new Error('Unable to retrieve pull request files');
}
if (batch.length === 0) {
break;
}
yield * batch;
if (batch.length < 100) {
break;
}
page++;
}
}
async listDirectory({ owner, repo, path, ref }) {
let url = `/repos/${owner}/${repo}/contents/${path}`;
if (ref) {
url += `?ref=${encodeURIComponent(ref)}`;
}
return this.json(url);
}
async dispatchWorkflow(workflowId, { owner, repo, ref, inputs }) {
const url =
`/repos/${owner}/${repo}/actions/workflows/${workflowId}/dispatches`;
const res = await this.fetch(url, {
method: 'POST',
body: JSON.stringify({ ref, inputs })
});
if (!res.ok) {
const text = await res.text();
throw new Error(
`Failed to dispatch workflow ${workflowId} on ${owner}/${repo}: ` +
`${res.status} ${res.statusText}${text ? `\n${text}` : ''}`);
}
return res;
}
async createPullRequest(title, body, { owner, repo, head, base }) {
const url = `/repos/${owner}/${repo}/pulls`;
const options = {
method: 'POST',
body: JSON.stringify({
title,
body,
head,
base
})
};
return this.json(url, options);
}
async closePullRequest(id, { owner, repo }) {
const url = `/repos/${owner}/${repo}/pulls/${id}`;
const options = {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
state: 'closed'
})
};
return this.json(url, options);
}
async gql(name, variables, path) {
const query = this.loadQuery(name);
if (path) {
const result = await this.queryAll(query, variables, path);
return result;
} else {
const result = await this.query(query, variables);
return result;
}
}
getJenkinsHeaders() {
const jenkinsCredentials = this.credentials.jenkins;
if (!jenkinsCredentials) {
throw new Error('The request has not been ' +
'authenticated with a Jenkins token');
}
return {
Authorization: `Basic ${jenkinsCredentials}`,
'User-Agent': 'node-core-utils'
};
}
async getTriagedReports() {
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 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 { ...result, data: reports };
}
async getPrograms() {
const url = 'https://api.hackerone.com/v1/me/programs';
const options = {
method: 'GET',
headers: {
Authorization: `Basic ${this.credentials.h1}`,
'User-Agent': 'node-core-utils',
Accept: 'application/json'
}
};
return this.json(url, options);
}
async requestCVE(programId, opts) {
const url = `https://api.hackerone.com/v1/programs/${programId}/cve_requests`;
const options = {
method: 'POST',
headers: {
Authorization: `Basic ${this.credentials.h1}`,
'User-Agent': 'node-core-utils',
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify(opts)
};
return this.json(url, options);
}
async updateReportCVE(reportId, opts) {
const url = `https://api.hackerone.com/v1/reports/${reportId}/cves`;
const options = {
method: 'PUT',
headers: {
Authorization: `Basic ${this.credentials.h1}`,
'User-Agent': 'node-core-utils',
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(opts)
};
return this.json(url, options);
}
async getReport(reportId) {
const url = `https://api.hackerone.com/v1/reports/${reportId}`;
const options = {
method: 'GET',
headers: {
Authorization: `Basic ${this.credentials.h1}`,
'User-Agent': 'node-core-utils',
Accept: 'application/json'
}
};
return this.json(url, options);
}
async updateReportState(reportId, state, message) {
const url = `https://api.hackerone.com/v1/reports/${reportId}/state_changes`;
const options = {
method: 'POST',
headers: {
Authorization: `Basic ${this.credentials.h1}`,
'User-Agent': 'node-core-utils',
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
data: {
type: 'state-change',
attributes: {
message,
state
}
}
})
};
return this.json(url, options);
}
async requestDisclosure(reportId) {
const url = `https://api.hackerone.com/v1/reports/${reportId}/disclosure_requests`;
const options = {
method: 'POST',
headers: {
Authorization: `Basic ${this.credentials.h1}`,
'User-Agent': 'node-core-utils',
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
data: {
attributes: {
message: 'Requesting disclosure',
// default to limited version
substate: 'no-content'
}
}
})
};
return this.json(url, options);
}
// This is for github v4 API queries, for other types of queries
// use .text or .json
async query(query, variables) {
const githubCredentials = this.credentials.github;
if (!githubCredentials) {
throw new Error('The request has not been ' +
'authenticated with a GitHub token');
}
const url = '/graphql';
const options = {
agent: this.proxyAgent,
method: 'POST',
headers: {
Accept: 'application/vnd.github.antiope-preview+json'
},
body: JSON.stringify({
query,
variables
})
};
const result = await this.json(url, options);
if (result.errors?.length > 0) {
const { type, message } = result.errors[0];
const err = new Error(`[${type}] GraphQL request Error: ${message}`);
err.data = {
variables,
errors: result.errors
};
throw err;
}
if (result.message) {
const err = new Error(`GraphQL request Error: ${result.message}`);
err.data = {
variables
};
throw err;
}
return result.data;
}
async queryAll(query, variables, path) {
let after = null;
let all = [];
// first page
do {
const varWithPage = Object.assign({
after
}, variables);
const data = await this.query(query, varWithPage);
let current = data;
for (const step of path) {
current = current[step];
}
// current should have:
// totalCount
// pageInfo { hasNextPage, endCursor }
// nodes
all = all.concat(current.nodes);
if (current.pageInfo.hasNextPage) {
after = current.pageInfo.endCursor;
} else {
after = null;
}
} while (after !== null);
return all;
}
// ---------------------------------------------------------------------------
// OpenJS Foundation CNA
//
// The Worker is a thin edge in front of `workflow_dispatch`. POST /dispatch
// returns a correlation id; GET /runs/{id} polls until the backing workflow
// run completes and surfaces the operation result.
// ---------------------------------------------------------------------------
cnaHeaders(extra = {}) {
const { token } = this.credentials.cna;
return {
Authorization: `Bearer ${token}`,
'User-Agent': 'node-core-utils',
Accept: 'application/json',
...extra
};
}
async cnaDispatch(operation, inputs = {}) {
const { worker_url } = this.credentials.cna;
const url = `${worker_url}/dispatch`;
const options = {
method: 'POST',
headers: this.cnaHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ operation, inputs })
};
const response = await this.json(url, options);
if (response.error) {
throw new Error(
`OpenJS CNA dispatch failed (${operation}): ${response.error}`
);
}
if (!response.correlation_id) {
throw new Error(
`OpenJS CNA dispatch (${operation}) returned no correlation_id`
);
}
return response;
}
async cnaPoll(correlationId) {
const { worker_url } = this.credentials.cna;
const url = `${worker_url}/runs/${correlationId}`;
const options = {
method: 'GET',
headers: this.cnaHeaders()
};
const response = await this.json(url, options);
if (response.error) {
throw new Error(
`OpenJS CNA poll failed (${correlationId}): ${response.error}`
);
}
return response;
}
// Polls /runs/{id} until the run reports status === 'completed'. Throws a
// `CnaTimeoutError` when the deadline passes (the run keeps going on the
// server, so callers should persist the correlation id and resume later),
// and a plain Error on a `conclusion` other than 'success'.
async cnaWaitForCompletion(correlationId, { timeoutMs = 600_000, intervalMs = 5_000 } = {}) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const run = await this.cnaPoll(correlationId);
if (run.status === 'completed') {
if (run.conclusion !== 'success') {
throw new Error(
`OpenJS CNA run ${correlationId} concluded with ` +
`'${run.conclusion}'. See ${run.url} for details.`
);
}
return run;
}
await new Promise(resolve => setTimeout(resolve, intervalMs));
}
const error = new Error(
`OpenJS CNA run ${correlationId} did not complete within ${timeoutMs}ms`
);
error.name = 'CnaTimeoutError';
error.correlationId = correlationId;
throw error;
}
}