diff --git a/src/lib/api/issues.ts b/src/lib/api/issues.ts index c235cff67..66155a917 100644 --- a/src/lib/api/issues.ts +++ b/src/lib/api/issues.ts @@ -9,7 +9,12 @@ import { listAnOrganization_sIssues } from "@sentry/api"; import type { SentryIssue } from "../../types/index.js"; import type { IssueSubstatus } from "../../types/sentry.js"; - +import { + buildTlsErrorDetail, + customFetch, + isTlsCertError, + warnIfSaasWithEnvCa, +} from "../custom-ca.js"; import { applyCustomHeaders } from "../custom-headers.js"; import { ApiError, ValidationError } from "../errors.js"; import { resolveOrgRegion } from "../region.js"; @@ -694,7 +699,22 @@ export async function getSharedIssue( // URL-scoped: headers only attach when `url`'s origin matches the trusted // host, so IAP tokens etc. can't leak to an attacker-controlled share URL. applyCustomHeaders(headers, url); - const response = await fetch(url, { headers }); + warnIfSaasWithEnvCa(url); + + let response: Response; + try { + response = await customFetch(url, { headers }); + } catch (error) { + if (error instanceof Error && isTlsCertError(error)) { + throw new ApiError( + "TLS certificate error", + 0, + buildTlsErrorDetail(error), + `shared/issues/${shareId}` + ); + } + throw error; + } if (!response.ok) { if (response.status === 404) { diff --git a/src/lib/binary.ts b/src/lib/binary.ts index f612d0cb0..408bea1b0 100644 --- a/src/lib/binary.ts +++ b/src/lib/binary.ts @@ -15,6 +15,11 @@ import { import { chmod, mkdir, unlink } from "node:fs/promises"; import { delimiter, join, resolve } from "node:path"; import { getUserAgent } from "./constants.js"; +import { + buildTlsErrorDetail, + customFetch, + isTlsCertError, +} from "./custom-ca.js"; import { stringifyUnknown, UpgradeError } from "./errors.js"; /** Known directories where the curl installer may place the binary */ @@ -232,12 +237,15 @@ export async function fetchWithUpgradeError( serviceName: string ): Promise { try { - return await fetch(url, init); + return await customFetch(url, init); } catch (error) { // Re-throw AbortError as-is so callers can handle it specifically if (error instanceof Error && error.name === "AbortError") { throw error; } + if (error instanceof Error && isTlsCertError(error)) { + throw new UpgradeError("network_error", buildTlsErrorDetail(error)); + } const msg = stringifyUnknown(error); throw new UpgradeError( "network_error", diff --git a/src/lib/custom-ca.ts b/src/lib/custom-ca.ts index e95d0c890..c90c8e639 100644 --- a/src/lib/custom-ca.ts +++ b/src/lib/custom-ca.ts @@ -280,6 +280,38 @@ export function buildTlsErrorDetail(error: Error): string { ); } +/** + * Get the combined CA certificate PEM string for Node.js `http.request()`. + * Returns undefined when no custom CAs are configured. + * + * Unlike {@link getCustomTlsOptions} (which returns Bun's `{ tls: { ca } }` shape), + * this returns the raw PEM string suitable for Node's `https.RequestOptions.ca` + * and the Sentry SDK's `NodeTransportOptions.caCerts`. + */ +export function getCustomCaCerts(): string | undefined { + resolve(); + return resolved?.tls.ca; +} + +/** + * Drop-in replacement for `fetch()` that injects custom CA certificates + * when configured. All non-authenticated fetch call sites should use this + * instead of bare `fetch()`. + * + * Authenticated API calls go through `fetchWithTimeout()` in sentry-client.ts + * which already applies TLS options directly alongside the SaaS warning. + */ +export function customFetch( + input: string | URL | Request, + init?: RequestInit +): Promise { + const tlsOpts = getCustomTlsOptions(); + if (!tlsOpts) { + return fetch(input, init); + } + return fetch(input, { ...init, ...tlsOpts }); +} + /** * Reset all cached state. Exported for test isolation only. * @internal diff --git a/src/lib/delta-upgrade.ts b/src/lib/delta-upgrade.ts index 8aece148a..a9d35f4f2 100644 --- a/src/lib/delta-upgrade.ts +++ b/src/lib/delta-upgrade.ts @@ -30,6 +30,7 @@ import { } from "./binary.js"; import { applyPatch } from "./bspatch.js"; import { CLI_VERSION } from "./constants.js"; +import { customFetch } from "./custom-ca.js"; import { formatBytes } from "./formatters/numbers.js"; import { downloadLayerBlob, @@ -165,14 +166,15 @@ export async function fetchRecentReleases( const perPage = MAX_STABLE_CHAIN_DEPTH + 2; let response: Response; try { - response = await fetch(`${GITHUB_RELEASES_URL}?per_page=${perPage}`, { + response = await customFetch(`${GITHUB_RELEASES_URL}?per_page=${perPage}`, { headers: { Accept: "application/vnd.github.v3+json", "User-Agent": "sentry-cli", }, signal, }); - } catch { + } catch (error) { + log.debug("Failed to fetch recent releases from GitHub", error); return []; } if (!response.ok) { @@ -210,11 +212,12 @@ export async function downloadStablePatch( ): Promise { let response: Response; try { - response = await fetch(url, { + response = await customFetch(url, { headers: { "User-Agent": "sentry-cli" }, signal, }); - } catch { + } catch (error) { + log.debug("Failed to download stable patch", error); return null; } if (!response.ok) { diff --git a/src/lib/ghcr.ts b/src/lib/ghcr.ts index 4cc221dec..43d6deb7e 100644 --- a/src/lib/ghcr.ts +++ b/src/lib/ghcr.ts @@ -18,6 +18,7 @@ */ import { getUserAgent } from "./constants.js"; +import { customFetch } from "./custom-ca.js"; import { UpgradeError } from "./errors.js"; /** Default timeout for GHCR HTTP requests (10 seconds) */ @@ -105,7 +106,7 @@ async function fetchWithRetry( for (let attempt = 0; attempt <= GHCR_MAX_RETRIES; attempt++) { try { - const response = await fetch(url, { + const response = await customFetch(url, { ...init, signal: buildSignal(timeout, externalSignal), }); @@ -339,7 +340,7 @@ export async function downloadNightlyBlob( // ghcr.io returns 307 → Azure Blob Storage signed URL. let blobResponse: Response; try { - blobResponse = await fetch(blobUrl, { + blobResponse = await customFetch(blobUrl, { headers: { Authorization: `Bearer ${token}`, "User-Agent": getUserAgent(), @@ -384,7 +385,7 @@ export async function downloadNightlyBlob( // Azure Blob Storage has reliable latency characteristics. let redirectResponse: Response; try { - redirectResponse = await fetch(redirectUrl, { + redirectResponse = await customFetch(redirectUrl, { headers: { "User-Agent": getUserAgent() }, signal, }); diff --git a/src/lib/init/readiness.ts b/src/lib/init/readiness.ts index 8d1393882..2060e8623 100644 --- a/src/lib/init/readiness.ts +++ b/src/lib/init/readiness.ts @@ -5,8 +5,10 @@ * Fails fast with actionable errors instead of failing mid-run. */ +import { customFetch } from "../custom-ca.js"; import { getAuthToken } from "../db/auth.js"; import { WizardError } from "../errors.js"; +import { logger } from "../logger.js"; import { MASTRA_API_URL } from "./constants.js"; import type { WizardUI } from "./ui/types.js"; @@ -67,12 +69,13 @@ async function checkMastraApi(): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT_MS); try { - const resp = await fetch(`${MASTRA_API_URL}/health`, { + const resp = await customFetch(`${MASTRA_API_URL}/health`, { signal: controller.signal, method: "GET", }); return resp.ok; - } catch { + } catch (error) { + logger.withTag("readiness").debug("Mastra API health check failed", error); return false; } finally { clearTimeout(timer); diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index baf06e26b..68f4112c1 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -22,6 +22,7 @@ import { } from "@sentry/node-core/light"; import { formatBanner } from "../banner.js"; import { CLI_VERSION } from "../constants.js"; +import { customFetch } from "../custom-ca.js"; import { detectAgent } from "../detect-agent.js"; import { EXIT, WizardError } from "../errors.js"; import { @@ -636,7 +637,7 @@ export async function runWizard(initialOptions: WizardOptions): Promise { // Preserve `init.signal` via the spread — MastraClient may pass its // own per-request signal, and the client-level `abortSignal` is // forwarded through the same channel. - return fetch(url, { + return customFetch(url, { ...init, headers: { ...(init?.headers as Record | undefined), diff --git a/src/lib/release-notes.ts b/src/lib/release-notes.ts index 3f9c9cdfe..de1a75c18 100644 --- a/src/lib/release-notes.ts +++ b/src/lib/release-notes.ts @@ -18,6 +18,7 @@ import { GITHUB_RELEASES_URL, getGitHubHeaders, } from "./binary.js"; +import { customFetch } from "./custom-ca.js"; import type { GitHubRelease } from "./delta-upgrade.js"; import { logger } from "./logger.js"; @@ -559,11 +560,12 @@ const CHANGELOG_MAX_RELEASES = 30; async function fetchReleasesForChangelog(): Promise { let response: Response; try { - response = await fetch( + response = await customFetch( `${GITHUB_RELEASES_URL}?per_page=${CHANGELOG_MAX_RELEASES}`, { headers: getGitHubHeaders() } ); - } catch { + } catch (error) { + log.debug("Failed to fetch releases for changelog", error); return []; } if (!response.ok) { @@ -650,7 +652,7 @@ async function fetchNightlyChangelog( let response: Response; try { - response = await fetch(url, { headers: getGitHubHeaders() }); + response = await customFetch(url, { headers: getGitHubHeaders() }); } catch { log.debug("Failed to fetch nightly commits"); return null; diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index fc5c1181f..2003f73fe 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -19,6 +19,7 @@ import { getConfiguredSentryUrl, SENTRY_CLI_DSN, } from "./constants.js"; +import { getCustomCaCerts } from "./custom-ca.js"; import { isReadonlyError, tryRepairAndRetry } from "./db/schema.js"; import { type AgentInfo, @@ -540,6 +541,10 @@ export function initSentry( // Automatic gzip fallback when running on Node < 22.15 without the // `Bun.zstdCompress` polyfill (see script/node-polyfills.ts). transport: makeCompressedTransport, + // Pass custom CA certificates to the transport for corporate TLS proxies. + // The zstd-transport reads `caCerts` and passes it as `ca:` to + // `http.request()`, and the SDK's fallback `makeNodeTransport` does the same. + transportOptions: { caCerts: getCustomCaCerts() }, // Keep default integrations but filter out ones that add overhead without benefit. // Important: Don't use defaultIntegrations: false as it may break debug ID support. // NodeSystemError is excluded on runtimes missing util.getSystemErrorMap (Bun) — CLI-K1. diff --git a/test/lib/custom-ca.test.ts b/test/lib/custom-ca.test.ts index 088a3617f..b4d937fa2 100644 --- a/test/lib/custom-ca.test.ts +++ b/test/lib/custom-ca.test.ts @@ -10,6 +10,8 @@ import { writeFileSync } from "node:fs"; import { join } from "node:path"; import { __resetForTests, + customFetch, + getCustomCaCerts, getCustomCaSource, getCustomTlsOptions, getTlsCertErrorMessage, @@ -272,3 +274,134 @@ describe("warnIfSaasWithEnvCa", () => { expect(getCustomCaSource()).toBe("env"); }); }); + +// --------------------------------------------------------------------------- +// getCustomCaCerts — raw PEM string for Node http.request() +// --------------------------------------------------------------------------- + +describe("getCustomCaCerts", () => { + const getConfigDir = useTestConfigDir("ca-certs-"); + const CERT_PEM = + "-----BEGIN CERTIFICATE-----\nMIIBxyz...\n-----END CERTIFICATE-----\n"; + + let savedNodeExtra: string | undefined; + + beforeEach(() => { + __resetForTests(); + savedNodeExtra = process.env.NODE_EXTRA_CA_CERTS; + delete process.env.NODE_EXTRA_CA_CERTS; + }); + + afterEach(() => { + if (savedNodeExtra !== undefined) { + process.env.NODE_EXTRA_CA_CERTS = savedNodeExtra; + } else { + delete process.env.NODE_EXTRA_CA_CERTS; + } + }); + + test("returns undefined when no CAs configured", () => { + expect(getCustomCaCerts()).toBeUndefined(); + }); + + test("returns PEM string when CA is loaded", () => { + const certPath = join(getConfigDir(), "ca.pem"); + writeFileSync(certPath, CERT_PEM); + process.env.NODE_EXTRA_CA_CERTS = certPath; + + const caCerts = getCustomCaCerts(); + expect(caCerts).toBeDefined(); + expect(caCerts).toContain(CERT_PEM); + }); + + test("returns same value as getCustomTlsOptions().tls.ca", () => { + const certPath = join(getConfigDir(), "ca.pem"); + writeFileSync(certPath, CERT_PEM); + process.env.NODE_EXTRA_CA_CERTS = certPath; + + const caCerts = getCustomCaCerts(); + const tlsOpts = getCustomTlsOptions(); + expect(caCerts).toBe(tlsOpts?.tls.ca); + }); +}); + +// --------------------------------------------------------------------------- +// customFetch — TLS-aware fetch wrapper +// --------------------------------------------------------------------------- + +describe("customFetch", () => { + const getConfigDir = useTestConfigDir("custom-fetch-"); + const CERT_PEM = + "-----BEGIN CERTIFICATE-----\nMIIBxyz...\n-----END CERTIFICATE-----\n"; + + let savedNodeExtra: string | undefined; + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + __resetForTests(); + savedNodeExtra = process.env.NODE_EXTRA_CA_CERTS; + delete process.env.NODE_EXTRA_CA_CERTS; + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + if (savedNodeExtra !== undefined) { + process.env.NODE_EXTRA_CA_CERTS = savedNodeExtra; + } else { + delete process.env.NODE_EXTRA_CA_CERTS; + } + }); + + test("calls fetch without tls option when no custom CA", async () => { + let capturedInit: RequestInit | undefined; + globalThis.fetch = ((_input: unknown, init?: RequestInit) => { + capturedInit = init; + return Promise.resolve(new Response("ok")); + }) as typeof fetch; + + await customFetch("https://example.com", { headers: { "X-Test": "1" } }); + expect(capturedInit).toBeDefined(); + expect(capturedInit?.headers).toEqual({ "X-Test": "1" }); + expect((capturedInit as Record).tls).toBeUndefined(); + }); + + test("spreads tls options when custom CA is loaded", async () => { + const certPath = join(getConfigDir(), "ca.pem"); + writeFileSync(certPath, CERT_PEM); + process.env.NODE_EXTRA_CA_CERTS = certPath; + + let capturedInit: Record | undefined; + globalThis.fetch = ((_input: unknown, init?: RequestInit) => { + capturedInit = init as Record; + return Promise.resolve(new Response("ok")); + }) as typeof fetch; + + await customFetch("https://example.com", { headers: { "X-Test": "1" } }); + expect(capturedInit).toBeDefined(); + expect(capturedInit?.tls).toBeDefined(); + expect((capturedInit?.tls as { ca: string }).ca).toContain(CERT_PEM); + }); + + test("preserves caller init options alongside tls", async () => { + const certPath = join(getConfigDir(), "ca.pem"); + writeFileSync(certPath, CERT_PEM); + process.env.NODE_EXTRA_CA_CERTS = certPath; + + let capturedInit: Record | undefined; + globalThis.fetch = ((_input: unknown, init?: RequestInit) => { + capturedInit = init as Record; + return Promise.resolve(new Response("ok")); + }) as typeof fetch; + + await customFetch("https://example.com", { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + expect(capturedInit?.method).toBe("POST"); + expect(capturedInit?.headers).toEqual({ + "Content-Type": "application/json", + }); + expect(capturedInit?.tls).toBeDefined(); + }); +});