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
34 changes: 26 additions & 8 deletions packages/cli/src/lib/dsn/detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
* 3. Full scan: .env files, then source code (slow)
*/

import { join } from "node:path";
import { extname, join } from "node:path";
import { getCachedDsn, setCachedDsn } from "./cache.js";
import { detectFromEnv, SENTRY_DSN_ENV } from "./env.js";
import {
Expand All @@ -23,8 +23,8 @@ import {
import {
detectAllFromCode,
detectFromCode,
extractDsnFromCode,
} from "./languages/javascript.js";
languageDetectors,
} from "./languages/index.js";
import { createDetectedDsn, parseDsn } from "./parser.js";
import type {
CachedDsnEntry,
Expand Down Expand Up @@ -180,7 +180,11 @@ async function verifyCachedDsn(

try {
const content = await Bun.file(filePath).text();
const foundDsn = extractDsnFromContent(content, cached.source);
const foundDsn = extractDsnFromContent(
content,
cached.source,
cached.sourcePath
);

if (foundDsn === cached.dsn) {
// Same DSN - cache is valid!
Expand All @@ -199,17 +203,31 @@ async function verifyCachedDsn(
}

/**
* Extract DSN from content based on source type
* Extract DSN from content based on source type and file path.
*
* @param content - File content
* @param source - Source type (env_file, code, etc.)
* @param sourcePath - Path to the file (used to determine language for code files)
*/
function extractDsnFromContent(
content: string,
source: DsnSource
source: DsnSource,
sourcePath?: string
): string | null {
switch (source) {
case "env_file":
return extractDsnFromEnvFile(content);
case "code":
return extractDsnFromCode(content);
case "code": {
if (!sourcePath) {
return null;
}
// Find the right language detector based on file extension
const ext = extname(sourcePath);
const detector = languageDetectors.find((d) =>
d.extensions.includes(ext)
);
return detector?.extractDsn(content) ?? null;
}
default:
return null;
}
Expand Down
52 changes: 52 additions & 0 deletions packages/cli/src/lib/dsn/languages/go.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Go DSN Detection
*
* Detects DSN from Go source code patterns.
* Looks for sentry.Init(sentry.ClientOptions{Dsn: "..."}) and similar patterns.
*/

import type { LanguageDetector } from "./types.js";

/**
* Regex pattern for DSN in Go struct initialization.
* Matches: Dsn: "https://...@..." in sentry.ClientOptions{}
*/
const DSN_PATTERN_STRUCT = /Dsn:\s*["'](https?:\/\/[^"']+@[^"']+)["']/s;

/**
* Generic pattern for dsn assignment
* Matches: dsn := "..." or dsn = "..."
*/
const DSN_PATTERN_ASSIGN = /dsn\s*:?=\s*["`](https?:\/\/[^"`]+@[^"`]+)["`]/is;

/**
* Extract DSN string from Go code content.
*
* @param content - Source code content
* @returns DSN string or null if not found
*/
export function extractDsnFromGo(content: string): string | null {
// Try struct field pattern first (most common)
const structMatch = content.match(DSN_PATTERN_STRUCT);
if (structMatch?.[1]) {
return structMatch[1];
}

// Try assignment pattern
const assignMatch = content.match(DSN_PATTERN_ASSIGN);
if (assignMatch?.[1]) {
return assignMatch[1];
}

return null;
}

/**
* Go language detector.
*/
export const goDetector: LanguageDetector = {
name: "Go",
extensions: [".go"],
skipDirs: ["vendor", ".git", "testdata"],
extractDsn: extractDsnFromGo,
};
154 changes: 154 additions & 0 deletions packages/cli/src/lib/dsn/languages/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* Language Detection Registry
*
* Unified scanner for detecting DSN from source code across all supported languages.
* Uses a registry of language detectors to scan files by extension.
*/

import { extname, join } from "node:path";
import { createDetectedDsn } from "../parser.js";
import type { DetectedDsn } from "../types.js";
import { goDetector } from "./go.js";
import { javaDetector } from "./java.js";
import { javascriptDetector } from "./javascript.js";
import { phpDetector } from "./php.js";
import { pythonDetector } from "./python.js";
import { rubyDetector } from "./ruby.js";
import type { LanguageDetector } from "./types.js";

// ─────────────────────────────────────────────────────────────────────────────
// Registry
// ─────────────────────────────────────────────────────────────────────────────

/** All supported language detectors */
export const languageDetectors: LanguageDetector[] = [
javascriptDetector,
pythonDetector,
phpDetector,
rubyDetector,
goDetector,
javaDetector,
];

/** Map of file extension to detector for fast lookup */
const extensionToDetector = new Map<string, LanguageDetector>();
for (const detector of languageDetectors) {
for (const ext of detector.extensions) {
extensionToDetector.set(ext, detector);
}
}

/** Combined set of all skip directories */
const allSkipDirs = new Set<string>();
for (const detector of languageDetectors) {
for (const dir of detector.skipDirs) {
allSkipDirs.add(dir);
}
}

/** Glob pattern matching all supported file extensions */
const allExtensions = languageDetectors.flatMap((d) => d.extensions);
const globPattern = `**/*{${allExtensions.join(",")}}`;
const codeGlob = new Bun.Glob(globPattern);

// ─────────────────────────────────────────────────────────────────────────────
// Scanner
// ─────────────────────────────────────────────────────────────────────────────

/**
* Check if a path should be skipped during scanning.
* Matches any path segment against the combined skip directories from all detectors.
*
* @param filepath - Relative file path to check
* @returns True if any path segment matches a skip directory
*/
function shouldSkipPath(filepath: string): boolean {
const parts = filepath.split("/");
return parts.some((part) => allSkipDirs.has(part));
}

/**
* Get the appropriate detector for a file based on its extension.
*
* @param filepath - File path to get detector for
* @returns The matching detector, or undefined if no detector handles this extension
*/
function getDetectorForFile(filepath: string): LanguageDetector | undefined {
const ext = extname(filepath);
return extensionToDetector.get(ext);
}

/**
* Detect DSN from source code files in a directory.
* Scans all supported languages and returns the first DSN found.
*
* @param cwd - Directory to search in
* @returns First detected DSN or null if not found
*/
export async function detectFromCode(cwd: string): Promise<DetectedDsn | null> {
for await (const relativePath of codeGlob.scan({ cwd, onlyFiles: true })) {
if (shouldSkipPath(relativePath)) {
continue;
}

const detector = getDetectorForFile(relativePath);
if (!detector) {
continue;
}

const filepath = join(cwd, relativePath);

try {
const content = await Bun.file(filepath).text();
const dsn = detector.extractDsn(content);

if (dsn) {
return createDetectedDsn(dsn, "code", relativePath);
}
} catch {
// Skip files we can't read
}
}

return null;
}

/**
* Detect DSN from ALL source code files (for conflict detection).
* Unlike detectFromCode, this doesn't stop at the first match.
*
* @param cwd - Directory to search in
* @returns Array of all detected DSNs
*/
export async function detectAllFromCode(cwd: string): Promise<DetectedDsn[]> {
const results: DetectedDsn[] = [];

for await (const relativePath of codeGlob.scan({ cwd, onlyFiles: true })) {
if (shouldSkipPath(relativePath)) {
continue;
}

const detector = getDetectorForFile(relativePath);
if (!detector) {
continue;
}

const filepath = join(cwd, relativePath);

try {
const content = await Bun.file(filepath).text();
const dsn = detector.extractDsn(content);

if (dsn) {
const detected = createDetectedDsn(dsn, "code", relativePath);
if (detected) {
results.push(detected);
}
}
} catch {
// Skip files we can't read
}
}

return results;
}
77 changes: 77 additions & 0 deletions packages/cli/src/lib/dsn/languages/java.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* Java/Kotlin DSN Detection
*
* Detects DSN from Java and Kotlin source code patterns.
* Also checks sentry.properties files.
* Looks for options.setDsn("...") and dsn=... patterns.
*/

import type { LanguageDetector } from "./types.js";

/**
* Regex for Java/Kotlin code: options.setDsn("...")
*/
const DSN_PATTERN_SET_DSN = /\.setDsn\s*\(\s*["']([^"']+)["']\s*\)/s;

/**
* Regex for sentry.properties file: dsn=...
*/
const DSN_PATTERN_PROPERTIES = /^dsn\s*=\s*(.+)$/m;

/**
* Generic pattern for DSN in annotations or config
* Matches: dsn = "..." or "dsn", "..."
*/
const DSN_PATTERN_GENERIC =
/["']?dsn["']?\s*[=,]\s*["'](https?:\/\/[^"']+@[^"']+)["']/s;

/**
* Extract DSN string from Java/Kotlin code or properties content.
*
* @param content - Source code or properties content
* @returns DSN string or null if not found
*/
export function extractDsnFromJava(content: string): string | null {
// Try setDsn() pattern first (most common in Java)
const setDsnMatch = content.match(DSN_PATTERN_SET_DSN);
if (setDsnMatch?.[1]) {
return setDsnMatch[1];
}

// Try properties file pattern (dsn=...)
const propsMatch = content.match(DSN_PATTERN_PROPERTIES);
if (propsMatch?.[1]) {
const dsn = propsMatch[1].trim();
// Validate it looks like a DSN
if (dsn.startsWith("https://") && dsn.includes("@")) {
return dsn;
}
}

// Try generic pattern
const genericMatch = content.match(DSN_PATTERN_GENERIC);
if (genericMatch?.[1]) {
return genericMatch[1];
}

return null;
}

/**
* Java/Kotlin language detector.
*/
export const javaDetector: LanguageDetector = {
name: "Java",
extensions: [".java", ".kt", ".properties"],
skipDirs: [
"target",
"build",
".gradle",
".idea",
".git",
"out",
"bin",
".mvn",
],
extractDsn: extractDsnFromJava,
};
Loading