From 788ce912e606ad04190efbab4d5f7f1ab0d676ea Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Apr 2026 12:31:40 +0000 Subject: [PATCH 1/3] fix(dsn): detect framework-prefixed DSN env vars in .env files and process.env (#820) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sentry init` was not detecting DSNs supplied via framework-specific environment variables like `NEXT_PUBLIC_SENTRY_DSN`, `REACT_APP_SENTRY_DSN`, `VITE_SENTRY_DSN`, etc. This caused init to create a new Sentry project instead of reusing the existing one when the DSN was provided through a framework-prefixed env var. The .env file scanner (env-file.ts) only matched the exact pattern `SENTRY_DSN=...`. The runtime env detection (env.ts) only checked `process.env.SENTRY_DSN`. Fix: - Expanded the .env file regex from `^SENTRY_DSN` to `^(?:\w+_)?SENTRY_DSN` to match framework-prefixed variants (NEXT_PUBLIC_, REACT_APP_, VITE_, etc.) - Added explicit checks for the 5 most common framework-prefixed env vars in the runtime env detection, checked after the canonical SENTRY_DSN Co-authored-by: Miguel Betegón --- src/lib/dsn/env-file.ts | 13 +++++++++---- src/lib/dsn/env.ts | 34 +++++++++++++++++++++++++++++----- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/lib/dsn/env-file.ts b/src/lib/dsn/env-file.ts index 3193b1a0d..0bad94901 100644 --- a/src/lib/dsn/env-file.ts +++ b/src/lib/dsn/env-file.ts @@ -44,11 +44,16 @@ export const ENV_FILES = [ ] as const; /** - * Pattern to match SENTRY_DSN in .env files. - * Handles: SENTRY_DSN=value, SENTRY_DSN="value", SENTRY_DSN='value' - * Also handles trailing comments: SENTRY_DSN=value # comment + * Pattern to match Sentry DSN variables in .env files. + * Handles the canonical `SENTRY_DSN` as well as framework-prefixed variants + * commonly used by Next.js, Vite, CRA, Expo, Nuxt, and others: + * NEXT_PUBLIC_SENTRY_DSN, REACT_APP_SENTRY_DSN, VITE_SENTRY_DSN, + * EXPO_PUBLIC_SENTRY_DSN, NUXT_PUBLIC_SENTRY_DSN, etc. + * + * Handles: VAR=value, VAR="value", VAR='value' + * Also handles trailing comments: VAR=value # comment */ -const ENV_DSN_PATTERN = /^SENTRY_DSN\s*=\s*(['"]?)(.+?)\1\s*(?:#.*)?$/; +const ENV_DSN_PATTERN = /^(?:\w+_)?SENTRY_DSN\s*=\s*(['"]?)(.+?)\1\s*(?:#.*)?$/; /** * Extract SENTRY_DSN value from .env file content. diff --git a/src/lib/dsn/env.ts b/src/lib/dsn/env.ts index c8dfb0dbe..dc2c01d64 100644 --- a/src/lib/dsn/env.ts +++ b/src/lib/dsn/env.ts @@ -13,7 +13,22 @@ import type { DetectedDsn } from "./types.js"; export const SENTRY_DSN_ENV = "SENTRY_DSN"; /** - * Detect DSN from environment variable. + * Framework-prefixed env var names that commonly hold a Sentry DSN. + * Checked in order after `SENTRY_DSN` (canonical name has priority). + */ +const FRAMEWORK_DSN_VARS = [ + "NEXT_PUBLIC_SENTRY_DSN", + "REACT_APP_SENTRY_DSN", + "VITE_SENTRY_DSN", + "EXPO_PUBLIC_SENTRY_DSN", + "NUXT_PUBLIC_SENTRY_DSN", +] as const; + +/** + * Detect DSN from environment variables. + * + * Checks `SENTRY_DSN` first (canonical), then common framework-prefixed + * variants (NEXT_PUBLIC_SENTRY_DSN, REACT_APP_SENTRY_DSN, etc.). * * @returns Detected DSN or null if not set/invalid * @@ -23,10 +38,19 @@ export const SENTRY_DSN_ENV = "SENTRY_DSN"; * // { raw: "...", source: "env", projectId: "456", ... } */ export function detectFromEnv(): DetectedDsn | null { - const dsn = getEnv()[SENTRY_DSN_ENV]; - if (!dsn) { - return null; + const env = getEnv(); + + const canonical = env[SENTRY_DSN_ENV]; + if (canonical) { + return createDetectedDsn(canonical, "env"); + } + + for (const varName of FRAMEWORK_DSN_VARS) { + const value = env[varName]; + if (value) { + return createDetectedDsn(value, "env"); + } } - return createDetectedDsn(dsn, "env"); + return null; } From 30b4cc37a94f30dba1ff3179a66acc17efaf4c01 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 1 May 2026 22:00:42 +0000 Subject: [PATCH 2/3] fix: skip invalid DSNs and propagate env var name to source description Address Bugbot review: - Invalid DSN in SENTRY_DSN no longer causes premature return; the loop continues to check framework-prefixed vars. - The actual env var name (e.g. NEXT_PUBLIC_SENTRY_DSN) is passed as sourcePath so getDsnSourceDescription reports the correct variable. --- src/lib/dsn/detector.ts | 2 +- src/lib/dsn/env.ts | 13 ++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/lib/dsn/detector.ts b/src/lib/dsn/detector.ts index e9f124154..19aef2e6f 100644 --- a/src/lib/dsn/detector.ts +++ b/src/lib/dsn/detector.ts @@ -400,7 +400,7 @@ async function fullScanFirst(cwd: string): Promise { export function getDsnSourceDescription(dsn: DetectedDsn): string { switch (dsn.source) { case "env": - return `${SENTRY_DSN_ENV} environment variable`; + return `${dsn.sourcePath ?? SENTRY_DSN_ENV} environment variable`; case "env_file": return dsn.sourcePath ?? ".env file"; case "config": diff --git a/src/lib/dsn/env.ts b/src/lib/dsn/env.ts index dc2c01d64..b65623d9c 100644 --- a/src/lib/dsn/env.ts +++ b/src/lib/dsn/env.ts @@ -40,15 +40,14 @@ const FRAMEWORK_DSN_VARS = [ export function detectFromEnv(): DetectedDsn | null { const env = getEnv(); - const canonical = env[SENTRY_DSN_ENV]; - if (canonical) { - return createDetectedDsn(canonical, "env"); - } - - for (const varName of FRAMEWORK_DSN_VARS) { + const allVars = [SENTRY_DSN_ENV, ...FRAMEWORK_DSN_VARS]; + for (const varName of allVars) { const value = env[varName]; if (value) { - return createDetectedDsn(value, "env"); + const detected = createDetectedDsn(value, "env", varName); + if (detected) { + return detected; + } } } From 90f01a25985c1785017a3861006bfec47b7fdd6a Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 1 May 2026 23:23:26 +0000 Subject: [PATCH 3/3] refactor: shared prefix allowlist, accurate JSDoc, and comprehensive tests - Export FRAMEWORK_ENV_PREFIXES from env.ts and build the env-file.ts regex from them, so both detection paths match exactly the same set of framework-prefixed variable names (no open \w+ regex). - Update sourcePath JSDoc in types.ts to document dual semantics (env var name for 'env' source, file path for file-based sources). - Update DsnSource doc to mention framework-prefixed env vars. - Add test/lib/dsn/env.test.ts covering: all framework vars, canonical priority, invalid-DSN skip-through, sourcePath propagation. - Add framework-prefix tests to env-file.test.ts (match + reject). - Add getDsnSourceDescription test for env source with sourcePath. --- src/lib/dsn/env-file.ts | 15 +++- src/lib/dsn/env.ts | 23 ++++-- src/lib/dsn/types.ts | 11 ++- test/lib/dsn/detector.test.ts | 17 +++++ test/lib/dsn/env-file.test.ts | 65 +++++++++++++++++ test/lib/dsn/env.test.ts | 133 ++++++++++++++++++++++++++++++++++ 6 files changed, 250 insertions(+), 14 deletions(-) create mode 100644 test/lib/dsn/env.test.ts diff --git a/src/lib/dsn/env-file.ts b/src/lib/dsn/env-file.ts index 0bad94901..a1fee3a60 100644 --- a/src/lib/dsn/env-file.ts +++ b/src/lib/dsn/env-file.ts @@ -11,6 +11,7 @@ import { opendir } from "node:fs/promises"; import { join } from "node:path"; import { withTracingSpan } from "../telemetry.js"; +import { FRAMEWORK_ENV_PREFIXES } from "./env.js"; import { createDetectedDsn } from "./parser.js"; import { scanSpecificFiles } from "./scanner.js"; import type { DetectedDsn } from "./types.js"; @@ -45,15 +46,21 @@ export const ENV_FILES = [ /** * Pattern to match Sentry DSN variables in .env files. - * Handles the canonical `SENTRY_DSN` as well as framework-prefixed variants - * commonly used by Next.js, Vite, CRA, Expo, Nuxt, and others: + * Matches the canonical `SENTRY_DSN` plus framework-prefixed variants + * from the shared {@link FRAMEWORK_ENV_PREFIXES} allowlist: * NEXT_PUBLIC_SENTRY_DSN, REACT_APP_SENTRY_DSN, VITE_SENTRY_DSN, - * EXPO_PUBLIC_SENTRY_DSN, NUXT_PUBLIC_SENTRY_DSN, etc. + * EXPO_PUBLIC_SENTRY_DSN, NUXT_PUBLIC_SENTRY_DSN. * * Handles: VAR=value, VAR="value", VAR='value' * Also handles trailing comments: VAR=value # comment */ -const ENV_DSN_PATTERN = /^(?:\w+_)?SENTRY_DSN\s*=\s*(['"]?)(.+?)\1\s*(?:#.*)?$/; +const TRAILING_UNDERSCORE = /_$/; +const ENV_DSN_PREFIXES = FRAMEWORK_ENV_PREFIXES.map((p) => + p.replace(TRAILING_UNDERSCORE, "") +).join("|"); +const ENV_DSN_PATTERN = new RegExp( + `^(?:(?:${ENV_DSN_PREFIXES})_)?SENTRY_DSN\\s*=\\s*(['"]?)(.+?)\\1\\s*(?:#.*)?$` +); /** * Extract SENTRY_DSN value from .env file content. diff --git a/src/lib/dsn/env.ts b/src/lib/dsn/env.ts index b65623d9c..20199038a 100644 --- a/src/lib/dsn/env.ts +++ b/src/lib/dsn/env.ts @@ -12,17 +12,26 @@ import type { DetectedDsn } from "./types.js"; /** Environment variable name for Sentry DSN */ export const SENTRY_DSN_ENV = "SENTRY_DSN"; +/** + * Framework-specific env var prefixes that expose values to client-side code. + * Used to build both the runtime env var checklist and the .env file regex + * so that both detection paths match the same set of variable names. + */ +export const FRAMEWORK_ENV_PREFIXES = [ + "NEXT_PUBLIC_", + "REACT_APP_", + "VITE_", + "EXPO_PUBLIC_", + "NUXT_PUBLIC_", +] as const; + /** * Framework-prefixed env var names that commonly hold a Sentry DSN. * Checked in order after `SENTRY_DSN` (canonical name has priority). */ -const FRAMEWORK_DSN_VARS = [ - "NEXT_PUBLIC_SENTRY_DSN", - "REACT_APP_SENTRY_DSN", - "VITE_SENTRY_DSN", - "EXPO_PUBLIC_SENTRY_DSN", - "NUXT_PUBLIC_SENTRY_DSN", -] as const; +const FRAMEWORK_DSN_VARS = FRAMEWORK_ENV_PREFIXES.map( + (prefix) => `${prefix}SENTRY_DSN` +); /** * Detect DSN from environment variables. diff --git a/src/lib/dsn/types.ts b/src/lib/dsn/types.ts index cdbb23624..53388e94e 100644 --- a/src/lib/dsn/types.ts +++ b/src/lib/dsn/types.ts @@ -9,7 +9,7 @@ import { z } from "zod"; /** * Source where DSN was detected from * - * - env: SENTRY_DSN environment variable + * - env: SENTRY_DSN or framework-prefixed environment variable (e.g., NEXT_PUBLIC_SENTRY_DSN) * - env_file: .env file * - config: Language-specific config file (e.g., sentry.properties) * - code: Source code patterns (e.g., Sentry.init) @@ -40,7 +40,12 @@ export type DetectedDsn = ParsedDsn & { raw: string; /** Where the DSN was found */ source: DsnSource; - /** File path (relative to cwd) if detected from file */ + /** + * Context-dependent source identifier: + * - For `"env"`: the env var name (e.g., `"NEXT_PUBLIC_SENTRY_DSN"`) + * - For `"env_file"`, `"config"`, `"code"`: file path relative to cwd + * - For `"inferred"`: undefined + */ sourcePath?: string; /** Package/app directory path for monorepo grouping (e.g., "packages/frontend", "apps/web") */ packagePath?: string; @@ -77,7 +82,7 @@ export type CachedDsnEntry = { orgId?: string; /** Where the DSN was found */ source: DsnSource; - /** Relative path to the source file */ + /** Source identifier: env var name for `"env"`, relative file path for file-based sources */ sourcePath?: string; /** Resolved project info (avoids API call on cache hit) */ resolved?: ResolvedProjectInfo; diff --git a/test/lib/dsn/detector.test.ts b/test/lib/dsn/detector.test.ts index 29b2c20de..e5a3a6da1 100644 --- a/test/lib/dsn/detector.test.ts +++ b/test/lib/dsn/detector.test.ts @@ -387,6 +387,23 @@ describe("DSN Detector (New Module)", () => { ); }); + test("describes env source with framework-prefixed var name", () => { + const dsn = { + raw: "https://key@o1.ingest.sentry.io/1", + source: "env" as const, + sourcePath: "NEXT_PUBLIC_SENTRY_DSN", + protocol: "https", + publicKey: "key", + host: "o1.ingest.sentry.io", + projectId: "1", + orgId: "1", + }; + + expect(getDsnSourceDescription(dsn)).toBe( + "NEXT_PUBLIC_SENTRY_DSN environment variable" + ); + }); + test("describes env_file source with path", () => { const dsn = { raw: "https://key@o1.ingest.sentry.io/1", diff --git a/test/lib/dsn/env-file.test.ts b/test/lib/dsn/env-file.test.ts index 4685b158f..c9b584215 100644 --- a/test/lib/dsn/env-file.test.ts +++ b/test/lib/dsn/env-file.test.ts @@ -185,6 +185,71 @@ SENTRY_DSN=https://correct@sentry.io/456`; }); }); +// ============================================================================ +// Framework-Prefixed Env Var Tests +// ============================================================================ + +describe("extractDsnFromEnvContent framework-prefixed vars", () => { + test("extracts DSN from NEXT_PUBLIC_SENTRY_DSN", () => { + const content = "NEXT_PUBLIC_SENTRY_DSN=https://key@sentry.io/123"; + expect(extractDsnFromEnvContent(content)).toBe("https://key@sentry.io/123"); + }); + + test("extracts DSN from REACT_APP_SENTRY_DSN", () => { + const content = 'REACT_APP_SENTRY_DSN="https://key@sentry.io/123"'; + expect(extractDsnFromEnvContent(content)).toBe("https://key@sentry.io/123"); + }); + + test("extracts DSN from VITE_SENTRY_DSN", () => { + const content = "VITE_SENTRY_DSN=https://key@sentry.io/123"; + expect(extractDsnFromEnvContent(content)).toBe("https://key@sentry.io/123"); + }); + + test("extracts DSN from EXPO_PUBLIC_SENTRY_DSN", () => { + const content = "EXPO_PUBLIC_SENTRY_DSN=https://key@sentry.io/123"; + expect(extractDsnFromEnvContent(content)).toBe("https://key@sentry.io/123"); + }); + + test("extracts DSN from NUXT_PUBLIC_SENTRY_DSN", () => { + const content = "NUXT_PUBLIC_SENTRY_DSN=https://key@sentry.io/123"; + expect(extractDsnFromEnvContent(content)).toBe("https://key@sentry.io/123"); + }); + + test("canonical SENTRY_DSN is returned when it appears first", () => { + const content = `SENTRY_DSN=https://canonical@sentry.io/1 +NEXT_PUBLIC_SENTRY_DSN=https://next@sentry.io/2`; + expect(extractDsnFromEnvContent(content)).toBe( + "https://canonical@sentry.io/1" + ); + }); + + test("framework-prefixed var returned when it appears before canonical", () => { + const content = `NEXT_PUBLIC_SENTRY_DSN=https://next@sentry.io/2 +SENTRY_DSN=https://canonical@sentry.io/1`; + expect(extractDsnFromEnvContent(content)).toBe("https://next@sentry.io/2"); + }); + + test("rejects unknown prefix MY_CUSTOM_SENTRY_DSN", () => { + const content = "MY_CUSTOM_SENTRY_DSN=https://key@sentry.io/123"; + expect(extractDsnFromEnvContent(content)).toBeNull(); + }); + + test("rejects unknown prefix GATSBY_SENTRY_DSN", () => { + const content = "GATSBY_SENTRY_DSN=https://key@sentry.io/123"; + expect(extractDsnFromEnvContent(content)).toBeNull(); + }); + + test("rejects bare underscore prefix _SENTRY_DSN", () => { + const content = "_SENTRY_DSN=https://key@sentry.io/123"; + expect(extractDsnFromEnvContent(content)).toBeNull(); + }); + + test("rejects VUE_APP_SENTRY_DSN (not in allowlist)", () => { + const content = "VUE_APP_SENTRY_DSN=https://key@sentry.io/123"; + expect(extractDsnFromEnvContent(content)).toBeNull(); + }); +}); + // ============================================================================ // Integration Tests for File-Based Detection // ============================================================================ diff --git a/test/lib/dsn/env.test.ts b/test/lib/dsn/env.test.ts new file mode 100644 index 000000000..fddc9cf64 --- /dev/null +++ b/test/lib/dsn/env.test.ts @@ -0,0 +1,133 @@ +/** + * Runtime Environment Variable Detection Tests + * + * Tests for detecting Sentry DSN from process.env, including + * framework-prefixed variants (NEXT_PUBLIC_SENTRY_DSN, etc.). + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { detectFromEnv, SENTRY_DSN_ENV } from "../../../src/lib/dsn/env.js"; + +const VALID_DSN = "https://abc123@o1.ingest.us.sentry.io/456"; +const VALID_DSN_2 = "https://def456@o2.ingest.us.sentry.io/789"; +const INVALID_DSN = "not-a-valid-dsn"; + +/** All env var names this module may read */ +const ALL_DSN_VARS = [ + "SENTRY_DSN", + "NEXT_PUBLIC_SENTRY_DSN", + "REACT_APP_SENTRY_DSN", + "VITE_SENTRY_DSN", + "EXPO_PUBLIC_SENTRY_DSN", + "NUXT_PUBLIC_SENTRY_DSN", +] as const; + +/** Saved env values for cleanup */ +let savedEnv: Record; + +beforeEach(() => { + savedEnv = {}; + for (const key of ALL_DSN_VARS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } +}); + +afterEach(() => { + for (const key of ALL_DSN_VARS) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } +}); + +describe("detectFromEnv", () => { + test("returns null when no DSN env vars are set", () => { + expect(detectFromEnv()).toBeNull(); + }); + + test("detects canonical SENTRY_DSN", () => { + process.env.SENTRY_DSN = VALID_DSN; + const result = detectFromEnv(); + expect(result).not.toBeNull(); + expect(result?.raw).toBe(VALID_DSN); + expect(result?.source).toBe("env"); + expect(result?.sourcePath).toBe(SENTRY_DSN_ENV); + }); + + test("detects NEXT_PUBLIC_SENTRY_DSN", () => { + process.env.NEXT_PUBLIC_SENTRY_DSN = VALID_DSN; + const result = detectFromEnv(); + expect(result).not.toBeNull(); + expect(result?.raw).toBe(VALID_DSN); + expect(result?.sourcePath).toBe("NEXT_PUBLIC_SENTRY_DSN"); + }); + + test("detects REACT_APP_SENTRY_DSN", () => { + process.env.REACT_APP_SENTRY_DSN = VALID_DSN; + const result = detectFromEnv(); + expect(result?.raw).toBe(VALID_DSN); + expect(result?.sourcePath).toBe("REACT_APP_SENTRY_DSN"); + }); + + test("detects VITE_SENTRY_DSN", () => { + process.env.VITE_SENTRY_DSN = VALID_DSN; + const result = detectFromEnv(); + expect(result?.raw).toBe(VALID_DSN); + expect(result?.sourcePath).toBe("VITE_SENTRY_DSN"); + }); + + test("detects EXPO_PUBLIC_SENTRY_DSN", () => { + process.env.EXPO_PUBLIC_SENTRY_DSN = VALID_DSN; + const result = detectFromEnv(); + expect(result?.raw).toBe(VALID_DSN); + expect(result?.sourcePath).toBe("EXPO_PUBLIC_SENTRY_DSN"); + }); + + test("detects NUXT_PUBLIC_SENTRY_DSN", () => { + process.env.NUXT_PUBLIC_SENTRY_DSN = VALID_DSN; + const result = detectFromEnv(); + expect(result?.raw).toBe(VALID_DSN); + expect(result?.sourcePath).toBe("NUXT_PUBLIC_SENTRY_DSN"); + }); + + test("canonical SENTRY_DSN takes priority over framework-prefixed vars", () => { + process.env.SENTRY_DSN = VALID_DSN; + process.env.NEXT_PUBLIC_SENTRY_DSN = VALID_DSN_2; + const result = detectFromEnv(); + expect(result?.raw).toBe(VALID_DSN); + expect(result?.sourcePath).toBe(SENTRY_DSN_ENV); + }); + + test("skips invalid DSN in SENTRY_DSN and falls through to framework var", () => { + process.env.SENTRY_DSN = INVALID_DSN; + process.env.NEXT_PUBLIC_SENTRY_DSN = VALID_DSN; + const result = detectFromEnv(); + expect(result).not.toBeNull(); + expect(result?.raw).toBe(VALID_DSN); + expect(result?.sourcePath).toBe("NEXT_PUBLIC_SENTRY_DSN"); + }); + + test("skips invalid DSN in framework var and continues to next", () => { + process.env.NEXT_PUBLIC_SENTRY_DSN = INVALID_DSN; + process.env.VITE_SENTRY_DSN = VALID_DSN; + const result = detectFromEnv(); + expect(result?.raw).toBe(VALID_DSN); + expect(result?.sourcePath).toBe("VITE_SENTRY_DSN"); + }); + + test("returns null when all set vars contain invalid DSNs", () => { + process.env.SENTRY_DSN = INVALID_DSN; + process.env.NEXT_PUBLIC_SENTRY_DSN = "also-not-valid"; + expect(detectFromEnv()).toBeNull(); + }); + + test("ignores empty string values", () => { + process.env.SENTRY_DSN = ""; + process.env.NEXT_PUBLIC_SENTRY_DSN = VALID_DSN; + const result = detectFromEnv(); + expect(result?.raw).toBe(VALID_DSN); + }); +});