UNPKG

@vigilbase/cli

Version:

Vigilbase CLI — security scanners, agent tools, and Vigilbase One from the terminal.

201 lines (200 loc) • 9.51 kB
import { isSystemTrustConfigured } from './system-trust.js'; export class NetworkError extends Error { code; hint; status = 0; /** `hint` is printed after the error; doctor, which is the connectivity check, leaves it out. */ constructor(message, code, cause, hint) { super(message, { cause }); this.code = code; this.hint = hint; this.name = 'NetworkError'; } } function errorCodes(error, seen = new Set()) { if (!error || typeof error !== 'object' || seen.has(error)) return []; seen.add(error); const item = error; return [ ...(typeof item.code === 'string' ? [item.code] : []), ...errorCodes(item.cause, seen), ...(Array.isArray(item.errors) ? item.errors.flatMap((e) => errorCodes(e, seen)) : []), ]; } /** Messages along the cause chain: undici reports a blocked port or a refused redirect only by message. */ function causeMessages(error, seen = new Set()) { if (!error || typeof error !== 'object' || seen.has(error)) return []; seen.add(error); const item = error; return [...(typeof item.message === 'string' ? [item.message] : []), ...causeMessages(item.cause, seen)]; } export function networkError(error, url, timeoutMs, timedOut) { // Never echo query strings, URL credentials, request bodies, or raw causes. const { origin: endpoint, port } = new URL(url); const codes = errorCodes(error); const code = codes[0] ?? 'NETWORK_ERROR'; const messages = causeMessages(error); if (timedOut || codes.some((c) => /TIMEOUT|ETIMEDOUT/.test(c))) { return new NetworkError(`Request to ${endpoint} timed out after ${timeoutMs}ms. Check your connection or increase --timeout.`, 'TIMEOUT', error); } if (messages.includes('bad port')) { return new NetworkError(`Node's fetch refuses port ${port} of ${endpoint} (a port the Fetch standard blocks). Use another port.`, 'BLOCKED_PORT', error); } if (messages.includes('unexpected redirect')) { return new NetworkError(`${endpoint} answered with a redirect, which the CLI does not follow.`, 'UNEXPECTED_REDIRECT', error); } if (codes.some((c) => /CERT|SELF_SIGNED|UNABLE_TO_VERIFY|UNABLE_TO_GET_ISSUER/.test(c))) { // Without system trust (older Node), the OS certificate store isn't used at all. const upgrade = isSystemTrustConfigured() ? '' : ' Use Node 22.19+ or Node 24.5+ for automatic system certificate trust.'; return new NetworkError(`TLS certificate verification failed for ${endpoint} (${code}).${upgrade} If your organization uses a private CA, configure NODE_EXTRA_CA_CERTS with its trusted PEM certificate.`, code, error); } if (codes.some((c) => c === 'ENOTFOUND' || c === 'EAI_AGAIN')) { return new NetworkError(`Cannot resolve ${endpoint} (${code}). Check DNS, your VPN, and the configured API URL.`, code, error); } return new NetworkError(`Cannot connect to ${endpoint} (${code}). Check your connection, VPN, proxy settings, and API URL.`, code, error, 'Run `vigilbase doctor` for connectivity checks.'); } const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); /** The option that sets this origin, when the caller didn't name it. */ function optionFor(config, origin) { if (origin === config.publicBaseUrl) return '--base-url'; if (origin === config.oneBaseUrl) return '--one-url'; if (origin === config.identityBaseUrl) return '--auth-url'; return undefined; } /** A redirect the CLI refuses, naming only the target's origin: its path and query may carry tokens. */ function redirectError(config, url, response, named) { const { origin } = new URL(url); let target; try { const location = new URL(response.headers.get('location') ?? '', url); if ((location.protocol === 'https:' || location.protocol === 'http:') && location.origin !== origin) target = location.origin; } catch { // An unparsable Location: say only that it redirected. } // Public, One and Identity may share an origin, so the caller's service decides the option. const option = named ?? optionFor(config, origin); const message = !target ? `${origin} answered with a redirect (HTTP ${response.status}), which the CLI does not follow.` : option ? `${origin} redirected (HTTP ${response.status}) to ${target}. Set ${option} to that origin.` : `${origin} redirected (HTTP ${response.status}) to ${target}, which the CLI does not follow.`; return new NetworkError(message, 'UNEXPECTED_REDIRECT'); } export const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; /** Bound decoded bytes even when Content-Length is absent, compressed, or dishonest. */ export async function readBoundedBody(response, signal, maxBytes = MAX_RESPONSE_BYTES) { if (Number(response.headers.get('content-length')) > maxBytes) { await response.body?.cancel(); throw new NetworkError('Response body exceeds the size limit.', 'RESPONSE_TOO_LARGE'); } if (!response.body) return ''; const reader = response.body.getReader(); const abort = () => { void reader.cancel().catch(() => undefined); }; signal.addEventListener('abort', abort, { once: true }); let size = 0; const chunks = []; try { while (true) { if (signal.aborted) throw new Error('Aborted'); const { done, value } = await reader.read(); if (signal.aborted) throw new Error('Aborted'); if (done) return Buffer.concat(chunks).toString('utf8'); size += value.byteLength; if (size > maxBytes) { await reader.cancel(); throw new NetworkError('Response body exceeds the size limit.', 'RESPONSE_TOO_LARGE'); } chunks.push(value); } } finally { signal.removeEventListener('abort', abort); reader.releaseLock(); } } /** * A signal that aborts with the reason of the first of `signals` to abort. * Uses `AbortSignal.any` (Node 20.3+) and combines them by hand on older * Node 20; `dispose` then removes the listeners so a long-lived signal does * not collect one per request. */ export function anySignal(signals) { if (typeof AbortSignal.any === 'function') return { signal: AbortSignal.any(signals), dispose: () => undefined }; const controller = new AbortController(); const dispose = () => { for (const signal of signals) signal.removeEventListener('abort', onAbort); }; function onAbort(event) { dispose(); controller.abort(event.target.reason); } const aborted = signals.find((signal) => signal.aborted); if (aborted) { controller.abort(aborted.reason); return { signal: controller.signal, dispose: () => undefined }; } for (const signal of signals) signal.addEventListener('abort', onAbort, { once: true }); return { signal: controller.signal, dispose }; } /** * Keep the timeout active until the complete response body has been read. `option` names the option that sets the * service's origin, for the message when it redirects. */ export async function requestText(config, url, { option, ...init } = {}) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), config.timeoutMs); const { signal, dispose } = init.signal ? anySignal([init.signal, controller.signal]) : { signal: controller.signal, dispose: () => undefined }; try { // Never follow redirects: they could carry token-bearing POST bodies (or credentials) elsewhere. // 'manual' surfaces the 3xx, which is refused here unless the caller asked for it. const response = await fetch(url, { ...init, signal, redirect: 'manual' }); if (init.redirect !== 'manual' && REDIRECT_STATUSES.has(response.status) && response.headers.has('location')) { await response.body?.cancel().catch(() => undefined); throw redirectError(config, url, response, option); } return { response, text: await readBoundedBody(response, signal) }; } catch (error) { if (error instanceof NetworkError) throw error; // Fetch refuses a header value (a token with a newline) before sending anything. Its message holds the value. if (error instanceof TypeError && error.cause === undefined && /header/i.test(error.message)) { throw Object.assign(new Error('A request header contains characters HTTP does not allow, so nothing was sent.'), { code: 'INVALID_ARGUMENT' }); } if (init.signal?.aborted) { throw new NetworkError('Request cancelled.', 'CANCELLED', error); } throw networkError(error, url, config.timeoutMs, controller.signal.aborted); } finally { clearTimeout(timer); dispose(); } } export function parseJsonResponse(response, text) { try { // Empty successful responses are valid for some mutation endpoints. return text ? JSON.parse(text) : null; } catch { if (!response.ok) return null; throw new NetworkError(`Expected JSON but received a non-JSON response (HTTP ${response.status}). Check the configured API URL and any proxy or sign-in page.`, 'INVALID_RESPONSE'); } }