Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions src/lib/api/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down
10 changes: 9 additions & 1 deletion src/lib/binary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -232,12 +237,15 @@ export async function fetchWithUpgradeError(
serviceName: string
): Promise<Response> {
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",
Expand Down
32 changes: 32 additions & 0 deletions src/lib/custom-ca.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> {
const tlsOpts = getCustomTlsOptions();
if (!tlsOpts) {
return fetch(input, init);
}
return fetch(input, { ...init, ...tlsOpts });
}

/**
* Reset all cached state. Exported for test isolation only.
* @internal
Expand Down
11 changes: 7 additions & 4 deletions src/lib/delta-upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -210,11 +212,12 @@ export async function downloadStablePatch(
): Promise<Uint8Array | null> {
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) {
Expand Down
7 changes: 4 additions & 3 deletions src/lib/ghcr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
Expand Down Expand Up @@ -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),
});
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
});
Expand Down
7 changes: 5 additions & 2 deletions src/lib/init/readiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -67,12 +69,13 @@ async function checkMastraApi(): Promise<boolean> {
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);
Expand Down
3 changes: 2 additions & 1 deletion src/lib/init/wizard-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -636,7 +637,7 @@ export async function runWizard(initialOptions: WizardOptions): Promise<void> {
// 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<string, string> | undefined),
Expand Down
8 changes: 5 additions & 3 deletions src/lib/release-notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -559,11 +560,12 @@ const CHANGELOG_MAX_RELEASES = 30;
async function fetchReleasesForChangelog(): Promise<GitHubRelease[]> {
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) {
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions src/lib/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading