UNPKG

@cyclonedx/cdxgen

Version:

Creates CycloneDX Software Bill of Materials (SBOM) from source or container image

159 lines (152 loc) 5.8 kB
import { Buffer } from "node:buffer"; import { PassThrough } from "node:stream"; import { URL } from "node:url"; import { Client, interceptors, Pool, request as undiciRequest } from "undici"; /** * undici-backed connection helper for talking to the local Docker / Podman * daemon (over a unix socket) or a remote daemon (over TCP/TLS). This replaces * cdxgen's previous use of `got` for daemon communication. * * @module dockerConnection */ // Retry policy for transient Docker/Podman daemon and registry failures. Mirrors // the retry behaviour cdxgen previously configured on the got client. export const DAEMON_RETRY_OPTIONS = { maxRetries: 3, methods: ["GET", "POST", "HEAD"], statusCodes: [408, 413, 429, 500, 502, 503, 504, 521, 522, 524], }; // Bun's bundled `undici` module does not implement the `compose`/interceptor // API (see cdxgen issue #4245). Detect it so we can skip the retry interceptor // and use the plain dispatcher instead of crashing. const isBun = typeof globalThis.Bun !== "undefined"; /** * Parse a got-style Docker daemon `prefixUrl` into the pieces undici needs. * Unix socket URLs use the form `http://unix:/path/to/socket:`; anything else * is treated as a regular TCP/TLS base URL. * * @param {string} prefixUrl got-style prefix URL for the daemon endpoint. * @returns {{socketPath: (string|undefined), baseUrl: string}} The unix socket * path (when applicable) and the origin to use as the undici base URL. */ export const parseDaemonPrefixUrl = (prefixUrl) => { const unixMarker = "unix:"; const markerIndex = prefixUrl.indexOf(unixMarker); if (markerIndex !== -1) { // Everything between `unix:` and the trailing `:` is the socket path. let socketPath = prefixUrl.slice(markerIndex + unixMarker.length); if (socketPath.endsWith(":")) { socketPath = socketPath.slice(0, -1); } return { socketPath, baseUrl: "http://localhost" }; } return { socketPath: undefined, baseUrl: new URL(prefixUrl).origin }; }; /** * Create a connection to the Docker / Podman daemon backed by undici. The * returned object mimics the small slice of the got client that cdxgen relies * on: a `request()` method returning a parsed body and a synchronous `stream()` * method returning a readable image tar stream. * * @param {string} prefixUrl got-style daemon prefix URL (unix socket or TCP). * @param {Object} [headers] Default headers (e.g. `X-Registry-Auth`) to send. * @param {Object} [https] TLS material for remote daemons * (`{ certificate, key, rejectUnauthorized }`). * @returns {Object} Connection object with `request`, `stream`, `close`, * `prefixUrl` and `baseUrl`. */ export const createDaemonConnection = (prefixUrl, headers, https) => { const { socketPath, baseUrl } = parseDaemonPrefixUrl(prefixUrl); const connect = {}; if (https?.certificate) { connect.cert = https.certificate; } if (https?.key) { connect.key = https.key; } if (https?.rejectUnauthorized === false) { connect.rejectUnauthorized = false; } const hasConnectOptions = Object.keys(connect).length > 0; const baseDispatcher = socketPath ? new Client(baseUrl, { socketPath, ...(hasConnectOptions ? { connect } : {}), }) : new Pool(baseUrl, hasConnectOptions ? { connect } : undefined); const dispatcher = isBun ? baseDispatcher : baseDispatcher.compose(interceptors.retry(DAEMON_RETRY_OPTIONS)); const toUrl = (path) => `${baseUrl}/${String(path).replace(/^\//, "")}`; const mergedHeaders = (extra) => ({ ...(headers || {}), ...(extra || {}) }); return { prefixUrl, baseUrl, /** * Send a request to the daemon and resolve to the response body. * * @param {string} path API path relative to the daemon base URL. * @param {Object} [opts] `{ method, headers, body, responseType }`. * @returns {Promise<Object|Buffer>} Parsed JSON for `responseType: "json"`, * otherwise a Buffer. */ async request(path, opts = {}) { const method = (opts.method || "GET").toUpperCase(); const res = await undiciRequest(toUrl(path), { method, headers: mergedHeaders(opts.headers), body: opts.body, dispatcher, }); if (res.statusCode >= 400) { await res.body.arrayBuffer(); const error = new Error( `The container daemon responded with HTTP ${res.statusCode}.`, ); error.statusCode = res.statusCode; throw error; } if (opts.responseType === "json") { return await res.body.json(); } return Buffer.from(await res.body.arrayBuffer()); }, /** * Return a readable stream of the response body for the given path. The * stream is returned synchronously so it can be handed directly to * `stream.pipeline`. * * @param {string} path API path relative to the daemon base URL. * @returns {import("node:stream").Readable} Readable response body stream. */ stream(path) { const passThrough = new PassThrough(); undiciRequest(toUrl(path), { method: "GET", headers, dispatcher }) .then((res) => { if (res.statusCode >= 400) { passThrough.destroy( new Error( `The container daemon responded with HTTP ${res.statusCode}.`, ), ); return; } res.body.pipe(passThrough); }) .catch((error) => passThrough.destroy(error)); return passThrough; }, /** * Close the underlying undici dispatcher, releasing pooled sockets. * * @returns {Promise<void>} */ async close() { try { await baseDispatcher.close(); } catch { // ignore } }, }; };