@cyclonedx/cdxgen
Version:
Creates CycloneDX Software Bill of Materials (SBOM) from source or container image
639 lines (593 loc) • 22.9 kB
JavaScript
import { Buffer } from "node:buffer";
import process from "node:process";
import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
import {
Agent,
EnvHttpProxyAgent,
interceptors,
request as undiciRequest,
} from "undici";
/**
* A tiny, `got`-compatible HTTP client built on top of undici.
*
* cdxgen historically relied on the `got` library. `got` keeps a per-request
* HTTP cache backed by an EventEmitter which, during large `--deep` scans that
* issue thousands of parallel license/metadata lookups, leaks "error" listeners
* and floods the console with `MaxListenersExceededWarning` messages. undici
* uses a pooled dispatcher and does not exhibit this behaviour.
*
* This module intentionally implements only the subset of the `got` surface
* that cdxgen consumes:
*
* - Callable form: `client(url, options)` and the verb helpers `client.get`,
* `client.post`, `client.put` and `client.head`.
* - `client.extend(defaults)` to derive a new client with merged defaults.
* - Request options: `method`, `headers`, `body`, `json`, `responseType`
* (`"json"` | `"buffer"` | `"text"`), `throwHttpErrors`, `followRedirect`,
* `timeout` (number of milliseconds or a `got`-style phase object), `retry`
* (accepted for API compatibility; no automatic retries are performed),
* `https.rejectUnauthorized` and `context`.
* - `beforeRequest`, `afterResponse` and `beforeError` hooks with the same
* calling conventions cdxgen uses today. These hooks continue to power
* cdxgen's dry-run enforcement, host allow-listing, network-activity
* recording and HTTP trace logging.
* - Automatic response decompression (`gzip`, `deflate`, `br`) driven by the
* `Content-Encoding` header, matching `got`'s transparent decoding.
* - Proxy support via the standard `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY`
* environment variables (through undici's `EnvHttpProxyAgent`).
* - An in-memory GET response cache (enabled by default, disabled by setting
* the `CDXGEN_NO_CACHE` environment variable) that replaces the got + Keyv
* cache cdxgen previously relied on.
* - Response objects exposing `statusCode`, `headers`, `body`, `rawBody`,
* `url` and `request.options`.
* - A lazily-resolved `.json()` method on the returned promise, mirroring
* `got`'s `client(url).json()` usage.
*
* @module httpClient
*/
/**
* Error thrown when the server responds with a non 2xx/3xx status code and
* `throwHttpErrors` has not been disabled. Shaped like `got`'s `HTTPError` so
* that existing `error.response.statusCode` and `error.options.context` checks
* keep working.
*/
export class HTTPError extends Error {
/**
* @param {Object} response Response object produced by this client.
* @param {Object} options Merged request options for the failed request.
*/
constructor(response, options) {
super(
`Response code ${response.statusCode} (${response.statusMessage || "Request failed"})`,
);
this.name = "HTTPError";
this.response = response;
this.options = options;
this.code = "ERR_NON_2XX_3XX_RESPONSE";
}
}
/**
* Error thrown for transport-level failures (DNS, connection reset, timeouts).
* Carries the merged request `options` so `beforeError` hooks can inspect the
* request context.
*/
export class RequestError extends Error {
/**
* @param {Error} cause Underlying error thrown by undici.
* @param {Object} options Merged request options for the failed request.
*/
constructor(cause, options) {
super(cause.message);
this.name = "RequestError";
this.options = options;
this.code = cause.code;
this.cause = cause;
}
}
/**
* Merge two option objects one level deep. `headers`, `hooks` and `context`
* are merged recursively so per-call overrides do not discard the client
* defaults.
*
* @param {Object} base Base (default) options.
* @param {Object} override Per-call options.
* @returns {Object} Merged options object.
*/
function mergeOptions(base = {}, override = {}) {
const merged = { ...base, ...override };
merged.headers = mergeHeaders(base.headers, override.headers);
merged.context = { ...(base.context || {}), ...(override.context || {}) };
merged.hooks = mergeHooks(base.hooks, override.hooks);
return merged;
}
/**
* Merge two header objects, skipping `undefined` values so callers can pass
* `headers: undefined` without clobbering the client defaults.
*
* @param {Object} [base] Default headers.
* @param {Object} [override] Per-call headers.
* @returns {Object} Merged headers.
*/
function mergeHeaders(base = {}, override = {}) {
const headers = { ...(base || {}) };
for (const [key, value] of Object.entries(override || {})) {
if (value !== undefined) {
headers[key] = value;
}
}
return headers;
}
/**
* Concatenate hook arrays from the defaults and per-call options so both run.
*
* @param {Object} [base] Default hooks.
* @param {Object} [override] Per-call hooks.
* @returns {Object} Merged hooks with `beforeRequest`, `afterResponse` and
* `beforeError` arrays.
*/
function mergeHooks(base = {}, override = {}) {
const hookNames = ["beforeRequest", "afterResponse", "beforeError"];
const merged = {};
for (const name of hookNames) {
merged[name] = [...(base?.[name] || []), ...(override?.[name] || [])];
}
return merged;
}
/**
* Translate a `got`-style timeout option into a single total-request timeout in
* milliseconds understood by an `AbortSignal`. A plain number is used verbatim.
* A phase object (e.g. `{ connect, send, response }`) is reduced to the sum of
* its phases, which provides a sensible upper bound for the whole request.
*
* @param {number|Object} [timeout] `got`-style timeout option.
* @returns {number|undefined} Total timeout in milliseconds, or `undefined`.
*/
// Default per-request timeout (ms). undici (unlike got) applies no request
// timeout by default, so an unresponsive host would hang a scan indefinitely.
// 15s is generous for metadata/registry lookups; raise it for slow networks or
// large downloads via CDXGEN_HTTP_TIMEOUT_MS. Capped at 1h so a typo cannot
// effectively disable the timeout.
const DEFAULT_HTTP_TIMEOUT_MS = 15000;
const MAX_HTTP_TIMEOUT_MS = 3600000;
/**
* Resolve the default HTTP request timeout, honoring the validated
* CDXGEN_HTTP_TIMEOUT_MS environment variable when set to a positive integer.
*
* @returns {number} Timeout in milliseconds
*/
export function getDefaultHttpTimeoutMs() {
const raw = process.env.CDXGEN_HTTP_TIMEOUT_MS;
if (raw === undefined || raw === "") {
return DEFAULT_HTTP_TIMEOUT_MS;
}
const parsed = Number.parseInt(raw, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
return DEFAULT_HTTP_TIMEOUT_MS;
}
return Math.min(parsed, MAX_HTTP_TIMEOUT_MS);
}
export function resolveTimeout(timeout) {
if (timeout === undefined || timeout === null) {
return undefined;
}
if (typeof timeout === "number") {
return timeout;
}
if (typeof timeout === "object") {
if (typeof timeout.request === "number") {
return timeout.request;
}
const phases = [
timeout.connect,
timeout.secureConnect,
timeout.send,
timeout.response,
timeout.socket,
].filter((value) => typeof value === "number");
if (phases.length) {
return phases.reduce((total, value) => total + value, 0);
}
}
return undefined;
}
/**
* Decompress a response body buffer according to its `Content-Encoding`. undici
* performs no automatic decompression, so we replicate the behaviour `got` gave
* us for `gzip`, `deflate` and `br` encoded responses. Multiple, comma-separated
* encodings are applied in reverse order. Unknown encodings and decompression
* failures fall back to returning the buffer untouched.
*
* @param {Buffer} rawBody Raw response bytes.
* @param {string} [contentEncoding] Value of the Content-Encoding header.
* @returns {Buffer} Decompressed (or original) buffer.
*/
function decompressBody(rawBody, contentEncoding) {
if (!contentEncoding || rawBody.length === 0) {
return rawBody;
}
const encodings = contentEncoding
.split(",")
.map((encoding) => encoding.trim().toLowerCase())
.filter(Boolean);
let buffer = rawBody;
for (const encoding of encodings.reverse()) {
try {
if (encoding === "gzip" || encoding === "x-gzip") {
buffer = gunzipSync(buffer);
} else if (encoding === "deflate") {
buffer = inflateSync(buffer);
} else if (encoding === "br") {
buffer = brotliDecompressSync(buffer);
} else if (encoding === "identity") {
// no-op
} else {
// Unknown encoding; leave the buffer as-is.
return buffer;
}
} catch {
// Decompression failed (e.g. body was not actually encoded); return what
// we have so callers can attempt to parse it.
return buffer;
}
}
return buffer;
}
/**
* Read and decode an undici response body according to the requested
* `responseType`.
*
* @param {Object} undiciResponse Response returned by `undici.request`.
* @param {string} [responseType] One of `"json"`, `"buffer"` or `"text"`.
* @returns {Promise<{body: any, rawBody: Buffer}>} Decoded body and raw bytes.
*/
async function readBody(undiciResponse, responseType) {
const arrayBuffer = await undiciResponse.body.arrayBuffer();
let rawBody = Buffer.from(arrayBuffer);
// undici (unlike got) does not transparently decompress response bodies, so
// honor the Content-Encoding header ourselves. Some registries (e.g. the
// nuget registration5-gz-semver2 endpoint) always return gzip-encoded JSON.
rawBody = decompressBody(
rawBody,
undiciResponse.headers?.["content-encoding"],
);
let body;
if (responseType === "buffer") {
body = rawBody;
} else if (responseType === "json") {
const text = rawBody.toString("utf-8");
body = text.length ? JSON.parse(text) : undefined;
} else {
body = rawBody.toString("utf-8");
}
return { body, rawBody };
}
/**
* Convert a body into the `[body, headers]` pair to hand to undici. When the
* `json` option is present it is serialized and a JSON `content-type` is added
* unless the caller already set one.
*
* @param {Object} options Merged request options.
* @returns {{body: (string|Buffer|undefined), headers: Object}} Request body
* and (possibly augmented) headers.
*/
function resolveRequestBody(options) {
const headers = { ...(options.headers || {}) };
if (options.json !== undefined) {
const hasContentType = Object.keys(headers).some(
(key) => key.toLowerCase() === "content-type",
);
if (!hasContentType) {
headers["content-type"] = "application/json";
}
return { body: JSON.stringify(options.json), headers };
}
return { body: options.body, headers };
}
// In-memory cache of successful GET responses, mirroring the HTTP cache cdxgen
// historically kept via got + Keyv. Repeated metadata/license lookups during a
// scan are served from here instead of hitting the network again. Set the
// CDXGEN_NO_CACHE environment variable to "true" or "1" to disable it.
const responseCache = new Map();
/**
* Determine whether the in-memory HTTP response cache is disabled via the
* CDXGEN_NO_CACHE environment variable. Evaluated per request so tests and
* callers can toggle it at runtime.
*
* @returns {boolean} True when caching should be skipped.
*/
export function isCacheDisabled() {
const value = process.env.CDXGEN_NO_CACHE;
return value === "true" || value === "1";
}
/**
* Clear the in-memory HTTP response cache. Primarily useful for tests.
*
* @returns {void}
*/
export function clearHttpCache() {
responseCache.clear();
}
/**
* Build the cache key for a request. Only the method and normalized URL are
* used, matching got's default cache key behaviour.
*
* @param {string} method Uppercased HTTP method.
* @param {URL} url Request URL.
* @returns {string} Cache key.
*/
function cacheKeyFor(method, url) {
return `${method}:${url.toString()}`;
}
// The maximum number of redirects to follow when `followRedirect` is enabled.
const MAX_REDIRECTIONS = 10;
// Bun ships its own bundled implementation of the `undici` module which, as of
// Bun 1.2, does not expose the `Agent.prototype.compose` composition API nor the
// `interceptors.redirect` interceptor that undici v7 uses for redirect handling
// (see cdxgen issue #4245). Detect the Bun runtime so we can fall back to plain
// dispatchers and follow redirects manually instead of crashing at module load.
const isBun = typeof globalThis.Bun !== "undefined";
// Reusable pooled dispatchers. On Node.js, undici composes redirect handling as
// an interceptor (there is no per-request `maxRedirections` option in undici v7),
// so we pre-build the small set of dispatcher variants we need instead of
// allocating a fresh Agent per request (which would defeat connection pooling).
// On Bun, `compose`/`interceptors.redirect` are unavailable, so the redirect
// dispatchers are just the plain agents and redirects are handled in
// `requestWithRedirects` below.
// Whether standard proxy environment variables are set. When they are, requests
// must be routed through the proxy. Because this client always passes an
// explicit per-request `dispatcher`, it overrides Node's global (potentially
// proxy-aware) dispatcher, so we have to build proxy-aware dispatchers here or
// HTTP_PROXY/HTTPS_PROXY/NO_PROXY (documented in docs/ENV.md) are silently
// ignored. `EnvHttpProxyAgent` reads those variables itself, including NO_PROXY
// bypass handling, per request.
function hasProxyEnv() {
return Boolean(
process.env.HTTP_PROXY ||
process.env.http_proxy ||
process.env.HTTPS_PROXY ||
process.env.https_proxy ||
process.env.ALL_PROXY ||
process.env.all_proxy,
);
}
// Build a base dispatcher, honoring proxy environment variables when present.
// `EnvHttpProxyAgent` is unavailable in Bun's bundled undici, so fall back to a
// plain Agent there (matching pre-existing behaviour: no proxy support on Bun).
function createBaseAgent(agentOptions = {}) {
if (hasProxyEnv() && typeof EnvHttpProxyAgent === "function" && !isBun) {
try {
return new EnvHttpProxyAgent(agentOptions);
} catch {
// Fall through to a plain Agent if the proxy agent cannot be constructed.
}
}
return new Agent(agentOptions);
}
const defaultAgent = createBaseAgent();
const insecureAgent = createBaseAgent({
connect: { rejectUnauthorized: false },
});
const redirectDispatcher = isBun
? defaultAgent
: defaultAgent.compose(
interceptors.redirect({ maxRedirections: MAX_REDIRECTIONS }),
);
const insecureRedirectDispatcher = isBun
? insecureAgent
: insecureAgent.compose(
interceptors.redirect({ maxRedirections: MAX_REDIRECTIONS }),
);
/**
* Pick the undici dispatcher matching the request's redirect and TLS
* verification preferences.
*
* @param {boolean} followRedirect Whether HTTP redirects should be followed.
* @param {boolean} insecure Whether TLS certificate verification is disabled.
* @returns {import("undici").Dispatcher} The dispatcher to use for the request.
*/
function selectDispatcher(followRedirect, insecure) {
if (insecure) {
return followRedirect ? insecureRedirectDispatcher : insecureAgent;
}
return followRedirect ? redirectDispatcher : defaultAgent;
}
// HTTP status codes that represent a redirect with a `location` header.
const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]);
/**
* Issue an undici request, following redirects manually when running under Bun.
*
* On Node.js the redirect-following interceptor baked into the dispatcher does
* the work, so this simply forwards to `undiciRequest`. On Bun (where the
* interceptor API is unavailable) we loop on 3xx responses, draining each
* intermediate body and resolving relative `location` headers, up to
* `MAX_REDIRECTIONS` hops.
*
* @param {URL} url Request URL.
* @param {Object} requestOptions Options passed to `undiciRequest`.
* @param {boolean} followRedirect Whether redirects should be followed.
* @returns {Promise<Object>} The final undici response.
*/
async function requestWithRedirects(url, requestOptions, followRedirect) {
if (!isBun || !followRedirect) {
return await undiciRequest(url, requestOptions);
}
let currentUrl = url;
let redirections = 0;
while (true) {
const response = await undiciRequest(currentUrl, requestOptions);
const location = response.headers?.location;
if (
!REDIRECT_STATUS_CODES.has(response.statusCode) ||
!location ||
redirections >= MAX_REDIRECTIONS
) {
return response;
}
// Drain the intermediate response body so the connection can be reused.
try {
await response.body.dump();
} catch {
// Ignore drain failures; the socket will be discarded.
}
currentUrl = new URL(location, currentUrl);
redirections += 1;
// 303, and 301/302 for anything other than GET/HEAD, must switch to GET
// without a body, matching how undici's redirect interceptor behaves.
const method = (requestOptions.method || "GET").toUpperCase();
if (
response.statusCode === 303 ||
((response.statusCode === 301 || response.statusCode === 302) &&
method !== "GET" &&
method !== "HEAD")
) {
requestOptions.method = "GET";
requestOptions.body = undefined;
}
}
}
/**
* Perform a single HTTP request and return a `got`-like response object,
* running the configured hooks along the way.
*
* @param {Object} mergedOptions Fully merged request options including `url`.
* @returns {Promise<Object>} Response object with `statusCode`, `headers`,
* `body`, `rawBody`, `url` and `request.options`.
*/
async function doRequest(mergedOptions) {
const options = mergedOptions;
const url =
options.url instanceof URL ? options.url : new URL(String(options.url));
options.url = url;
// beforeRequest hooks may mutate options (e.g. set context) or throw to abort
// the request (dry-run mode). Return values are ignored, matching how cdxgen
// uses these hooks with got.
for (const hook of options.hooks?.beforeRequest || []) {
await hook(options);
}
const { body, headers } = resolveRequestBody(options);
const method = (options.method || "GET").toUpperCase();
// Serve cacheable GET requests from the in-memory cache when enabled.
const cacheable = method === "GET" && !isCacheDisabled();
const cacheKey = cacheable ? cacheKeyFor(method, url) : undefined;
if (cacheable && responseCache.has(cacheKey)) {
return responseCache.get(cacheKey);
}
const timeoutMs = resolveTimeout(options.timeout);
const signal = timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined;
const followRedirect = options.followRedirect !== false;
const insecure = options.https?.rejectUnauthorized === false;
const requestOptions = {
method,
headers,
body: method === "GET" || method === "HEAD" ? undefined : body,
dispatcher: selectDispatcher(followRedirect, insecure),
signal,
};
let undiciResponse;
try {
undiciResponse = await requestWithRedirects(
url,
requestOptions,
followRedirect,
);
} catch (err) {
const requestError = new RequestError(err, options);
for (const hook of options.hooks?.beforeError || []) {
await hook(requestError);
}
throw requestError;
}
const { body: decodedBody, rawBody } = await readBody(
undiciResponse,
options.responseType,
);
const response = {
statusCode: undiciResponse.statusCode,
headers: undiciResponse.headers,
body: decodedBody,
rawBody,
url: url.toString(),
request: { options },
};
const throwHttpErrors = options.throwHttpErrors !== false;
if (throwHttpErrors && response.statusCode >= 400) {
const httpError = new HTTPError(response, options);
for (const hook of options.hooks?.beforeError || []) {
await hook(httpError);
}
throw httpError;
}
let finalResponse = response;
for (const hook of options.hooks?.afterResponse || []) {
finalResponse = (await hook(finalResponse)) || finalResponse;
}
// Only successful responses are cached, matching HTTP cache semantics.
if (cacheable && finalResponse.statusCode < 400) {
responseCache.set(cacheKey, finalResponse);
}
return finalResponse;
}
/**
* Kick off a request and return a promise augmented with a lazy `.json()`
* helper, mirroring `got`'s `client(url).json()` convenience.
*
* @param {Object} mergedOptions Fully merged request options including `url`.
* @returns {Promise<Object> & {json: function(): Promise<any>}} Response promise.
*/
function requestWithHelpers(mergedOptions) {
const promise = doRequest(mergedOptions);
promise.json = () =>
promise.then((response) => {
if (typeof response.body === "string") {
return response.body.length ? JSON.parse(response.body) : undefined;
}
if (Buffer.isBuffer(response.body)) {
const text = response.body.toString("utf-8");
return text.length ? JSON.parse(text) : undefined;
}
return response.body;
});
return promise;
}
/**
* Create a `got`-compatible HTTP client bound to the supplied defaults.
*
* @param {Object} [defaults] Default request options merged into every call.
* @returns {Function} Callable client exposing `get`/`post`/`put`/`head`,
* `extend`, `defaults` and `hooks`.
*/
export function createHttpClient(defaults = {}) {
const clientDefaults = {
...defaults,
headers: { ...(defaults.headers || {}) },
hooks: mergeHooks(defaults.hooks),
context: { ...(defaults.context || {}) },
};
const client = (url, options = {}) =>
requestWithHelpers(mergeOptions(clientDefaults, { ...options, url }));
const verb =
(method) =>
(url, options = {}) =>
requestWithHelpers(
mergeOptions(clientDefaults, { ...options, method, url }),
);
client.get = verb("GET");
client.post = verb("POST");
client.put = verb("PUT");
client.head = verb("HEAD");
client.delete = verb("DELETE");
/**
* Derive a new client whose defaults are this client's defaults merged with
* the supplied options.
*
* @param {Object} [moreDefaults] Additional defaults to merge.
* @returns {Function} A new client instance.
*/
client.extend = (moreDefaults = {}) =>
createHttpClient(mergeOptions(clientDefaults, moreDefaults));
// Expose the resolved defaults and hooks the same way got does, so existing
// code and tests can reach `client.defaults.options.hooks` and `client.hooks`.
client.defaults = { options: clientDefaults };
client.hooks = clientDefaults.hooks;
return client;
}