Skip to content

Commit e3e022c

Browse files
feat(mdm): add support for new ide & pluggins
1 parent 04fb226 commit e3e022c

13 files changed

Lines changed: 915 additions & 12 deletions

File tree

examples/sample-output.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,13 @@
7070
"install_path": "/Applications/Claude.app",
7171
"vendor": "Anthropic",
7272
"is_installed": true
73+
},
74+
{
75+
"ide_type": "goland",
76+
"version": "2024.3.1",
77+
"install_path": "/Applications/GoLand.app",
78+
"vendor": "JetBrains",
79+
"is_installed": true
7380
}
7481
],
7582
"ide_extensions": [
@@ -128,7 +135,7 @@
128135
"node_packages": [],
129136
"summary": {
130137
"ai_agents_and_tools_count": 5,
131-
"ide_installations_count": 3,
138+
"ide_installations_count": 4,
132139
"ide_extensions_count": 4,
133140
"mcp_configs_count": 2,
134141
"node_projects_count": 0
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package detector
2+
3+
import (
4+
"path/filepath"
5+
"strings"
6+
7+
"github.com/step-security/dev-machine-guard/internal/model"
8+
)
9+
10+
// eclipseFeatureDirs are Eclipse feature directories to scan.
11+
// Features represent installed plugins/extensions (both bundled and user-installed).
12+
var eclipseFeatureDirs = []string{
13+
"/Applications/Eclipse.app/Contents/Eclipse/features",
14+
"/Applications/Eclipse.app/Contents/Eclipse/dropins",
15+
}
16+
17+
// eclipseBundledPrefixes are feature ID prefixes that ship as part of the
18+
// base Eclipse platform. Features matching these are tagged as "bundled".
19+
var eclipseBundledPrefixes = []string{
20+
"org.eclipse.platform",
21+
"org.eclipse.rcp",
22+
"org.eclipse.e4.rcp",
23+
"org.eclipse.equinox.",
24+
"org.eclipse.help",
25+
"org.eclipse.justj.",
26+
"org.eclipse.oomph.",
27+
"org.eclipse.epp.package.",
28+
}
29+
30+
// DetectEclipsePlugins scans Eclipse feature directories and returns
31+
// all features tagged as "bundled" or "user_installed".
32+
func (d *ExtensionDetector) DetectEclipsePlugins() []model.Extension {
33+
var results []model.Extension
34+
for _, dir := range eclipseFeatureDirs {
35+
if !d.exec.DirExists(dir) {
36+
continue
37+
}
38+
results = append(results, d.collectEclipseFeatures(dir)...)
39+
}
40+
return results
41+
}
42+
43+
// collectEclipseFeatures reads Eclipse features from a directory.
44+
// Each feature is tagged as "bundled" or "user_installed".
45+
func (d *ExtensionDetector) collectEclipseFeatures(featuresDir string) []model.Extension {
46+
entries, err := d.exec.ReadDir(featuresDir)
47+
if err != nil {
48+
return nil
49+
}
50+
51+
var results []model.Extension
52+
for _, entry := range entries {
53+
name := entry.Name()
54+
baseName := strings.TrimSuffix(name, ".jar")
55+
56+
ext := parseEclipsePluginName(baseName)
57+
if ext == nil {
58+
continue
59+
}
60+
61+
// Tag as bundled or user_installed
62+
if isEclipseBundled(ext.ID) {
63+
ext.Source = "bundled"
64+
} else {
65+
ext.Source = "user_installed"
66+
}
67+
68+
path := filepath.Join(featuresDir, name)
69+
info, err := d.exec.Stat(path)
70+
if err == nil {
71+
ext.InstallDate = info.ModTime().Unix()
72+
}
73+
74+
results = append(results, *ext)
75+
}
76+
77+
return results
78+
}
79+
80+
func isEclipseBundled(pluginID string) bool {
81+
for _, prefix := range eclipseBundledPrefixes {
82+
if strings.HasPrefix(pluginID, prefix) {
83+
return true
84+
}
85+
}
86+
return false
87+
}
88+
89+
// parseEclipsePluginName parses "id_version" format.
90+
// Example: "com.github.spotbugs.plugin.eclipse_4.9.8.r202510181643-c1fa7f2"
91+
//
92+
// → id=com.github.spotbugs.plugin.eclipse, version=4.9.8.r202510181643-c1fa7f2
93+
func parseEclipsePluginName(name string) *model.Extension {
94+
lastUnderscore := -1
95+
for i := len(name) - 1; i >= 0; i-- {
96+
if name[i] == '_' {
97+
if i+1 < len(name) && name[i+1] >= '0' && name[i+1] <= '9' {
98+
lastUnderscore = i
99+
break
100+
}
101+
}
102+
}
103+
104+
if lastUnderscore < 1 {
105+
return nil
106+
}
107+
108+
pluginID := name[:lastUnderscore]
109+
version := name[lastUnderscore+1:]
110+
111+
if pluginID == "" || version == "" {
112+
return nil
113+
}
114+
115+
publisher := "unknown"
116+
parts := strings.SplitN(pluginID, ".", 3)
117+
if len(parts) >= 2 {
118+
publisher = parts[0] + "." + parts[1]
119+
}
120+
121+
return &model.Extension{
122+
ID: pluginID,
123+
Name: pluginID,
124+
Version: version,
125+
Publisher: publisher,
126+
IDEType: "eclipse",
127+
}
128+
}

internal/detector/extension.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ type ideExtensionSpec struct {
1818

1919
var extensionDirs = []ideExtensionSpec{
2020
{"VS Code", "vscode", "~/.vscode/extensions"},
21-
{"Cursor", "openvsx", "~/.cursor/extensions"},
21+
{"Cursor", "cursor", "~/.cursor/extensions"},
22+
{"Windsurf", "windsurf", "~/.windsurf/extensions"},
23+
{"Antigravity", "antigravity", "~/.antigravity/extensions"},
2224
}
2325

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

33-
func (d *ExtensionDetector) Detect(_ context.Context, searchDirs []string) []model.Extension {
35+
func (d *ExtensionDetector) Detect(ctx context.Context, searchDirs []string) []model.Extension {
3436
homeDir := getHomeDir(d.exec)
3537
var results []model.Extension
3638

39+
// VS Code-style extensions (publisher.name-version directory format)
3740
for _, spec := range extensionDirs {
3841
extDir := expandTilde(spec.ExtDir, homeDir)
3942
exts := d.collectFromDir(extDir, spec.IDEType)
4043
results = append(results, exts...)
4144
}
4245

46+
// JetBrains and Android Studio plugins (META-INF/plugin.xml format)
47+
results = append(results, d.DetectJetBrainsPlugins()...)
48+
49+
// Xcode Source Editor extensions (via macOS pluginkit)
50+
results = append(results, d.DetectXcodeExtensions(ctx)...)
51+
52+
// Eclipse plugins (id_version.jar format)
53+
results = append(results, d.DetectEclipsePlugins()...)
54+
4355
return results
4456
}
4557

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

93+
// VS Code-style extensions are always user-installed
94+
ext.Source = "user_installed"
95+
8196
// Get install date from directory modification time
8297
info, err := d.exec.Stat(filepath.Join(extDir, dirname))
8398
if err == nil {

internal/detector/ide.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,25 @@ var ideDefinitions = []ideSpec{
6161
AppPath: "/Applications/Copilot.app",
6262
WinPaths: []string{`%LOCALAPPDATA%\Programs\Copilot`},
6363
},
64+
65+
// JetBrains IDEs
66+
{AppName: "IntelliJ IDEA", IDEType: "intellij_idea", Vendor: "JetBrains", AppPath: "/Applications/IntelliJ IDEA.app"},
67+
{AppName: "IntelliJ IDEA CE", IDEType: "intellij_idea_ce", Vendor: "JetBrains", AppPath: "/Applications/IntelliJ IDEA CE.app"},
68+
{AppName: "PyCharm", IDEType: "pycharm", Vendor: "JetBrains", AppPath: "/Applications/PyCharm.app"},
69+
{AppName: "PyCharm CE", IDEType: "pycharm_ce", Vendor: "JetBrains", AppPath: "/Applications/PyCharm CE.app"},
70+
{AppName: "WebStorm", IDEType: "webstorm", Vendor: "JetBrains", AppPath: "/Applications/WebStorm.app"},
71+
{AppName: "GoLand", IDEType: "goland", Vendor: "JetBrains", AppPath: "/Applications/GoLand.app"},
72+
{AppName: "Rider", IDEType: "rider", Vendor: "JetBrains", AppPath: "/Applications/Rider.app"},
73+
{AppName: "PhpStorm", IDEType: "phpstorm", Vendor: "JetBrains", AppPath: "/Applications/PhpStorm.app"},
74+
{AppName: "RubyMine", IDEType: "rubymine", Vendor: "JetBrains", AppPath: "/Applications/RubyMine.app"},
75+
{AppName: "CLion", IDEType: "clion", Vendor: "JetBrains", AppPath: "/Applications/CLion.app"},
76+
{AppName: "DataGrip", IDEType: "datagrip", Vendor: "JetBrains", AppPath: "/Applications/DataGrip.app"},
77+
{AppName: "Fleet", IDEType: "fleet", Vendor: "JetBrains", AppPath: "/Applications/Fleet.app"},
78+
{AppName: "Android Studio", IDEType: "android_studio", Vendor: "Google", AppPath: "/Applications/Android Studio.app"},
79+
80+
// Other IDEs
81+
{AppName: "Eclipse", IDEType: "eclipse", Vendor: "Eclipse Foundation", AppPath: "/Applications/Eclipse.app"},
82+
{AppName: "Xcode", IDEType: "xcode", Vendor: "Apple", AppPath: "/Applications/Xcode.app"},
6483
}
6584

6685
// IDEDetector detects installed IDEs and AI desktop apps.

internal/detector/ide_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,3 +155,40 @@ func TestIDEDetector_Windows_FindsClaude(t *testing.T) {
155155
t.Error("expected is_installed=true")
156156
}
157157
}
158+
159+
func TestIDEDetector_JetBrains(t *testing.T) {
160+
mock := executor.NewMock()
161+
mock.SetDir("/Applications/GoLand.app")
162+
mock.SetFile("/Applications/GoLand.app/Contents/Info.plist", []byte{})
163+
mock.SetCommand("2024.3.1", "", 0, "/usr/libexec/PlistBuddy", "-c", "Print :CFBundleShortVersionString", "/Applications/GoLand.app/Contents/Info.plist")
164+
165+
mock.SetDir("/Applications/IntelliJ IDEA.app")
166+
mock.SetFile("/Applications/IntelliJ IDEA.app/Contents/Info.plist", []byte{})
167+
mock.SetCommand("2024.3.2", "", 0, "/usr/libexec/PlistBuddy", "-c", "Print :CFBundleShortVersionString", "/Applications/IntelliJ IDEA.app/Contents/Info.plist")
168+
169+
det := NewIDEDetector(mock)
170+
results := det.Detect(context.Background())
171+
172+
found := map[string]string{}
173+
for _, r := range results {
174+
found[r.IDEType] = r.Version
175+
}
176+
177+
if v, ok := found["goland"]; !ok {
178+
t.Error("expected GoLand to be detected")
179+
} else if v != "2024.3.1" {
180+
t.Errorf("expected GoLand version 2024.3.1, got %s", v)
181+
}
182+
183+
if v, ok := found["intellij_idea"]; !ok {
184+
t.Error("expected IntelliJ IDEA to be detected")
185+
} else if v != "2024.3.2" {
186+
t.Errorf("expected IntelliJ IDEA version 2024.3.2, got %s", v)
187+
}
188+
189+
for _, r := range results {
190+
if r.IDEType == "goland" && r.Vendor != "JetBrains" {
191+
t.Errorf("expected JetBrains vendor for GoLand, got %s", r.Vendor)
192+
}
193+
}
194+
}

0 commit comments

Comments
 (0)