Skip to content
Closed
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
9 changes: 8 additions & 1 deletion examples/sample-output.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@
"install_path": "/Applications/Claude.app",
"vendor": "Anthropic",
"is_installed": true
},
{
"ide_type": "goland",
"version": "2024.3.1",
"install_path": "/Applications/GoLand.app",
"vendor": "JetBrains",
"is_installed": true
}
],
"ide_extensions": [
Expand Down Expand Up @@ -128,7 +135,7 @@
"node_packages": [],
"summary": {
"ai_agents_and_tools_count": 5,
"ide_installations_count": 3,
"ide_installations_count": 4,
"ide_extensions_count": 4,
"mcp_configs_count": 2,
"node_projects_count": 0
Comment thread
shubham-stepsecurity marked this conversation as resolved.
Expand Down
128 changes: 128 additions & 0 deletions internal/detector/eclipse_plugins.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package detector

import (
"path/filepath"
"strings"

"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{
"/Applications/Eclipse.app/Contents/Eclipse/features",
"/Applications/Eclipse.app/Contents/Eclipse/dropins",
}

// 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.",
}

// DetectEclipsePlugins scans Eclipse feature directories and returns
// 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
}
results = append(results, d.collectEclipseFeatures(dir)...)
}
return results
}
Comment thread
shubham-stepsecurity marked this conversation as resolved.

// collectEclipseFeatures reads Eclipse features from a directory.
// Each feature is tagged as "bundled" or "user_installed".
func (d *ExtensionDetector) collectEclipseFeatures(featuresDir string) []model.Extension {
entries, err := d.exec.ReadDir(featuresDir)
if err != nil {
return nil
}

var results []model.Extension
for _, entry := range entries {
name := entry.Name()
baseName := strings.TrimSuffix(name, ".jar")

ext := parseEclipsePluginName(baseName)
if ext == nil {
continue
}

// Tag as bundled or user_installed
if isEclipseBundled(ext.ID) {
ext.Source = "bundled"
} else {
ext.Source = "user_installed"
}

path := filepath.Join(featuresDir, name)
info, err := d.exec.Stat(path)
if err == nil {
ext.InstallDate = info.ModTime().Unix()
}

results = append(results, *ext)
}

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-- {
if name[i] == '_' {
if i+1 < len(name) && name[i+1] >= '0' && name[i+1] <= '9' {
lastUnderscore = i
break
}
}
}

if lastUnderscore < 1 {
return nil
}

pluginID := name[:lastUnderscore]
version := name[lastUnderscore+1:]

if pluginID == "" || version == "" {
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,
IDEType: "eclipse",
}
}
19 changes: 17 additions & 2 deletions internal/detector/extension.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ type ideExtensionSpec struct {

var extensionDirs = []ideExtensionSpec{
{"VS Code", "vscode", "~/.vscode/extensions"},
{"Cursor", "openvsx", "~/.cursor/extensions"},
{"Cursor", "cursor", "~/.cursor/extensions"},
{"Windsurf", "windsurf", "~/.windsurf/extensions"},
{"Antigravity", "antigravity", "~/.antigravity/extensions"},
}

// ExtensionDetector collects IDE extensions.
Expand All @@ -30,16 +32,26 @@ func NewExtensionDetector(exec executor.Executor) *ExtensionDetector {
return &ExtensionDetector{exec: exec}
}

func (d *ExtensionDetector) Detect(_ context.Context, searchDirs []string) []model.Extension {
func (d *ExtensionDetector) Detect(ctx context.Context, searchDirs []string) []model.Extension {
homeDir := getHomeDir(d.exec)
var results []model.Extension

Comment thread
shubham-stepsecurity marked this conversation as resolved.
// VS Code-style extensions (publisher.name-version directory format)
for _, spec := range extensionDirs {
extDir := expandTilde(spec.ExtDir, homeDir)
exts := d.collectFromDir(extDir, spec.IDEType)
results = append(results, exts...)
}

// JetBrains and Android Studio plugins (META-INF/plugin.xml format)
results = append(results, d.DetectJetBrainsPlugins()...)

// Xcode Source Editor extensions (via macOS pluginkit)
results = append(results, d.DetectXcodeExtensions(ctx)...)

// Eclipse plugins (id_version.jar format)
results = append(results, d.DetectEclipsePlugins()...)

return results
}

Expand Down Expand Up @@ -78,6 +90,9 @@ func (d *ExtensionDetector) collectFromDir(extDir, ideType string) []model.Exten
continue
}

// VS Code-style extensions are always user-installed
ext.Source = "user_installed"

// Get install date from directory modification time
info, err := d.exec.Stat(filepath.Join(extDir, dirname))
if err == nil {
Expand Down
19 changes: 19 additions & 0 deletions internal/detector/ide.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,25 @@ var ideDefinitions = []ideSpec{
AppPath: "/Applications/Copilot.app",
WinPaths: []string{`%LOCALAPPDATA%\Programs\Copilot`},
},

// JetBrains IDEs
{AppName: "IntelliJ IDEA", IDEType: "intellij_idea", Vendor: "JetBrains", AppPath: "/Applications/IntelliJ IDEA.app"},
{AppName: "IntelliJ IDEA CE", IDEType: "intellij_idea_ce", Vendor: "JetBrains", AppPath: "/Applications/IntelliJ IDEA CE.app"},
{AppName: "PyCharm", IDEType: "pycharm", Vendor: "JetBrains", AppPath: "/Applications/PyCharm.app"},
{AppName: "PyCharm CE", IDEType: "pycharm_ce", Vendor: "JetBrains", AppPath: "/Applications/PyCharm CE.app"},
{AppName: "WebStorm", IDEType: "webstorm", Vendor: "JetBrains", AppPath: "/Applications/WebStorm.app"},
{AppName: "GoLand", IDEType: "goland", Vendor: "JetBrains", AppPath: "/Applications/GoLand.app"},
{AppName: "Rider", IDEType: "rider", Vendor: "JetBrains", AppPath: "/Applications/Rider.app"},
{AppName: "PhpStorm", IDEType: "phpstorm", Vendor: "JetBrains", AppPath: "/Applications/PhpStorm.app"},
{AppName: "RubyMine", IDEType: "rubymine", Vendor: "JetBrains", AppPath: "/Applications/RubyMine.app"},
{AppName: "CLion", IDEType: "clion", Vendor: "JetBrains", AppPath: "/Applications/CLion.app"},
{AppName: "DataGrip", IDEType: "datagrip", Vendor: "JetBrains", AppPath: "/Applications/DataGrip.app"},
{AppName: "Fleet", IDEType: "fleet", Vendor: "JetBrains", AppPath: "/Applications/Fleet.app"},
{AppName: "Android Studio", IDEType: "android_studio", Vendor: "Google", AppPath: "/Applications/Android Studio.app"},

// Other IDEs
{AppName: "Eclipse", IDEType: "eclipse", Vendor: "Eclipse Foundation", AppPath: "/Applications/Eclipse.app"},
{AppName: "Xcode", IDEType: "xcode", Vendor: "Apple", AppPath: "/Applications/Xcode.app"},
}

// IDEDetector detects installed IDEs and AI desktop apps.
Expand Down
37 changes: 37 additions & 0 deletions internal/detector/ide_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,40 @@ func TestIDEDetector_Windows_FindsClaude(t *testing.T) {
t.Error("expected is_installed=true")
}
}

func TestIDEDetector_JetBrains(t *testing.T) {
mock := executor.NewMock()
mock.SetDir("/Applications/GoLand.app")
mock.SetFile("/Applications/GoLand.app/Contents/Info.plist", []byte{})
mock.SetCommand("2024.3.1", "", 0, "/usr/libexec/PlistBuddy", "-c", "Print :CFBundleShortVersionString", "/Applications/GoLand.app/Contents/Info.plist")

mock.SetDir("/Applications/IntelliJ IDEA.app")
mock.SetFile("/Applications/IntelliJ IDEA.app/Contents/Info.plist", []byte{})
mock.SetCommand("2024.3.2", "", 0, "/usr/libexec/PlistBuddy", "-c", "Print :CFBundleShortVersionString", "/Applications/IntelliJ IDEA.app/Contents/Info.plist")

det := NewIDEDetector(mock)
results := det.Detect(context.Background())

found := map[string]string{}
for _, r := range results {
found[r.IDEType] = r.Version
}

if v, ok := found["goland"]; !ok {
t.Error("expected GoLand to be detected")
} else if v != "2024.3.1" {
t.Errorf("expected GoLand version 2024.3.1, got %s", v)
}

if v, ok := found["intellij_idea"]; !ok {
t.Error("expected IntelliJ IDEA to be detected")
} else if v != "2024.3.2" {
t.Errorf("expected IntelliJ IDEA version 2024.3.2, got %s", v)
}

for _, r := range results {
if r.IDEType == "goland" && r.Vendor != "JetBrains" {
t.Errorf("expected JetBrains vendor for GoLand, got %s", r.Vendor)
}
}
}
Loading
Loading