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
32 changes: 21 additions & 11 deletions packages/cli/src/lib/dsn/detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
getCachedProjectRoot,
setCachedProjectRoot,
} from "../db/project-root-cache.js";
import { logger } from "../logger.js";
import {
extractFirstDsnFromContent,
scanCodeForDsns,
Expand All @@ -37,14 +38,23 @@ import {
} from "./env-file.js";
import { isRegularFile } from "./fs-utils.js";
import { createDetectedDsn, createDsnFingerprint, parseDsn } from "./parser.js";
import { findProjectRoot, isHomeOrAncestor } from "./project-root.js";
import { findProjectRoot, shouldSkipDownwardScan } from "./project-root.js";
import type {
CachedDsnEntry,
DetectedDsn,
DsnDetectionResult,
DsnSource,
} from "./types.js";

const log = logger.withTag("dsn");

/** Explain at debug level why DSN auto-detection did not scan any files. */
function logSkippedScan(projectRoot: string, reason: string): void {
log.debug(
`Skipping DSN file scan: project root ${projectRoot} (${reason}) is the home directory, an ancestor of it, or a shared temp directory`,
);
}

/**
* Detect DSN with project root detection and caching support.
*
Expand All @@ -67,11 +77,12 @@ export async function detectDsn(cwd: string): Promise<DetectedDsn | null> {
// return it immediately - code DSNs take priority)
const { projectRoot, reason } = await findProjectRoot(cwd);

// When project root detection fell back to the home directory (or an
// ancestor of it), a downward scan would reach OS app-data and credential
// directories (~/Library, ~/.ssh, ~/.aws, …). Skip the filesystem scan
// entirely in that case and only consult the SENTRY_DSN env var.
if (reason === "fallback" && isHomeOrAncestor(projectRoot)) {
// When the project root is the home directory, an ancestor of it, or a
// shared temp root, a downward scan would reach OS app-data, credential,
// and mounted dirs (~/Library, ~/.ssh, /tmp/.mount_*, …). Skip the
// filesystem scan entirely and only consult the SENTRY_DSN env var.
if (shouldSkipDownwardScan(projectRoot)) {
logSkippedScan(projectRoot, reason);
return detectFromEnv();
}

Expand Down Expand Up @@ -156,11 +167,10 @@ export async function detectAllDsns(cwd: string): Promise<DsnDetectionResult> {
});
}

// When project root detection fell back to the home directory (or an
// ancestor of it), a downward scan would reach OS app-data and credential
// directories (~/Library, ~/.ssh, ~/.aws, …). Skip the filesystem scan
// entirely and only report the SENTRY_DSN env var, if any.
if (reason === "fallback" && isHomeOrAncestor(projectRoot)) {
// Same guard as detectDsn: never scan down from $HOME, its ancestors, or a
// shared temp root. Only report the SENTRY_DSN env var, if any.
if (shouldSkipDownwardScan(projectRoot)) {
logSkippedScan(projectRoot, reason);
const envDsn = detectFromEnv();
const all = envDsn ? [envDsn] : [];
return {
Expand Down
27 changes: 26 additions & 1 deletion packages/cli/src/lib/dsn/project-root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
*/

import { opendir, readFile, stat } from "node:fs/promises";
import { homedir } from "node:os";
import { homedir, tmpdir } from "node:os";
import { isAbsolute, join, relative, resolve } from "node:path";
import pLimit from "p-limit";
import picomatch from "picomatch";
Expand Down Expand Up @@ -424,6 +424,31 @@ export function isHomeOrAncestor(dir: string): boolean {
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
}

/**
* Shared system temp roots. Other processes mount private FUSE filesystems
* directly under these (AppImage `/tmp/.mount_*`, FreeRDP clipboard dirs),
* and stale mounts fail `readdir` with ENOTCONN — so a temp root is never
* a project directory worth scanning. Subdirectories are still scanned.
*/
function isSharedTempRoot(dir: string): boolean {
const resolvedDir = resolve(dir);
return [tmpdir(), "/tmp", "/var/tmp", "/private/tmp"].some(
(root) => resolve(root) === resolvedDir,
);
}

/**
* True when a downward DSN scan from `projectRoot` must be skipped: the
* root is `$HOME`, an ancestor of it, or a shared temp root. Applies to
* every root-detection reason — a stray `~/package.json` (language marker)
* or a dotfiles `~/.git` makes `$HOME` the "project root" just like the
* no-marker fallback does, and scanning from there walks into `~/Library`,
* cloud-storage mounts, and credential dirs.
*/
export function shouldSkipDownwardScan(projectRoot: string): boolean {
return isHomeOrAncestor(projectRoot) || isSharedTempRoot(projectRoot);
}

/**
* Process one directory level during the walk-up search
*/
Expand Down
79 changes: 78 additions & 1 deletion packages/cli/test/lib/dsn/home-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,17 @@ import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";

const { fakeHome } = vi.hoisted(() => ({ fakeHome: { path: "" } }));
const { fakeHome, fakeTmp } = vi.hoisted(() => ({
fakeHome: { path: "" },
fakeTmp: { path: "" },
}));

vi.mock("node:os", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:os")>();
return {
...actual,
homedir: () => fakeHome.path,
tmpdir: () => fakeTmp.path || actual.tmpdir(),
};
});

Expand All @@ -42,6 +46,7 @@ describe("DSN detection from $HOME fallback", () => {

afterEach(() => {
fakeHome.path = "";
fakeTmp.path = "";
delete process.env.SENTRY_DSN;
});

Expand Down Expand Up @@ -117,6 +122,78 @@ describe("DSN detection from $HOME fallback", () => {
expect(result?.source).toBe("code");
});

describe("when a stray project marker sits directly in $HOME", () => {
// A ~/package.json or a dotfiles ~/.git makes $HOME the project root
// with reason "language" / "vcs" instead of "fallback". The scan must
// still be skipped — otherwise it walks ~/Library, cloud-storage and
// FUSE mounts, and can pick up an unrelated DSN from anywhere in $HOME.
const strayDsn = "https://stray@o444.ingest.sentry.io/444";

beforeEach(() => {
mkdirSync(join(home, "notes"), { recursive: true });
writeFileSync(
join(home, "notes", "old-demo.ts"),
`Sentry.init({ dsn: "${strayDsn}" });`,
);
});

test.each([
["language marker", "package.json"],
["build-system marker", "Makefile"],
])("skips the scan with a %s in $HOME", async (_label, marker) => {
writeFileSync(join(home, marker), "");

expect(await detectDsn(home)).toBeNull();
expect((await detectAllDsns(home)).all).toHaveLength(0);
});

test("skips the scan with a VCS root at $HOME", async () => {
mkdirSync(join(home, ".git"), { recursive: true });

expect(await detectDsn(home)).toBeNull();
expect((await detectAllDsns(home)).all).toHaveLength(0);
});

test("skips the scan when a marker-less subdirectory walks up to $HOME", async () => {
writeFileSync(join(home, "package.json"), "{}");
const workspace = join(home, ".agent", "workspaces", "w1");
mkdirSync(workspace, { recursive: true });

expect(await detectDsn(workspace)).toBeNull();
expect((await detectAllDsns(workspace)).all).toHaveLength(0);
});
});

describe("from a shared temp root", () => {
test("does not scan the temp root itself", async () => {
const tmpRoot = join(getConfigDir(), "tmp");
fakeTmp.path = tmpRoot;
const mount = join(tmpRoot, ".mount_app");
mkdirSync(mount, { recursive: true });
writeFileSync(
join(mount, "config.ts"),
`Sentry.init({ dsn: "https://mnt@o555.ingest.sentry.io/555" });`,
);

expect(await detectDsn(tmpRoot)).toBeNull();
expect((await detectAllDsns(tmpRoot)).all).toHaveLength(0);
});

test("still scans a project directory inside the temp root", async () => {
const tmpRoot = join(getConfigDir(), "tmp");
fakeTmp.path = tmpRoot;
const project = join(tmpRoot, "checkout");
mkdirSync(join(project, ".git"), { recursive: true });
const projectDsn = "https://code@o666.ingest.sentry.io/666";
writeFileSync(
join(project, "config.ts"),
`Sentry.init({ dsn: "${projectDsn}" });`,
);

expect((await detectDsn(project))?.raw).toBe(projectDsn);
});
});

describe("from an ancestor of $HOME", () => {
let ancestor: string;

Expand Down
Loading