From 34296659414668f93ee092f17d069e35eb6c4550 Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Tue, 21 Apr 2026 23:12:23 +0530 Subject: [PATCH 01/10] fix(windows): add Windows Eclipse plugin detection paths eclipseFeatureDirs was macOS-only (/Applications/Eclipse.app/...). On Windows, Eclipse installs to variable paths. Now dynamically resolves features/dropins directories from common Windows locations: - %PROGRAMFILES%\eclipse - C:\eclipse - %USERPROFILE%\eclipse\\eclipse (Oomph installer) --- internal/detector/eclipse_plugins.go | 59 ++++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/internal/detector/eclipse_plugins.go b/internal/detector/eclipse_plugins.go index 3046787..e611974 100644 --- a/internal/detector/eclipse_plugins.go +++ b/internal/detector/eclipse_plugins.go @@ -4,16 +4,64 @@ import ( "path/filepath" "strings" + "github.com/step-security/dev-machine-guard/internal/executor" "github.com/step-security/dev-machine-guard/internal/model" ) -// eclipseFeatureDirs are Eclipse feature directories to scan. -// Features represent installed plugins/extensions (both bundled and user-installed). -var eclipseFeatureDirs = []string{ +// macOS Eclipse feature directories (fixed paths). +var eclipseFeatureDirsDarwin = []string{ "/Applications/Eclipse.app/Contents/Eclipse/features", "/Applications/Eclipse.app/Contents/Eclipse/dropins", } +// resolveEclipseFeatureDirs returns the Eclipse feature directories to scan +// for the current platform. +func resolveEclipseFeatureDirs(exec executor.Executor) []string { + if exec.GOOS() != "windows" { + return eclipseFeatureDirsDarwin + } + + // On Windows, Eclipse installs to variable paths. Check the same locations + // as ideDefinitions and resolve features/dropins under each. + var dirs []string + candidates := []string{ + filepath.Join(exec.Getenv("PROGRAMFILES"), "eclipse"), + `C:\eclipse`, + } + + // Also check user profile eclipse dirs (Oomph installer) + userProfile := exec.Getenv("USERPROFILE") + if userProfile != "" { + eclipseUserDir := filepath.Join(userProfile, "eclipse") + if exec.DirExists(eclipseUserDir) { + entries, err := exec.ReadDir(eclipseUserDir) + if err == nil { + for _, e := range entries { + if e.IsDir() { + candidates = append(candidates, filepath.Join(eclipseUserDir, e.Name(), "eclipse")) + } + } + } + } + } + + for _, base := range candidates { + if !exec.DirExists(base) { + continue + } + featuresDir := filepath.Join(base, "features") + if exec.DirExists(featuresDir) { + dirs = append(dirs, featuresDir) + } + dropinsDir := filepath.Join(base, "dropins") + if exec.DirExists(dropinsDir) { + dirs = append(dirs, dropinsDir) + } + } + + return dirs +} + // eclipseBundledPrefixes are feature ID prefixes that ship as part of the // base Eclipse platform. Features matching these are tagged as "bundled". var eclipseBundledPrefixes = []string{ @@ -31,10 +79,7 @@ var eclipseBundledPrefixes = []string{ // all features tagged as "bundled" or "user_installed". func (d *ExtensionDetector) DetectEclipsePlugins() []model.Extension { var results []model.Extension - for _, dir := range eclipseFeatureDirs { - if !d.exec.DirExists(dir) { - continue - } + for _, dir := range resolveEclipseFeatureDirs(d.exec) { results = append(results, d.collectEclipseFeatures(dir)...) } return results From 16c90a1b821a9f1564284a4c392b3097b8f1e932 Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Tue, 21 Apr 2026 23:55:39 +0530 Subject: [PATCH 02/10] fix(windows): Eclipse plugin detection uses detected IDE install paths Eclipse plugin detection now receives the detected IDEs list and uses the actual Eclipse install path (which may have been discovered via registry) instead of hardcoded candidates. This ensures plugins are found even when Eclipse is installed at a custom path. Also parses bundles.info on Windows (modern Eclipse uses p2 provisioning) instead of scanning features/ directory which may not exist. --- internal/detector/eclipse_plugins.go | 84 ++++++++++++++++++++- internal/detector/extension.go | 6 +- internal/detector/jetbrains_plugins_test.go | 2 +- internal/scan/scanner.go | 2 +- internal/telemetry/telemetry.go | 2 +- 5 files changed, 87 insertions(+), 9 deletions(-) diff --git a/internal/detector/eclipse_plugins.go b/internal/detector/eclipse_plugins.go index e611974..2e24d2f 100644 --- a/internal/detector/eclipse_plugins.go +++ b/internal/detector/eclipse_plugins.go @@ -75,9 +75,16 @@ var eclipseBundledPrefixes = []string{ "org.eclipse.epp.package.", } -// DetectEclipsePlugins scans Eclipse feature directories and returns -// all features tagged as "bundled" or "user_installed". -func (d *ExtensionDetector) DetectEclipsePlugins() []model.Extension { +// DetectEclipsePlugins scans Eclipse installations for plugins. +// Uses detected IDE install paths when available (handles custom install locations). +// On macOS: scans features/dropins directories for id_version.jar files. +// On Windows: parses bundles.info (modern Eclipse uses p2 provisioning, +// not a features directory). +func (d *ExtensionDetector) DetectEclipsePlugins(ides []model.IDE) []model.Extension { + if d.exec.GOOS() == "windows" { + return d.detectEclipsePluginsWindows(ides) + } + var results []model.Extension for _, dir := range resolveEclipseFeatureDirs(d.exec) { results = append(results, d.collectEclipseFeatures(dir)...) @@ -85,6 +92,77 @@ func (d *ExtensionDetector) DetectEclipsePlugins() []model.Extension { return results } +// detectEclipsePluginsWindows finds Eclipse install directories and parses +// bundles.info for installed plugins. Uses the detected IDE install paths +// (which may have been discovered via registry) so custom paths are covered. +func (d *ExtensionDetector) detectEclipsePluginsWindows(ides []model.IDE) []model.Extension { + // Collect Eclipse install paths from detected IDEs (registry-aware) + var eclipseDirs []string + for _, ide := range ides { + if ide.IDEType == "eclipse" && ide.InstallPath != "" { + eclipseDirs = append(eclipseDirs, ide.InstallPath) + } + } + + var results []model.Extension + for _, base := range eclipseDirs { + bundlesInfo := filepath.Join(base, "configuration", "org.eclipse.equinox.simpleconfigurator", "bundles.info") + plugins := d.parseEclipseBundlesInfo(bundlesInfo) + results = append(results, plugins...) + } + return results +} + +// parseEclipseBundlesInfo reads an Eclipse bundles.info file and returns extensions. +// Format: id,version,location,startLevel,autoStart (one per line, # comments) +func (d *ExtensionDetector) parseEclipseBundlesInfo(filePath string) []model.Extension { + data, err := d.exec.ReadFile(filePath) + if err != nil { + return nil + } + + var results []model.Extension + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.SplitN(line, ",", 5) + if len(parts) < 2 { + continue + } + + pluginID := parts[0] + version := parts[1] + if pluginID == "" || version == "" { + continue + } + + publisher := "unknown" + pubParts := strings.SplitN(pluginID, ".", 3) + if len(pubParts) >= 2 { + publisher = pubParts[0] + "." + pubParts[1] + } + + source := "user_installed" + if isEclipseBundled(pluginID) { + source = "bundled" + } + + results = append(results, model.Extension{ + ID: pluginID, + Name: pluginID, + Version: version, + Publisher: publisher, + IDEType: "eclipse", + Source: source, + }) + } + + return results +} + // collectEclipseFeatures reads Eclipse features from a directory. // Each feature is tagged as "bundled" or "user_installed". func (d *ExtensionDetector) collectEclipseFeatures(featuresDir string) []model.Extension { diff --git a/internal/detector/extension.go b/internal/detector/extension.go index b46bfd0..a6a28bb 100644 --- a/internal/detector/extension.go +++ b/internal/detector/extension.go @@ -32,7 +32,7 @@ func NewExtensionDetector(exec executor.Executor) *ExtensionDetector { return &ExtensionDetector{exec: exec} } -func (d *ExtensionDetector) Detect(ctx context.Context, searchDirs []string) []model.Extension { +func (d *ExtensionDetector) Detect(ctx context.Context, searchDirs []string, ides []model.IDE) []model.Extension { homeDir := getHomeDir(d.exec) var results []model.Extension @@ -49,8 +49,8 @@ func (d *ExtensionDetector) Detect(ctx context.Context, searchDirs []string) []m // Xcode Source Editor extensions (via macOS pluginkit) results = append(results, d.DetectXcodeExtensions(ctx)...) - // Eclipse plugins (id_version.jar format) - results = append(results, d.DetectEclipsePlugins()...) + // Eclipse plugins — use detected IDE install paths for accurate discovery + results = append(results, d.DetectEclipsePlugins(ides)...) return results } diff --git a/internal/detector/jetbrains_plugins_test.go b/internal/detector/jetbrains_plugins_test.go index dcaf07f..384a270 100644 --- a/internal/detector/jetbrains_plugins_test.go +++ b/internal/detector/jetbrains_plugins_test.go @@ -171,7 +171,7 @@ func TestExtensionDetector_IncludesJetBrains(t *testing.T) { `IdeaVim2.10.0JetBrains`)) det := NewExtensionDetector(mock) - results := det.Detect(context.Background(), nil) + results := det.Detect(context.Background(), nil, nil) vscodeCount := 0 jetbrainsCount := 0 diff --git a/internal/scan/scanner.go b/internal/scan/scanner.go index 83a2bc3..6f840e0 100644 --- a/internal/scan/scanner.go +++ b/internal/scan/scanner.go @@ -58,7 +58,7 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { log.StepStart("Collecting IDE extensions") start = time.Now() extDetector := detector.NewExtensionDetector(exec) - extensions := extDetector.Detect(ctx, searchDirs) + extensions := extDetector.Detect(ctx, searchDirs, ides) // Collect JetBrains plugins jbDetector := detector.NewJetBrainsPluginDetector(exec) diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 2e36d54..dec586c 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -146,7 +146,7 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { // Collect extensions log.Progress("Scanning extensions...") extDetector := detector.NewExtensionDetector(exec) - extensions := extDetector.Detect(ctx, searchDirs) + extensions := extDetector.Detect(ctx, searchDirs, ides) // Collect JetBrains plugins jbDetector := detector.NewJetBrainsPluginDetector(exec) From 9165fda7a40d6449010b910489b40577c17696b3 Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Wed, 22 Apr 2026 00:11:48 +0530 Subject: [PATCH 03/10] feat(windows): robust multi-stage Eclipse plugin detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites Eclipse plugin detection with a comprehensive pipeline: - Stage 1: Uses detected IDE install paths (registry-aware) - Stage 2: Well-known path probes including Oomph installer paths, vendor variants (STS, MyEclipse), and D:-Z: drive letter scanning - Stage 4: Install validation (eclipse.ini + plugins/ + configuration/) before reporting — eliminates false positives - Stage 6: bundles.info parsing (primary) + dropins/ scanning (secondary) with dedup by symbolicName@version Also expands bundled prefix list to correctly classify Eclipse platform bundles (491 bundled vs 45 user-installed on a typical install). --- internal/detector/eclipse_plugins.go | 395 ++++++++++++++++++++------- 1 file changed, 299 insertions(+), 96 deletions(-) diff --git a/internal/detector/eclipse_plugins.go b/internal/detector/eclipse_plugins.go index 2e24d2f..04af499 100644 --- a/internal/detector/eclipse_plugins.go +++ b/internal/detector/eclipse_plugins.go @@ -8,33 +8,151 @@ import ( "github.com/step-security/dev-machine-guard/internal/model" ) -// macOS Eclipse feature directories (fixed paths). +// eclipseBundledPrefixes are bundle ID prefixes that ship as part of the +// base Eclipse platform. Bundles matching these are tagged as "bundled". +var eclipseBundledPrefixes = []string{ + "org.eclipse.", + "org.apache.", + "org.objectweb.", + "org.osgi.", + "org.sat4j.", + "org.tukaani.", + "org.w3c.", + "javax.", + "jakarta.", + "com.sun.", + "com.ibm.icu", + "com.jcraft.", + "com.google.gson", + "com.google.guava", + "bcpg", + "bcpkix", + "bcprov", + "bcutil", + "ch.qos.logback", + "net.i2p.crypto", + "slf4j.", + "args4j", + "biz.aQute.", +} + +// eclipseExePatterns are executable names that indicate an Eclipse-family install. +var eclipseExePatterns = []string{ + "eclipse.exe", + "eclipsec.exe", + "sts.exe", + "myeclipse.exe", +} + +// eclipseIniPatterns are .ini filenames for Eclipse-family products. +var eclipseIniPatterns = []string{ + "eclipse.ini", + "sts.ini", + "SpringToolSuite.ini", + "myeclipse.ini", +} + +// ---------- macOS detection (unchanged) ---------- + var eclipseFeatureDirsDarwin = []string{ "/Applications/Eclipse.app/Contents/Eclipse/features", "/Applications/Eclipse.app/Contents/Eclipse/dropins", } -// resolveEclipseFeatureDirs returns the Eclipse feature directories to scan -// for the current platform. -func resolveEclipseFeatureDirs(exec executor.Executor) []string { - if exec.GOOS() != "windows" { - return eclipseFeatureDirsDarwin +// ---------- Public API ---------- + +// DetectEclipsePlugins scans Eclipse installations for plugins. +// On macOS: scans features/dropins directories. +// On Windows: multi-stage pipeline using detected IDE paths, path probes, +// and drive letter scanning, with validation before reporting. +func (d *ExtensionDetector) DetectEclipsePlugins(ides []model.IDE) []model.Extension { + if d.exec.GOOS() != "windows" { + var results []model.Extension + for _, dir := range eclipseFeatureDirsDarwin { + if d.exec.DirExists(dir) { + results = append(results, d.collectEclipseFeatures(dir)...) + } + } + return results } + return d.detectEclipsePluginsWindows(ides) +} - // On Windows, Eclipse installs to variable paths. Check the same locations - // as ideDefinitions and resolve features/dropins under each. - var dirs []string - candidates := []string{ - filepath.Join(exec.Getenv("PROGRAMFILES"), "eclipse"), - `C:\eclipse`, +// ---------- Windows multi-stage pipeline ---------- + +func (d *ExtensionDetector) detectEclipsePluginsWindows(ides []model.IDE) []model.Extension { + // Stage 1+2: Collect candidate paths from detected IDEs + well-known locations + candidates := d.gatherEclipseCandidates(ides) + + // Stage 4: Validate each candidate + seen := make(map[string]bool) + var validInstalls []string + for _, path := range candidates { + key := strings.ToLower(filepath.Clean(path)) + if seen[key] { + continue + } + seen[key] = true + + if d.validateEclipseInstall(path) { + validInstalls = append(validInstalls, path) + } + } + + // Stage 6: Enumerate plugins from each validated install + pluginSeen := make(map[string]bool) + var results []model.Extension + for _, installDir := range validInstalls { + plugins := d.enumerateEclipsePlugins(installDir) + for _, p := range plugins { + dedupKey := p.ID + "@" + p.Version + if pluginSeen[dedupKey] { + continue + } + pluginSeen[dedupKey] = true + results = append(results, p) + } } - // Also check user profile eclipse dirs (Oomph installer) - userProfile := exec.Getenv("USERPROFILE") + return results +} + +// gatherEclipseCandidates collects candidate install paths from multiple sources. +func (d *ExtensionDetector) gatherEclipseCandidates(ides []model.IDE) []string { + var candidates []string + + // Source 1: Detected IDEs (registry-aware — handles custom install paths) + for _, ide := range ides { + if ide.IDEType == "eclipse" && ide.InstallPath != "" { + candidates = append(candidates, ide.InstallPath) + } + } + + // Source 2: Well-known path probes + programFiles := d.exec.Getenv("PROGRAMFILES") + programFilesX86 := d.exec.Getenv("PROGRAMFILES(X86)") + userProfile := d.exec.Getenv("USERPROFILE") + localAppData := d.exec.Getenv("LOCALAPPDATA") + + // Machine-scope + if programFiles != "" { + candidates = append(candidates, filepath.Join(programFiles, "eclipse")) + } + if programFilesX86 != "" { + candidates = append(candidates, filepath.Join(programFilesX86, "eclipse")) + } + candidates = append(candidates, `C:\eclipse`) + + // STS / vendor variants + if programFiles != "" { + candidates = append(candidates, d.globDirs(filepath.Join(programFiles, "sts-*"))...) + } + + // User-scope: Oomph installer default if userProfile != "" { eclipseUserDir := filepath.Join(userProfile, "eclipse") - if exec.DirExists(eclipseUserDir) { - entries, err := exec.ReadDir(eclipseUserDir) + if d.exec.DirExists(eclipseUserDir) { + entries, err := d.exec.ReadDir(eclipseUserDir) if err == nil { for _, e := range entries { if e.IsDir() { @@ -45,75 +163,97 @@ func resolveEclipseFeatureDirs(exec executor.Executor) []string { } } - for _, base := range candidates { - if !exec.DirExists(base) { - continue - } - featuresDir := filepath.Join(base, "features") - if exec.DirExists(featuresDir) { - dirs = append(dirs, featuresDir) - } - dropinsDir := filepath.Join(base, "dropins") - if exec.DirExists(dropinsDir) { - dirs = append(dirs, dropinsDir) - } + // User-scope: LOCALAPPDATA + if localAppData != "" { + candidates = append(candidates, d.globDirs(filepath.Join(localAppData, "Programs", "Eclipse*"))...) + candidates = append(candidates, d.globDirs(filepath.Join(localAppData, "Programs", "Spring*"))...) } - return dirs + // Drive letter probe: D:\eclipse through Z:\eclipse (fixed drives only) + for drive := 'D'; drive <= 'Z'; drive++ { + driveRoot := string(drive) + `:\eclipse` + candidates = append(candidates, driveRoot) + } + + return candidates } -// eclipseBundledPrefixes are feature ID prefixes that ship as part of the -// base Eclipse platform. Features matching these are tagged as "bundled". -var eclipseBundledPrefixes = []string{ - "org.eclipse.platform", - "org.eclipse.rcp", - "org.eclipse.e4.rcp", - "org.eclipse.equinox.", - "org.eclipse.help", - "org.eclipse.justj.", - "org.eclipse.oomph.", - "org.eclipse.epp.package.", +// globDirs expands a glob pattern and returns matching directories. +func (d *ExtensionDetector) globDirs(pattern string) []string { + matches, err := d.exec.Glob(pattern) + if err != nil || len(matches) == 0 { + return nil + } + var dirs []string + for _, m := range matches { + if d.exec.DirExists(m) { + dirs = append(dirs, m) + } + } + return dirs } -// DetectEclipsePlugins scans Eclipse installations for plugins. -// Uses detected IDE install paths when available (handles custom install locations). -// On macOS: scans features/dropins directories for id_version.jar files. -// On Windows: parses bundles.info (modern Eclipse uses p2 provisioning, -// not a features directory). -func (d *ExtensionDetector) DetectEclipsePlugins(ides []model.IDE) []model.Extension { - if d.exec.GOOS() == "windows" { - return d.detectEclipsePluginsWindows(ides) +// validateEclipseInstall checks that a candidate directory is actually an Eclipse install. +// Requires: an .ini file + plugins/ directory + configuration/ directory. +func (d *ExtensionDetector) validateEclipseInstall(installDir string) bool { + if !d.exec.DirExists(installDir) { + return false } - var results []model.Extension - for _, dir := range resolveEclipseFeatureDirs(d.exec) { - results = append(results, d.collectEclipseFeatures(dir)...) + // Check for eclipse.ini or branded variant + hasIni := false + for _, ini := range eclipseIniPatterns { + if d.exec.FileExists(filepath.Join(installDir, ini)) { + hasIni = true + break + } } - return results + if !hasIni { + return false + } + + // Check for plugins/ and configuration/ directories + if !d.exec.DirExists(filepath.Join(installDir, "plugins")) { + return false + } + if !d.exec.DirExists(filepath.Join(installDir, "configuration")) { + return false + } + + return true } -// detectEclipsePluginsWindows finds Eclipse install directories and parses -// bundles.info for installed plugins. Uses the detected IDE install paths -// (which may have been discovered via registry) so custom paths are covered. -func (d *ExtensionDetector) detectEclipsePluginsWindows(ides []model.IDE) []model.Extension { - // Collect Eclipse install paths from detected IDEs (registry-aware) - var eclipseDirs []string - for _, ide := range ides { - if ide.IDEType == "eclipse" && ide.InstallPath != "" { - eclipseDirs = append(eclipseDirs, ide.InstallPath) +// enumerateEclipsePlugins collects plugins from a validated Eclipse install. +// Primary: bundles.info. Secondary: dropins/ directory. +func (d *ExtensionDetector) enumerateEclipsePlugins(installDir string) []model.Extension { + var results []model.Extension + seen := make(map[string]bool) + + // Primary: bundles.info + bundlesInfo := filepath.Join(installDir, "configuration", + "org.eclipse.equinox.simpleconfigurator", "bundles.info") + for _, ext := range d.parseEclipseBundlesInfo(bundlesInfo) { + key := ext.ID + "@" + ext.Version + if !seen[key] { + seen[key] = true + results = append(results, ext) } } - var results []model.Extension - for _, base := range eclipseDirs { - bundlesInfo := filepath.Join(base, "configuration", "org.eclipse.equinox.simpleconfigurator", "bundles.info") - plugins := d.parseEclipseBundlesInfo(bundlesInfo) - results = append(results, plugins...) + // Secondary: dropins/ + dropinsDir := filepath.Join(installDir, "dropins") + for _, ext := range d.collectDropins(dropinsDir) { + key := ext.ID + "@" + ext.Version + if !seen[key] { + seen[key] = true + results = append(results, ext) + } } + return results } -// parseEclipseBundlesInfo reads an Eclipse bundles.info file and returns extensions. +// parseEclipseBundlesInfo reads an Eclipse bundles.info file. // Format: id,version,location,startLevel,autoStart (one per line, # comments) func (d *ExtensionDetector) parseEclipseBundlesInfo(filePath string) []model.Extension { data, err := d.exec.ReadFile(filePath) @@ -133,18 +273,13 @@ func (d *ExtensionDetector) parseEclipseBundlesInfo(filePath string) []model.Ext continue } - pluginID := parts[0] - version := parts[1] + pluginID := strings.TrimSpace(parts[0]) + version := strings.TrimSpace(parts[1]) if pluginID == "" || version == "" { continue } - publisher := "unknown" - pubParts := strings.SplitN(pluginID, ".", 3) - if len(pubParts) >= 2 { - publisher = pubParts[0] + "." + pubParts[1] - } - + publisher := extractPublisher(pluginID) source := "user_installed" if isEclipseBundled(pluginID) { source = "bundled" @@ -163,8 +298,88 @@ func (d *ExtensionDetector) parseEclipseBundlesInfo(filePath string) []model.Ext return results } -// collectEclipseFeatures reads Eclipse features from a directory. -// Each feature is tagged as "bundled" or "user_installed". +// collectDropins scans the dropins/ directory for additional plugins. +// Handles direct JARs, directory bundles, and nested eclipse/plugins layouts. +func (d *ExtensionDetector) collectDropins(dropinsDir string) []model.Extension { + if !d.exec.DirExists(dropinsDir) { + return nil + } + + entries, err := d.exec.ReadDir(dropinsDir) + if err != nil { + return nil + } + + var results []model.Extension + for _, entry := range entries { + name := entry.Name() + + // Direct JAR: dropins/com.example.plugin_1.0.0.jar + if !entry.IsDir() && strings.HasSuffix(name, ".jar") { + if ext := parseEclipsePluginName(strings.TrimSuffix(name, ".jar")); ext != nil { + ext.Source = "dropins" + results = append(results, *ext) + } + continue + } + + if !entry.IsDir() { + continue + } + + // Directory bundle: dropins/com.example.plugin_1.0.0/ + if ext := parseEclipsePluginName(name); ext != nil { + ext.Source = "dropins" + results = append(results, *ext) + continue + } + + // Nested layout: dropins//eclipse/plugins/ or dropins//plugins/ + subPath := filepath.Join(dropinsDir, name) + for _, nested := range []string{ + filepath.Join(subPath, "eclipse", "plugins"), + filepath.Join(subPath, "plugins"), + } { + if !d.exec.DirExists(nested) { + continue + } + nestedEntries, err := d.exec.ReadDir(nested) + if err != nil { + continue + } + for _, ne := range nestedEntries { + baseName := strings.TrimSuffix(ne.Name(), ".jar") + if ext := parseEclipsePluginName(baseName); ext != nil { + ext.Source = "dropins" + results = append(results, *ext) + } + } + } + } + + return results +} + +// ---------- Shared helpers ---------- + +func isEclipseBundled(pluginID string) bool { + for _, prefix := range eclipseBundledPrefixes { + if strings.HasPrefix(pluginID, prefix) { + return true + } + } + return false +} + +func extractPublisher(pluginID string) string { + parts := strings.SplitN(pluginID, ".", 3) + if len(parts) >= 2 { + return parts[0] + "." + parts[1] + } + return "unknown" +} + +// collectEclipseFeatures reads Eclipse features from a directory (macOS). func (d *ExtensionDetector) collectEclipseFeatures(featuresDir string) []model.Extension { entries, err := d.exec.ReadDir(featuresDir) if err != nil { @@ -181,7 +396,6 @@ func (d *ExtensionDetector) collectEclipseFeatures(featuresDir string) []model.E continue } - // Tag as bundled or user_installed if isEclipseBundled(ext.ID) { ext.Source = "bundled" } else { @@ -200,19 +414,8 @@ func (d *ExtensionDetector) collectEclipseFeatures(featuresDir string) []model.E return results } -func isEclipseBundled(pluginID string) bool { - for _, prefix := range eclipseBundledPrefixes { - if strings.HasPrefix(pluginID, prefix) { - return true - } - } - return false -} - // parseEclipsePluginName parses "id_version" format. // Example: "com.github.spotbugs.plugin.eclipse_4.9.8.r202510181643-c1fa7f2" -// -// → id=com.github.spotbugs.plugin.eclipse, version=4.9.8.r202510181643-c1fa7f2 func parseEclipsePluginName(name string) *model.Extension { lastUnderscore := -1 for i := len(name) - 1; i >= 0; i-- { @@ -235,17 +438,17 @@ func parseEclipsePluginName(name string) *model.Extension { return nil } - publisher := "unknown" - parts := strings.SplitN(pluginID, ".", 3) - if len(parts) >= 2 { - publisher = parts[0] + "." + parts[1] - } - return &model.Extension{ ID: pluginID, Name: pluginID, Version: version, - Publisher: publisher, + Publisher: extractPublisher(pluginID), IDEType: "eclipse", } } + +// resolveEclipseFeatureDirs is kept for backward compatibility but only used on macOS. +func resolveEclipseFeatureDirs(exec executor.Executor) []string { + _ = exec // unused on this path but kept for interface consistency + return eclipseFeatureDirsDarwin +} From eb751bbcd67b50394ef66cdae712ac38e821db13 Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Wed, 22 Apr 2026 00:21:33 +0530 Subject: [PATCH 04/10] fix(eclipse): expand bundled prefix list to accurately identify marketplace plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expanded eclipseBundledPrefixes to cover all standard Eclipse platform dependencies (JUnit, JaCoCo, Gradle tooling, crypto libs, etc.). This reduces false positives from 45 to 4 — only truly marketplace- installed plugins (Claude Code, Putman) are now classified as user_installed. --- internal/detector/eclipse_plugins.go | 56 +++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 9 deletions(-) diff --git a/internal/detector/eclipse_plugins.go b/internal/detector/eclipse_plugins.go index 04af499..f24fb34 100644 --- a/internal/detector/eclipse_plugins.go +++ b/internal/detector/eclipse_plugins.go @@ -10,30 +10,68 @@ import ( // eclipseBundledPrefixes are bundle ID prefixes that ship as part of the // base Eclipse platform. Bundles matching these are tagged as "bundled". +// eclipseBundledPrefixes identifies bundles that ship as part of the Eclipse +// platform or are standard dependencies. Everything NOT matching is classified +// as "marketplace" (user-installed from Eclipse Marketplace or update sites). var eclipseBundledPrefixes = []string{ + // Eclipse platform "org.eclipse.", - "org.apache.", - "org.objectweb.", + "epp.", + "configure.", + // OSGi / Equinox runtime "org.osgi.", - "org.sat4j.", - "org.tukaani.", - "org.w3c.", + // Apache libraries + "org.apache.", + // JVM / standard APIs "javax.", "jakarta.", "com.sun.", "com.ibm.icu", - "com.jcraft.", - "com.google.gson", - "com.google.guava", + // Common platform dependencies + "org.objectweb.", + "org.sat4j.", + "org.tukaani.", + "org.w3c.", + "org.xml.sax", + "org.hamcrest", + "org.junit", + "org.opentest4j", + "org.apiguardian", + "org.commonmark", + "org.mortbay.", + "org.jdom", + "org.jsoup", + "org.snakeyaml", + "org.jcodings", + "org.joni", + "org.glassfish.", + "org.gradle.", + "org.jacoco.", + // JUnit platform (ships with Eclipse JDT) + "junit-jupiter", + "junit-platform", + "junit-vintage", + // Crypto / SSH / networking "bcpg", "bcpkix", "bcprov", "bcutil", - "ch.qos.logback", + "com.jcraft.", "net.i2p.crypto", + "net.bytebuddy", + // Google / JSON / utilities + "com.google.gson", + "com.google.guava", + "com.googlecode.", + // Logging + "ch.qos.logback", "slf4j.", + // Build tooling "args4j", "biz.aQute.", + // Other standard Eclipse deps + "com.sun.xml.", + "jaxen", } // eclipseExePatterns are executable names that indicate an Eclipse-family install. From 46ef8f5a80da748a6f33374c1377975012eac545 Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Wed, 22 Apr 2026 00:28:06 +0530 Subject: [PATCH 05/10] feat(eclipse): use p2 director API for authoritative marketplace plugin detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of heuristic prefix-matching on bundles.info, use Eclipse's own p2 director (eclipsec.exe -listInstalledRoots) to get the authoritative list of installed root features. Marketplace-installed features are those not prefixed with org.eclipse.* or epp.*. The p2 director output also enriches bundles.info — bundles belonging to marketplace features are tagged as 'marketplace' source. Falls back to bundles.info-only parsing if eclipsec.exe is unavailable. --- internal/detector/eclipse_plugins.go | 136 +++++++++++++++++++++++++-- 1 file changed, 127 insertions(+), 9 deletions(-) diff --git a/internal/detector/eclipse_plugins.go b/internal/detector/eclipse_plugins.go index f24fb34..ca53f2f 100644 --- a/internal/detector/eclipse_plugins.go +++ b/internal/detector/eclipse_plugins.go @@ -1,8 +1,10 @@ package detector import ( + "context" "path/filepath" "strings" + "time" "github.com/step-security/dev-machine-guard/internal/executor" "github.com/step-security/dev-machine-guard/internal/model" @@ -262,23 +264,72 @@ func (d *ExtensionDetector) validateEclipseInstall(installDir string) bool { } // enumerateEclipsePlugins collects plugins from a validated Eclipse install. -// Primary: bundles.info. Secondary: dropins/ directory. +// Uses the p2 director API (eclipsec.exe -listInstalledRoots) to get authoritative +// installed features, then enriches with bundles.info for full bundle details. func (d *ExtensionDetector) enumerateEclipsePlugins(installDir string) []model.Extension { + // Try p2 director first — returns authoritative list of installed root features + roots := d.queryP2InstalledRoots(installDir) + + // Build a set of root feature prefixes for marketplace classification. + // Root features that are NOT org.eclipse.* or epp.* are marketplace-installed. + marketplaceRoots := make(map[string]bool) + for _, root := range roots { + if !strings.HasPrefix(root.ID, "org.eclipse.") && !strings.HasPrefix(root.ID, "epp.") { + // Strip ".feature.group" suffix to get the base feature ID + baseID := strings.TrimSuffix(root.ID, ".feature.group") + baseID = strings.TrimSuffix(baseID, ".feature") + marketplaceRoots[baseID] = true + } + } + var results []model.Extension seen := make(map[string]bool) - // Primary: bundles.info - bundlesInfo := filepath.Join(installDir, "configuration", - "org.eclipse.equinox.simpleconfigurator", "bundles.info") - for _, ext := range d.parseEclipseBundlesInfo(bundlesInfo) { - key := ext.ID + "@" + ext.Version - if !seen[key] { + // If we got roots from p2 director, use them for classification + if len(roots) > 0 { + // Add root features as extensions + for _, root := range roots { + key := root.ID + "@" + root.Version + if seen[key] { + continue + } seen[key] = true - results = append(results, ext) + results = append(results, root) + } + + // Also parse bundles.info for non-root bundles that belong to marketplace features. + // A bundle belongs to a marketplace feature if its ID starts with any marketplace root prefix. + bundlesInfo := filepath.Join(installDir, "configuration", + "org.eclipse.equinox.simpleconfigurator", "bundles.info") + for _, ext := range d.parseEclipseBundlesInfo(bundlesInfo) { + key := ext.ID + "@" + ext.Version + if seen[key] { + continue + } + // Check if this bundle belongs to a marketplace feature + for prefix := range marketplaceRoots { + if strings.HasPrefix(ext.ID, prefix) { + ext.Source = "marketplace" + seen[key] = true + results = append(results, ext) + break + } + } + } + } else { + // Fallback: bundles.info parsing (p2 director unavailable) + bundlesInfo := filepath.Join(installDir, "configuration", + "org.eclipse.equinox.simpleconfigurator", "bundles.info") + for _, ext := range d.parseEclipseBundlesInfo(bundlesInfo) { + key := ext.ID + "@" + ext.Version + if !seen[key] { + seen[key] = true + results = append(results, ext) + } } } - // Secondary: dropins/ + // Also check dropins/ dropinsDir := filepath.Join(installDir, "dropins") for _, ext := range d.collectDropins(dropinsDir) { key := ext.ID + "@" + ext.Version @@ -291,6 +342,73 @@ func (d *ExtensionDetector) enumerateEclipsePlugins(installDir string) []model.E return results } +// queryP2InstalledRoots invokes Eclipse's p2 director to get the authoritative +// list of installed root features. Returns nil if eclipsec.exe is not available +// or the command fails. Output format: "feature.id/version" per line. +func (d *ExtensionDetector) queryP2InstalledRoots(installDir string) []model.Extension { + // Find eclipsec.exe (console launcher) + eclipsec := filepath.Join(installDir, "eclipsec.exe") + if !d.exec.FileExists(eclipsec) { + // Try eclipse.exe as fallback (may open a window briefly) + eclipsec = filepath.Join(installDir, "eclipse.exe") + if !d.exec.FileExists(eclipsec) { + return nil + } + } + + ctx := context.Background() + stdout, _, exitCode, err := d.exec.RunWithTimeout(ctx, 30*time.Second, + eclipsec, "-nosplash", + "-application", "org.eclipse.equinox.p2.director", + "-listInstalledRoots") + if err != nil || exitCode != 0 { + return nil + } + + var results []model.Extension + for _, line := range strings.Split(stdout, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // Skip debug/log lines — they contain spaces and timestamps. + // Valid p2 output lines are "feature.id/version" with no spaces before the "/" + if strings.Contains(line, " ") { + continue + } + if !strings.Contains(line, "/") { + continue + } + + parts := strings.SplitN(line, "/", 2) + if len(parts) != 2 { + continue + } + + featureID := parts[0] + version := parts[1] + if featureID == "" || version == "" { + continue + } + + source := "bundled" + if !strings.HasPrefix(featureID, "org.eclipse.") && !strings.HasPrefix(featureID, "epp.") { + source = "marketplace" + } + + results = append(results, model.Extension{ + ID: featureID, + Name: featureID, + Version: version, + Publisher: extractPublisher(featureID), + IDEType: "eclipse", + Source: source, + }) + } + + return results +} + // parseEclipseBundlesInfo reads an Eclipse bundles.info file. // Format: id,version,location,startLevel,autoStart (one per line, # comments) func (d *ExtensionDetector) parseEclipseBundlesInfo(filePath string) []model.Extension { From 3cb22fd012b3a776bd74edd2e8f5cfef5fc56fda Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Wed, 22 Apr 2026 00:35:26 +0530 Subject: [PATCH 06/10] feat: filter bundled plugins by default, add --include-bundled-plugins flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit By default, only user-installed and marketplace plugins are included in scan output and telemetry. Bundled/platform plugins (e.g., Eclipse's 500+ OSGi bundles) are filtered out to reduce noise and payload size. Use --include-bundled-plugins to include them if needed. Payload reduction: 124KB → 21KB for a typical Eclipse install. --- internal/cli/cli.go | 11 +++++++---- internal/scan/scanner.go | 17 +++++++++++++++++ internal/telemetry/telemetry.go | 17 +++++++++++++++++ 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 3c509e3..4bf6426 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -17,10 +17,11 @@ type Config struct { HTMLOutputFile string // set by --html (not persisted) ColorMode string // "auto", "always", "never" Verbose bool // --verbose - EnableNPMScan *bool // nil=auto, true/false=explicit - EnableBrewScan *bool // nil=auto, true/false=explicit - EnablePythonScan *bool // nil=auto, true/false=explicit - SearchDirs []string // defaults to ["$HOME"] + EnableNPMScan *bool // nil=auto, true/false=explicit + EnableBrewScan *bool // nil=auto, true/false=explicit + EnablePythonScan *bool // nil=auto, true/false=explicit + IncludeBundledPlugins bool // --include-bundled-plugins: include bundled/platform plugins in output + SearchDirs []string // defaults to ["$HOME"] } // Parse parses CLI arguments and returns a Config. @@ -83,6 +84,8 @@ func Parse(args []string) (*Config, error) { case arg == "--disable-python-scan": v := false cfg.EnablePythonScan = &v + case arg == "--include-bundled-plugins": + cfg.IncludeBundledPlugins = true case strings.HasPrefix(arg, "--color="): mode := strings.TrimPrefix(arg, "--color=") if mode != "auto" && mode != "always" && mode != "never" { diff --git a/internal/scan/scanner.go b/internal/scan/scanner.go index 6f840e0..8241e33 100644 --- a/internal/scan/scanner.go +++ b/internal/scan/scanner.go @@ -64,6 +64,11 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { jbDetector := detector.NewJetBrainsPluginDetector(exec) jbPlugins := jbDetector.Detect(ctx, ides) extensions = append(extensions, jbPlugins...) + + // Filter out bundled/platform plugins unless explicitly requested + if !cfg.IncludeBundledPlugins { + extensions = filterUserInstalledExtensions(extensions) + } log.StepDone(time.Since(start)) // Node.js scanning (community mode defaults to off, explicit flag overrides) @@ -254,3 +259,15 @@ func mcpConfigsToCommunity(configs []model.MCPConfig) []model.MCPConfig { } return configs } + +// filterUserInstalledExtensions removes bundled/platform extensions, +// keeping only user-installed and marketplace extensions. +func filterUserInstalledExtensions(exts []model.Extension) []model.Extension { + var filtered []model.Extension + for _, ext := range exts { + if ext.Source != "bundled" { + filtered = append(filtered, ext) + } + } + return filtered +} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index dec586c..00e4308 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -152,6 +152,11 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { jbDetector := detector.NewJetBrainsPluginDetector(exec) jbPlugins := jbDetector.Detect(ctx, ides) extensions = append(extensions, jbPlugins...) + + // Filter out bundled/platform plugins unless explicitly requested + if !cfg.IncludeBundledPlugins { + extensions = filterUserInstalledExtensions(extensions) + } log.Progress("Found total of %d IDE extensions", len(extensions)) fmt.Fprintln(os.Stderr) @@ -610,3 +615,15 @@ func ideDisplayName(ideType string) string { return ideType } } + +// filterUserInstalledExtensions removes bundled/platform extensions, +// keeping only user-installed and marketplace extensions. +func filterUserInstalledExtensions(exts []model.Extension) []model.Extension { + var filtered []model.Extension + for _, ext := range exts { + if ext.Source != "bundled" { + filtered = append(filtered, ext) + } + } + return filtered +} From 6f3a22bbb8156cb9c93dadbdd3b94f72f6b528f9 Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Wed, 22 Apr 2026 00:40:08 +0530 Subject: [PATCH 07/10] fix: scope bundled plugin filter to Windows only macOS detection doesn't produce bundled plugins in the same volume, so the filter is unnecessary there. Gate on exec.GOOS() == windows. --- internal/scan/scanner.go | 6 ++++-- internal/telemetry/telemetry.go | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/internal/scan/scanner.go b/internal/scan/scanner.go index 8241e33..7e3d66c 100644 --- a/internal/scan/scanner.go +++ b/internal/scan/scanner.go @@ -65,8 +65,10 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { jbPlugins := jbDetector.Detect(ctx, ides) extensions = append(extensions, jbPlugins...) - // Filter out bundled/platform plugins unless explicitly requested - if !cfg.IncludeBundledPlugins { + // On Windows, filter out bundled/platform plugins (e.g., Eclipse's 500+ OSGi + // bundles) unless explicitly requested. macOS detection doesn't produce bundled + // plugins in significant volume, so this filter is Windows-only. + if exec.GOOS() == "windows" && !cfg.IncludeBundledPlugins { extensions = filterUserInstalledExtensions(extensions) } log.StepDone(time.Since(start)) diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 00e4308..fec149e 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -153,8 +153,9 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { jbPlugins := jbDetector.Detect(ctx, ides) extensions = append(extensions, jbPlugins...) - // Filter out bundled/platform plugins unless explicitly requested - if !cfg.IncludeBundledPlugins { + // On Windows, filter out bundled/platform plugins (e.g., Eclipse's 500+ OSGi + // bundles) unless explicitly requested. macOS is unaffected. + if exec.GOOS() == "windows" && !cfg.IncludeBundledPlugins { extensions = filterUserInstalledExtensions(extensions) } log.Progress("Found total of %d IDE extensions", len(extensions)) From ed292a649c3a457e515738700e110948288c5fb0 Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Wed, 22 Apr 2026 00:41:19 +0530 Subject: [PATCH 08/10] feat(windows): eclipse plugin detection Signed-off-by: Swarit Pandey --- internal/detector/ide.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/detector/ide.go b/internal/detector/ide.go index 7aa5406..75150e1 100644 --- a/internal/detector/ide.go +++ b/internal/detector/ide.go @@ -41,19 +41,22 @@ var ideDefinitions = []ideSpec{ { AppName: "Cursor", IDEType: "cursor", Vendor: "Cursor", AppPath: "/Applications/Cursor.app", BinaryPath: "Contents/Resources/app/bin/cursor", - WinPaths: []string{`%LOCALAPPDATA%\Programs\cursor`}, WinBinary: "Cursor.exe", + // Use the .cmd console wrapper, not Cursor.exe (GUI binary that briefly opens a window) + WinPaths: []string{`%LOCALAPPDATA%\Programs\cursor`}, WinBinary: `resources\app\bin\cursor.cmd`, VersionFlag: "--version", }, { AppName: "Windsurf", IDEType: "windsurf", Vendor: "Codeium", AppPath: "/Applications/Windsurf.app", BinaryPath: "Contents/MacOS/Windsurf", - WinPaths: []string{`%LOCALAPPDATA%\Programs\Windsurf`}, WinBinary: "Windsurf.exe", + // Use the .cmd console wrapper to avoid launching the GUI + WinPaths: []string{`%LOCALAPPDATA%\Programs\Windsurf`}, WinBinary: `resources\app\bin\windsurf.cmd`, VersionFlag: "--version", }, { AppName: "Antigravity", IDEType: "antigravity", Vendor: "Google", AppPath: "/Applications/Antigravity.app", BinaryPath: "Contents/MacOS/Antigravity", - WinPaths: []string{`%LOCALAPPDATA%\Programs\Antigravity`}, WinBinary: "Antigravity.exe", + // Use the .cmd console wrapper to avoid launching the GUI + WinPaths: []string{`%LOCALAPPDATA%\Programs\Antigravity`}, WinBinary: `resources\app\bin\antigravity.cmd`, VersionFlag: "--version", }, { From 5891fc74e5bf6ddd6a606c7f8939a0c4f4602fdc Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Wed, 22 Apr 2026 00:46:17 +0530 Subject: [PATCH 09/10] fix(lint): remove unused eclipseExePatterns and resolveEclipseFeatureDirs --- internal/detector/eclipse_plugins.go | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/internal/detector/eclipse_plugins.go b/internal/detector/eclipse_plugins.go index ca53f2f..bc21681 100644 --- a/internal/detector/eclipse_plugins.go +++ b/internal/detector/eclipse_plugins.go @@ -6,7 +6,6 @@ import ( "strings" "time" - "github.com/step-security/dev-machine-guard/internal/executor" "github.com/step-security/dev-machine-guard/internal/model" ) @@ -76,14 +75,6 @@ var eclipseBundledPrefixes = []string{ "jaxen", } -// eclipseExePatterns are executable names that indicate an Eclipse-family install. -var eclipseExePatterns = []string{ - "eclipse.exe", - "eclipsec.exe", - "sts.exe", - "myeclipse.exe", -} - // eclipseIniPatterns are .ini filenames for Eclipse-family products. var eclipseIniPatterns = []string{ "eclipse.ini", @@ -603,8 +594,3 @@ func parseEclipsePluginName(name string) *model.Extension { } } -// resolveEclipseFeatureDirs is kept for backward compatibility but only used on macOS. -func resolveEclipseFeatureDirs(exec executor.Executor) []string { - _ = exec // unused on this path but kept for interface consistency - return eclipseFeatureDirsDarwin -} From 79b1250f58f14b23aa7c88ed9496bc98201156a4 Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Wed, 22 Apr 2026 01:52:36 +0530 Subject: [PATCH 10/10] fix: address Copilot review comments on PR #32 - Move filterUserInstalledExtensions to model package (was duplicated) - Thread context.Context through Eclipse detection pipeline (was using context.Background()) - Drive letter probes check if drive exists before probing (avoids network drive timeouts) - Fix eclipseBundledPrefixes comment to match actual Source values - Add --include-bundled-plugins to help text - Add 14 unit tests for Eclipse detection pipeline (validation, bundles.info parsing, p2 director output parsing, dropins, etc.) --- internal/cli/cli.go | 7 +- internal/detector/eclipse_plugins.go | 30 ++- internal/detector/eclipse_plugins_test.go | 293 ++++++++++++++++++++++ internal/detector/extension.go | 2 +- internal/model/model.go | 12 + internal/scan/scanner.go | 13 +- internal/telemetry/telemetry.go | 13 +- 7 files changed, 329 insertions(+), 41 deletions(-) create mode 100644 internal/detector/eclipse_plugins_test.go diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 4bf6426..2d9681d 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -148,9 +148,10 @@ Options: --disable-npm-scan Disable Node.js package scanning --enable-brew-scan Enable Homebrew package scanning --disable-brew-scan Disable Homebrew package scanning - --enable-python-scan Enable Python package scanning - --disable-python-scan Disable Python package scanning - --verbose Show progress messages (suppressed by default) + --enable-python-scan Enable Python package scanning + --disable-python-scan Disable Python package scanning + --include-bundled-plugins Include bundled/platform plugins in output (Windows) + --verbose Show progress messages (suppressed by default) --color=WHEN Color mode: auto | always | never (default: auto) -v, --version Show version -h, --help Show this help diff --git a/internal/detector/eclipse_plugins.go b/internal/detector/eclipse_plugins.go index bc21681..4f2cd11 100644 --- a/internal/detector/eclipse_plugins.go +++ b/internal/detector/eclipse_plugins.go @@ -12,8 +12,9 @@ import ( // eclipseBundledPrefixes are bundle ID prefixes that ship as part of the // base Eclipse platform. Bundles matching these are tagged as "bundled". // eclipseBundledPrefixes identifies bundles that ship as part of the Eclipse -// platform or are standard dependencies. Everything NOT matching is classified -// as "marketplace" (user-installed from Eclipse Marketplace or update sites). +// platform or are standard dependencies. Bundles that do not match these +// prefixes are treated as non-bundled and may be classified into source +// categories such as "marketplace", "user_installed", or "dropins". var eclipseBundledPrefixes = []string{ // Eclipse platform "org.eclipse.", @@ -96,7 +97,7 @@ var eclipseFeatureDirsDarwin = []string{ // On macOS: scans features/dropins directories. // On Windows: multi-stage pipeline using detected IDE paths, path probes, // and drive letter scanning, with validation before reporting. -func (d *ExtensionDetector) DetectEclipsePlugins(ides []model.IDE) []model.Extension { +func (d *ExtensionDetector) DetectEclipsePlugins(ctx context.Context, ides []model.IDE) []model.Extension { if d.exec.GOOS() != "windows" { var results []model.Extension for _, dir := range eclipseFeatureDirsDarwin { @@ -106,12 +107,12 @@ func (d *ExtensionDetector) DetectEclipsePlugins(ides []model.IDE) []model.Exten } return results } - return d.detectEclipsePluginsWindows(ides) + return d.detectEclipsePluginsWindows(ctx, ides) } // ---------- Windows multi-stage pipeline ---------- -func (d *ExtensionDetector) detectEclipsePluginsWindows(ides []model.IDE) []model.Extension { +func (d *ExtensionDetector) detectEclipsePluginsWindows(ctx context.Context, ides []model.IDE) []model.Extension { // Stage 1+2: Collect candidate paths from detected IDEs + well-known locations candidates := d.gatherEclipseCandidates(ides) @@ -134,7 +135,7 @@ func (d *ExtensionDetector) detectEclipsePluginsWindows(ides []model.IDE) []mode pluginSeen := make(map[string]bool) var results []model.Extension for _, installDir := range validInstalls { - plugins := d.enumerateEclipsePlugins(installDir) + plugins := d.enumerateEclipsePlugins(ctx, installDir) for _, p := range plugins { dedupKey := p.ID + "@" + p.Version if pluginSeen[dedupKey] { @@ -200,10 +201,14 @@ func (d *ExtensionDetector) gatherEclipseCandidates(ides []model.IDE) []string { candidates = append(candidates, d.globDirs(filepath.Join(localAppData, "Programs", "Spring*"))...) } - // Drive letter probe: D:\eclipse through Z:\eclipse (fixed drives only) + // Drive letter probe: D:\eclipse through Z:\eclipse. + // Only probe drives that actually exist to avoid slow network drive timeouts. for drive := 'D'; drive <= 'Z'; drive++ { - driveRoot := string(drive) + `:\eclipse` - candidates = append(candidates, driveRoot) + driveRoot := string(drive) + `:\` + if !d.exec.DirExists(driveRoot) { + continue + } + candidates = append(candidates, string(drive)+`:\eclipse`) } return candidates @@ -257,9 +262,9 @@ func (d *ExtensionDetector) validateEclipseInstall(installDir string) bool { // enumerateEclipsePlugins collects plugins from a validated Eclipse install. // Uses the p2 director API (eclipsec.exe -listInstalledRoots) to get authoritative // installed features, then enriches with bundles.info for full bundle details. -func (d *ExtensionDetector) enumerateEclipsePlugins(installDir string) []model.Extension { +func (d *ExtensionDetector) enumerateEclipsePlugins(ctx context.Context, installDir string) []model.Extension { // Try p2 director first — returns authoritative list of installed root features - roots := d.queryP2InstalledRoots(installDir) + roots := d.queryP2InstalledRoots(ctx, installDir) // Build a set of root feature prefixes for marketplace classification. // Root features that are NOT org.eclipse.* or epp.* are marketplace-installed. @@ -336,7 +341,7 @@ func (d *ExtensionDetector) enumerateEclipsePlugins(installDir string) []model.E // queryP2InstalledRoots invokes Eclipse's p2 director to get the authoritative // list of installed root features. Returns nil if eclipsec.exe is not available // or the command fails. Output format: "feature.id/version" per line. -func (d *ExtensionDetector) queryP2InstalledRoots(installDir string) []model.Extension { +func (d *ExtensionDetector) queryP2InstalledRoots(ctx context.Context, installDir string) []model.Extension { // Find eclipsec.exe (console launcher) eclipsec := filepath.Join(installDir, "eclipsec.exe") if !d.exec.FileExists(eclipsec) { @@ -347,7 +352,6 @@ func (d *ExtensionDetector) queryP2InstalledRoots(installDir string) []model.Ext } } - ctx := context.Background() stdout, _, exitCode, err := d.exec.RunWithTimeout(ctx, 30*time.Second, eclipsec, "-nosplash", "-application", "org.eclipse.equinox.p2.director", diff --git a/internal/detector/eclipse_plugins_test.go b/internal/detector/eclipse_plugins_test.go new file mode 100644 index 0000000..32d760e --- /dev/null +++ b/internal/detector/eclipse_plugins_test.go @@ -0,0 +1,293 @@ +package detector + +import ( + "context" + "os" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +func TestValidateEclipseInstall(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + + installDir := `C:\eclipse` + mock.SetDir(installDir) + // filepath.Join on macOS uses "/" between parts but preserves existing "\" + mock.SetFile(installDir+"/eclipse.ini", []byte{}) + mock.SetDir(installDir + "/plugins") + mock.SetDir(installDir + "/configuration") + + det := &ExtensionDetector{exec: mock} + if !det.validateEclipseInstall(installDir) { + t.Error("expected valid Eclipse install") + } +} + +func TestValidateEclipseInstall_MissingIni(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + + installDir := `C:\eclipse` + mock.SetDir(installDir) + // No eclipse.ini + mock.SetDir(installDir + `\plugins`) + mock.SetDir(installDir + `\configuration`) + + det := &ExtensionDetector{exec: mock} + if det.validateEclipseInstall(installDir) { + t.Error("expected invalid — missing eclipse.ini") + } +} + +func TestValidateEclipseInstall_BrandedIni(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + + installDir := `C:\sts` + mock.SetDir(installDir) + mock.SetFile(installDir+"/sts.ini", []byte{}) // Spring Tool Suite + mock.SetDir(installDir + "/plugins") + mock.SetDir(installDir + "/configuration") + + det := &ExtensionDetector{exec: mock} + if !det.validateEclipseInstall(installDir) { + t.Error("expected valid — branded sts.ini should count") + } +} + +func TestParseEclipseBundlesInfo(t *testing.T) { + mock := executor.NewMock() + mock.SetFile("/test/bundles.info", []byte(`#encoding=UTF-8 +#version=1 +org.eclipse.platform,4.39.0,file:/plugins/org.eclipse.platform_4.39.0.jar,4,false +com.anthropic.claudecode.eclipse,2.3.11,file:/plugins/com.anthropic.claudecode.eclipse_2.3.11.jar,4,false +`)) + + det := &ExtensionDetector{exec: mock} + results := det.parseEclipseBundlesInfo("/test/bundles.info") + + if len(results) != 2 { + t.Fatalf("expected 2 bundles, got %d", len(results)) + } + + if results[0].ID != "org.eclipse.platform" { + t.Errorf("expected org.eclipse.platform, got %s", results[0].ID) + } + if results[0].Source != "bundled" { + t.Errorf("expected bundled, got %s", results[0].Source) + } + + if results[1].ID != "com.anthropic.claudecode.eclipse" { + t.Errorf("expected com.anthropic.claudecode.eclipse, got %s", results[1].ID) + } + if results[1].Source != "user_installed" { + t.Errorf("expected user_installed, got %s", results[1].Source) + } +} + +func TestParseEclipseBundlesInfo_MissingFile(t *testing.T) { + mock := executor.NewMock() + det := &ExtensionDetector{exec: mock} + results := det.parseEclipseBundlesInfo("/nonexistent") + if len(results) != 0 { + t.Errorf("expected 0, got %d", len(results)) + } +} + +func TestQueryP2InstalledRoots_ParsesOutput(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + // filepath.Join on macOS uses "/" between parts + eclipsec := `C:\eclipse` + "/eclipsec.exe" + mock.SetFile(eclipsec, []byte{}) + + mock.SetCommand( + "civerooni.com.putman.feature.feature.group/1.0.0\n"+ + "com.anthropic.claudecode.eclipse.feature.feature.group/2.3.11\n"+ + "org.eclipse.jdt.feature.group/3.20.0\n"+ + "epp.package.java/4.39.0\n"+ + "Operation completed in 2131 ms.\n", + "", 0, + eclipsec, "-nosplash", + "-application", "org.eclipse.equinox.p2.director", + "-listInstalledRoots", + ) + + det := &ExtensionDetector{exec: mock} + results := det.queryP2InstalledRoots(context.Background(), `C:\eclipse`) + + if len(results) != 4 { + t.Fatalf("expected 4 root features, got %d", len(results)) + } + + marketplace := 0 + bundled := 0 + for _, r := range results { + if r.Source == "marketplace" { + marketplace++ + } else if r.Source == "bundled" { + bundled++ + } + } + + if marketplace != 2 { + t.Errorf("expected 2 marketplace, got %d", marketplace) + } + if bundled != 2 { + t.Errorf("expected 2 bundled, got %d", bundled) + } +} + +func TestQueryP2InstalledRoots_SkipsDebugLines(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + eclipsec := `C:\eclipse` + "/eclipsec.exe" + mock.SetFile(eclipsec, []byte{}) + + mock.SetCommand( + "18:53:22.314 [Start Level] DEBUG org.eclipse.jgit -- some debug\n"+ + "org.eclipse.platform.feature.group/4.39.0\n"+ + "Operation completed in 100 ms.\n", + "", 0, + eclipsec, "-nosplash", + "-application", "org.eclipse.equinox.p2.director", + "-listInstalledRoots", + ) + + det := &ExtensionDetector{exec: mock} + results := det.queryP2InstalledRoots(context.Background(), `C:\eclipse`) + + if len(results) != 1 { + t.Fatalf("expected 1 (debug lines filtered), got %d", len(results)) + } + if results[0].ID != "org.eclipse.platform.feature.group" { + t.Errorf("expected org.eclipse.platform.feature.group, got %s", results[0].ID) + } +} + +func TestQueryP2InstalledRoots_NoEclipsec(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + // No eclipsec.exe or eclipse.exe + + det := &ExtensionDetector{exec: mock} + results := det.queryP2InstalledRoots(context.Background(), `C:\eclipse`) + + if len(results) != 0 { + t.Errorf("expected 0 when no exe found, got %d", len(results)) + } +} + +func TestCollectDropins_DirectJAR(t *testing.T) { + mock := executor.NewMock() + dropinsDir := "/eclipse/dropins" + mock.SetDir(dropinsDir) + mock.SetDirEntries(dropinsDir, []os.DirEntry{ + executor.MockDirEntry("com.example.plugin_1.0.0.jar", false), + }) + + det := &ExtensionDetector{exec: mock} + results := det.collectDropins(dropinsDir) + + if len(results) != 1 { + t.Fatalf("expected 1 dropin, got %d", len(results)) + } + if results[0].ID != "com.example.plugin" { + t.Errorf("expected com.example.plugin, got %s", results[0].ID) + } + if results[0].Version != "1.0.0" { + t.Errorf("expected 1.0.0, got %s", results[0].Version) + } + if results[0].Source != "dropins" { + t.Errorf("expected dropins source, got %s", results[0].Source) + } +} + +func TestCollectDropins_Empty(t *testing.T) { + mock := executor.NewMock() + det := &ExtensionDetector{exec: mock} + results := det.collectDropins("/nonexistent") + if len(results) != 0 { + t.Errorf("expected 0, got %d", len(results)) + } +} + +func TestParseEclipsePluginName(t *testing.T) { + tests := []struct { + input string + id string + version string + }{ + {"com.github.spotbugs.plugin.eclipse_4.9.8.r202510181643-c1fa7f2", "com.github.spotbugs.plugin.eclipse", "4.9.8.r202510181643-c1fa7f2"}, + {"org.eclipse.jdt.core_3.36.0.v20240103-1234", "org.eclipse.jdt.core", "3.36.0.v20240103-1234"}, + {"simple.plugin_1.0", "simple.plugin", "1.0"}, + } + + for _, tt := range tests { + ext := parseEclipsePluginName(tt.input) + if ext == nil { + t.Errorf("parseEclipsePluginName(%q) returned nil", tt.input) + continue + } + if ext.ID != tt.id { + t.Errorf("ID: expected %s, got %s", tt.id, ext.ID) + } + if ext.Version != tt.version { + t.Errorf("Version: expected %s, got %s", tt.version, ext.Version) + } + } +} + +func TestParseEclipsePluginName_Invalid(t *testing.T) { + invalid := []string{ + "nounderscore", + "no_version_starts_with_letter", + "", + } + for _, input := range invalid { + if ext := parseEclipsePluginName(input); ext != nil { + t.Errorf("parseEclipsePluginName(%q) should return nil, got %+v", input, ext) + } + } +} + +func TestIsEclipseBundled(t *testing.T) { + bundled := []string{"org.eclipse.jdt.core", "javax.annotation", "ch.qos.logback.classic", "bcprov"} + for _, id := range bundled { + if !isEclipseBundled(id) { + t.Errorf("%q should be bundled", id) + } + } + + notBundled := []string{"com.anthropic.claudecode", "civerooni.com.putman", "my.custom.plugin"} + for _, id := range notBundled { + if isEclipseBundled(id) { + t.Errorf("%q should NOT be bundled", id) + } + } +} + +// Ensure model.FilterUserInstalledExtensions works correctly +func TestFilterUserInstalledExtensions(t *testing.T) { + exts := []model.Extension{ + {ID: "bundled1", Source: "bundled"}, + {ID: "marketplace1", Source: "marketplace"}, + {ID: "user1", Source: "user_installed"}, + {ID: "dropin1", Source: "dropins"}, + {ID: "nosource"}, + } + + filtered := model.FilterUserInstalledExtensions(exts) + if len(filtered) != 4 { + t.Fatalf("expected 4 (all except bundled), got %d", len(filtered)) + } + for _, e := range filtered { + if e.Source == "bundled" { + t.Errorf("bundled extension should have been filtered: %s", e.ID) + } + } +} + diff --git a/internal/detector/extension.go b/internal/detector/extension.go index a6a28bb..27046d7 100644 --- a/internal/detector/extension.go +++ b/internal/detector/extension.go @@ -50,7 +50,7 @@ func (d *ExtensionDetector) Detect(ctx context.Context, searchDirs []string, ide results = append(results, d.DetectXcodeExtensions(ctx)...) // Eclipse plugins — use detected IDE install paths for accurate discovery - results = append(results, d.DetectEclipsePlugins(ides)...) + results = append(results, d.DetectEclipsePlugins(ctx, ides)...) return results } diff --git a/internal/model/model.go b/internal/model/model.go index 4f9a18e..5fa523e 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -155,3 +155,15 @@ type PythonScanResult struct { ExitCode int `json:"exit_code"` ScanDurationMs int64 `json:"scan_duration_ms"` } + +// FilterUserInstalledExtensions removes bundled/platform extensions, +// keeping only user-installed, marketplace, and dropins extensions. +func FilterUserInstalledExtensions(exts []Extension) []Extension { + var filtered []Extension + for _, ext := range exts { + if ext.Source != "bundled" { + filtered = append(filtered, ext) + } + } + return filtered +} diff --git a/internal/scan/scanner.go b/internal/scan/scanner.go index 7e3d66c..ea4bf09 100644 --- a/internal/scan/scanner.go +++ b/internal/scan/scanner.go @@ -69,7 +69,7 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { // bundles) unless explicitly requested. macOS detection doesn't produce bundled // plugins in significant volume, so this filter is Windows-only. if exec.GOOS() == "windows" && !cfg.IncludeBundledPlugins { - extensions = filterUserInstalledExtensions(extensions) + extensions = model.FilterUserInstalledExtensions(extensions) } log.StepDone(time.Since(start)) @@ -262,14 +262,3 @@ func mcpConfigsToCommunity(configs []model.MCPConfig) []model.MCPConfig { return configs } -// filterUserInstalledExtensions removes bundled/platform extensions, -// keeping only user-installed and marketplace extensions. -func filterUserInstalledExtensions(exts []model.Extension) []model.Extension { - var filtered []model.Extension - for _, ext := range exts { - if ext.Source != "bundled" { - filtered = append(filtered, ext) - } - } - return filtered -} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index fec149e..29b5c47 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -156,7 +156,7 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { // On Windows, filter out bundled/platform plugins (e.g., Eclipse's 500+ OSGi // bundles) unless explicitly requested. macOS is unaffected. if exec.GOOS() == "windows" && !cfg.IncludeBundledPlugins { - extensions = filterUserInstalledExtensions(extensions) + extensions = model.FilterUserInstalledExtensions(extensions) } log.Progress("Found total of %d IDE extensions", len(extensions)) fmt.Fprintln(os.Stderr) @@ -617,14 +617,3 @@ func ideDisplayName(ideType string) string { } } -// filterUserInstalledExtensions removes bundled/platform extensions, -// keeping only user-installed and marketplace extensions. -func filterUserInstalledExtensions(exts []model.Extension) []model.Extension { - var filtered []model.Extension - for _, ext := range exts { - if ext.Source != "bundled" { - filtered = append(filtered, ext) - } - } - return filtered -}