diff --git a/examples/sample-output.json b/examples/sample-output.json index 75ebd37..af3b5e7 100644 --- a/examples/sample-output.json +++ b/examples/sample-output.json @@ -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": [ @@ -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 diff --git a/internal/detector/eclipse_plugins.go b/internal/detector/eclipse_plugins.go new file mode 100644 index 0000000..3046787 --- /dev/null +++ b/internal/detector/eclipse_plugins.go @@ -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 +} + +// 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", + } +} diff --git a/internal/detector/extension.go b/internal/detector/extension.go index cd00c11..b46bfd0 100644 --- a/internal/detector/extension.go +++ b/internal/detector/extension.go @@ -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. @@ -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 + // 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 } @@ -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 { diff --git a/internal/detector/ide.go b/internal/detector/ide.go index 231a41c..553ef1d 100644 --- a/internal/detector/ide.go +++ b/internal/detector/ide.go @@ -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. diff --git a/internal/detector/ide_test.go b/internal/detector/ide_test.go index b8de1bd..8608958 100644 --- a/internal/detector/ide_test.go +++ b/internal/detector/ide_test.go @@ -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) + } + } +} diff --git a/internal/detector/jetbrains_plugins.go b/internal/detector/jetbrains_plugins.go new file mode 100644 index 0000000..33d1dcc --- /dev/null +++ b/internal/detector/jetbrains_plugins.go @@ -0,0 +1,256 @@ +package detector + +import ( + "archive/zip" + "encoding/xml" + "io" + "path/filepath" + "strings" + + "github.com/step-security/dev-machine-guard/internal/model" +) + +// jetbrainsProductDir maps a JetBrains product directory prefix to its ide_type. +var jetbrainsProductDir = map[string]string{ + "IntelliJIdea": "intellij_idea", + "IdeaIC": "intellij_idea_ce", + "PyCharm": "pycharm", + "PyCharmCE": "pycharm_ce", + "WebStorm": "webstorm", + "GoLand": "goland", + "Rider": "rider", + "PhpStorm": "phpstorm", + "RubyMine": "rubymine", + "CLion": "clion", + "DataGrip": "datagrip", + "Fleet": "fleet", +} + +// jetbrainsAppBundlePaths maps ide_type to the bundled plugins path inside the .app. +var jetbrainsAppBundlePaths = map[string]string{ + "intellij_idea": "/Applications/IntelliJ IDEA.app/Contents/plugins", + "intellij_idea_ce": "/Applications/IntelliJ IDEA CE.app/Contents/plugins", + "pycharm": "/Applications/PyCharm.app/Contents/plugins", + "pycharm_ce": "/Applications/PyCharm CE.app/Contents/plugins", + "webstorm": "/Applications/WebStorm.app/Contents/plugins", + "goland": "/Applications/GoLand.app/Contents/plugins", + "rider": "/Applications/Rider.app/Contents/plugins", + "phpstorm": "/Applications/PhpStorm.app/Contents/plugins", + "rubymine": "/Applications/RubyMine.app/Contents/plugins", + "clion": "/Applications/CLion.app/Contents/plugins", + "datagrip": "/Applications/DataGrip.app/Contents/plugins", + "fleet": "/Applications/Fleet.app/Contents/plugins", + "android_studio": "/Applications/Android Studio.app/Contents/plugins", +} + +// DetectJetBrainsPlugins scans JetBrains and Android Studio plugin directories +// and returns detected plugins as model.Extension entries. +// Bundled plugins (from app bundle) are tagged "bundled". +// User-installed plugins (from ~/Library/Application Support/) are tagged "user_installed". +func (d *ExtensionDetector) DetectJetBrainsPlugins() []model.Extension { + homeDir := getHomeDir(d.exec) + var results []model.Extension + + // Bundled plugins: /Applications/.app/Contents/plugins/ + for ideType, bundlePath := range jetbrainsAppBundlePaths { + if d.exec.DirExists(bundlePath) { + plugins := d.collectJetBrainsPlugins(bundlePath, ideType) + for i := range plugins { + plugins[i].Source = "bundled" + } + results = append(results, plugins...) + } + } + + // User-installed: ~/Library/Application Support/JetBrains/*/plugins/ + jbBase := filepath.Join(homeDir, "Library", "Application Support", "JetBrains") + results = append(results, d.scanJetBrainsUserPlugins(jbBase, jetbrainsProductDir)...) + + // User-installed: ~/Library/Application Support/Google/AndroidStudio*/plugins/ + googleBase := filepath.Join(homeDir, "Library", "Application Support", "Google") + results = append(results, d.scanJetBrainsUserPlugins(googleBase, map[string]string{ + "AndroidStudio": "android_studio", + })...) + + return results +} + +// scanJetBrainsUserPlugins scans user-installed plugins from versioned product directories. +func (d *ExtensionDetector) scanJetBrainsUserPlugins(baseDir string, productMap map[string]string) []model.Extension { + if !d.exec.DirExists(baseDir) { + return nil + } + + entries, err := d.exec.ReadDir(baseDir) + if err != nil { + return nil + } + + var results []model.Extension + for _, entry := range entries { + if !entry.IsDir() { + continue + } + dirName := entry.Name() + + ideType := matchProductDir(dirName, productMap) + if ideType == "" { + continue + } + + pluginsDir := filepath.Join(baseDir, dirName, "plugins") + if !d.exec.DirExists(pluginsDir) { + continue + } + + plugins := d.collectJetBrainsPlugins(pluginsDir, ideType) + for i := range plugins { + plugins[i].Source = "user_installed" + } + results = append(results, plugins...) + } + + return results +} + +// matchProductDir checks if a directory name starts with any known product prefix. +// Longer prefixes are checked first to avoid "PyCharm" matching before "PyCharmCE". +func matchProductDir(dirName string, productMap map[string]string) string { + bestMatch := "" + bestLen := 0 + for prefix, ideType := range productMap { + if strings.HasPrefix(dirName, prefix) && len(prefix) > bestLen { + bestMatch = ideType + bestLen = len(prefix) + } + } + return bestMatch +} + +// collectJetBrainsPlugins reads plugins from a JetBrains plugins directory. +// Each subdirectory is a plugin; metadata is in META-INF/plugin.xml. +func (d *ExtensionDetector) collectJetBrainsPlugins(pluginsDir, ideType string) []model.Extension { + entries, err := d.exec.ReadDir(pluginsDir) + if err != nil { + return nil + } + + var results []model.Extension + for _, entry := range entries { + if !entry.IsDir() { + continue + } + pluginDir := filepath.Join(pluginsDir, entry.Name()) + ext := d.parseJetBrainsPlugin(pluginDir, ideType) + if ext != nil { + info, err := d.exec.Stat(pluginDir) + if err == nil { + ext.InstallDate = info.ModTime().Unix() + } + results = append(results, *ext) + } + } + + return results +} + +// pluginXML represents the relevant fields from META-INF/plugin.xml. +type pluginXML struct { + XMLName xml.Name `xml:"idea-plugin"` + ID string `xml:"id"` + Name string `xml:"name"` + Version string `xml:"version"` + Vendor string `xml:"vendor"` +} + +// parseJetBrainsPlugin reads META-INF/plugin.xml from a plugin directory. +// It first checks for a top-level META-INF/plugin.xml, then falls back +// to extracting it from jar files in the lib/ directory. +func (d *ExtensionDetector) parseJetBrainsPlugin(pluginDir, ideType string) *model.Extension { + xmlPath := filepath.Join(pluginDir, "META-INF", "plugin.xml") + data, err := d.exec.ReadFile(xmlPath) + if err != nil { + data = d.readPluginXMLFromJars(pluginDir) + if data == nil { + return nil + } + } + + var plugin pluginXML + if err := xml.Unmarshal(data, &plugin); err != nil { + return nil + } + + dirName := filepath.Base(pluginDir) + id := plugin.ID + if id == "" { + id = dirName + } + name := plugin.Name + if name == "" { + name = dirName + } + version := plugin.Version + if version == "" { + version = "unknown" + } + publisher := plugin.Vendor + if publisher == "" { + publisher = "unknown" + } + + return &model.Extension{ + ID: id, + Name: name, + Version: version, + Publisher: publisher, + IDEType: ideType, + } +} + +// readPluginXMLFromJars looks for META-INF/plugin.xml inside jar files +// in the plugin's lib/ directory. +func (d *ExtensionDetector) readPluginXMLFromJars(pluginDir string) []byte { + libDir := filepath.Join(pluginDir, "lib") + entries, err := d.exec.ReadDir(libDir) + if err != nil { + return nil + } + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jar") { + continue + } + jarPath := filepath.Join(libDir, entry.Name()) + data := readFileFromZip(jarPath, "META-INF/plugin.xml") + if data != nil { + return data + } + } + return nil +} + +// readFileFromZip extracts a single file from a zip/jar archive. +func readFileFromZip(zipPath, targetFile string) []byte { + r, err := zip.OpenReader(zipPath) + if err != nil { + return nil + } + defer r.Close() + + for _, f := range r.File { + if f.Name == targetFile { + rc, err := f.Open() + if err != nil { + return nil + } + defer rc.Close() + data, err := io.ReadAll(rc) + if err != nil { + return nil + } + return data + } + } + return nil +} diff --git a/internal/detector/jetbrains_plugins_test.go b/internal/detector/jetbrains_plugins_test.go new file mode 100644 index 0000000..dcaf07f --- /dev/null +++ b/internal/detector/jetbrains_plugins_test.go @@ -0,0 +1,193 @@ +package detector + +import ( + "context" + "os" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +func mockDir(name string) os.DirEntry { return executor.MockDirEntry(name, true) } + +func TestJetBrainsPluginDetection(t *testing.T) { + mock := executor.NewMock() + + jbBase := "/Users/testuser/Library/Application Support/JetBrains" + golandPlugins := jbBase + "/GoLand2024.3/plugins" + + mock.SetDirEntries(jbBase, []os.DirEntry{mockDir("GoLand2024.3")}) + mock.SetDirEntries(golandPlugins, []os.DirEntry{ + mockDir("org.jetbrains.kotlin"), + mockDir("IdeaVIM"), + }) + + mock.SetFile(golandPlugins+"/org.jetbrains.kotlin/META-INF/plugin.xml", []byte(` + + org.jetbrains.kotlin + Kotlin + 2.1.0 + JetBrains +`)) + + mock.SetFile(golandPlugins+"/IdeaVIM/META-INF/plugin.xml", []byte(` + + IdeaVim + 2.10.0 + JetBrains +`)) + + det := NewExtensionDetector(mock) + plugins := det.DetectJetBrainsPlugins() + + if len(plugins) != 2 { + t.Fatalf("expected 2 plugins, got %d", len(plugins)) + } + + found := map[string]bool{} + for _, p := range plugins { + found[p.Name] = true + if p.IDEType != "goland" { + t.Errorf("expected ide_type goland, got %s for plugin %s", p.IDEType, p.Name) + } + } + if !found["Kotlin"] { + t.Error("expected Kotlin plugin to be detected") + } + if !found["IdeaVim"] { + t.Error("expected IdeaVim plugin to be detected") + } +} + +func TestJetBrainsPluginXMLParsing(t *testing.T) { + mock := executor.NewMock() + + pluginDir := "/test/plugins/my-plugin" + mock.SetFile(pluginDir+"/META-INF/plugin.xml", []byte(` + + com.example.myplugin + My Plugin + 3.5.1 + Example Corp +`)) + + det := NewExtensionDetector(mock) + ext := det.parseJetBrainsPlugin(pluginDir, "intellij_idea") + + if ext == nil { + t.Fatal("expected plugin to be parsed") + } + if ext.ID != "com.example.myplugin" { + t.Errorf("expected ID com.example.myplugin, got %s", ext.ID) + } + if ext.Name != "My Plugin" { + t.Errorf("expected name My Plugin, got %s", ext.Name) + } + if ext.Version != "3.5.1" { + t.Errorf("expected version 3.5.1, got %s", ext.Version) + } + if ext.Publisher != "Example Corp" { + t.Errorf("expected publisher Example Corp, got %s", ext.Publisher) + } +} + +func TestJetBrainsPluginNoXML(t *testing.T) { + mock := executor.NewMock() + det := NewExtensionDetector(mock) + ext := det.parseJetBrainsPlugin("/test/plugins/jar-only-plugin", "goland") + if ext != nil { + t.Error("expected nil for plugin without plugin.xml") + } +} + +func TestAndroidStudioPluginDetection(t *testing.T) { + mock := executor.NewMock() + + googleBase := "/Users/testuser/Library/Application Support/Google" + asPlugins := googleBase + "/AndroidStudio2024.2/plugins" + + mock.SetDirEntries(googleBase, []os.DirEntry{mockDir("AndroidStudio2024.2")}) + mock.SetDirEntries(asPlugins, []os.DirEntry{mockDir("flutter-plugin")}) + mock.SetFile(asPlugins+"/flutter-plugin/META-INF/plugin.xml", []byte(` + + io.flutter + Flutter + 80.0.1 + flutter.dev +`)) + + det := NewExtensionDetector(mock) + plugins := det.DetectJetBrainsPlugins() + + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + if plugins[0].IDEType != "android_studio" { + t.Errorf("expected ide_type android_studio, got %s", plugins[0].IDEType) + } + if plugins[0].Name != "Flutter" { + t.Errorf("expected Flutter plugin, got %s", plugins[0].Name) + } +} + +func TestMatchProductDir(t *testing.T) { + tests := []struct { + dirName string + expected string + }{ + {"GoLand2024.3", "goland"}, + {"IntelliJIdea2024.3", "intellij_idea"}, + {"IdeaIC2024.3", "intellij_idea_ce"}, + {"PyCharm2024.3", "pycharm"}, + {"PyCharmCE2024.3", "pycharm_ce"}, + {"WebStorm2024.3", "webstorm"}, + {"UnknownProduct2024.3", ""}, + } + + for _, tt := range tests { + t.Run(tt.dirName, func(t *testing.T) { + got := matchProductDir(tt.dirName, jetbrainsProductDir) + if got != tt.expected { + t.Errorf("matchProductDir(%q) = %q, want %q", tt.dirName, got, tt.expected) + } + }) + } +} + +func TestExtensionDetector_IncludesJetBrains(t *testing.T) { + mock := executor.NewMock() + + // VS Code extension + mock.SetDirEntries("/Users/testuser/.vscode/extensions", []os.DirEntry{ + mockDir("ms-python.python-2024.22.0"), + }) + + // JetBrains plugin + jbBase := "/Users/testuser/Library/Application Support/JetBrains" + mock.SetDirEntries(jbBase, []os.DirEntry{mockDir("GoLand2024.3")}) + pluginsDir := jbBase + "/GoLand2024.3/plugins" + mock.SetDirEntries(pluginsDir, []os.DirEntry{mockDir("IdeaVIM")}) + mock.SetFile(pluginsDir+"/IdeaVIM/META-INF/plugin.xml", []byte( + `IdeaVim2.10.0JetBrains`)) + + det := NewExtensionDetector(mock) + results := det.Detect(context.Background(), nil) + + vscodeCount := 0 + jetbrainsCount := 0 + for _, r := range results { + switch r.IDEType { + case "vscode": + vscodeCount++ + case "goland": + jetbrainsCount++ + } + } + + if vscodeCount != 1 { + t.Errorf("expected 1 vscode extension, got %d", vscodeCount) + } + if jetbrainsCount != 1 { + t.Errorf("expected 1 goland plugin, got %d", jetbrainsCount) + } +} diff --git a/internal/detector/xcode_extensions.go b/internal/detector/xcode_extensions.go new file mode 100644 index 0000000..84e594c --- /dev/null +++ b/internal/detector/xcode_extensions.go @@ -0,0 +1,94 @@ +package detector + +import ( + "context" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/model" +) + +// DetectXcodeExtensions uses macOS pluginkit to find installed +// Xcode Source Editor extensions. +func (d *ExtensionDetector) DetectXcodeExtensions(ctx context.Context) []model.Extension { + stdout, _, _, err := d.exec.RunWithTimeout(ctx, 10*time.Second, + "pluginkit", "-mAD", "-p", "com.apple.dt.Xcode.extension.source-editor") + if err != nil { + return nil + } + + stdout = strings.TrimSpace(stdout) + if stdout == "" { + return nil + } + + var results []model.Extension + for _, line := range strings.Split(stdout, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + ext := parsePluginkitLine(line) + if ext != nil { + results = append(results, *ext) + } + } + + return results +} + +// parsePluginkitLine parses a line like: +// "+ com.charcoaldesign.SwiftFormat-for-Xcode.SourceEditorExtension(0.60.1)" +func parsePluginkitLine(line string) *model.Extension { + // Strip leading +/- and whitespace + enabled := false + if strings.HasPrefix(line, "+") { + enabled = true + } + line = strings.TrimLeft(line, "+- \t") + + if line == "" { + return nil + } + + // Split "bundleID(version)" — find first "(" since bundle IDs never contain parens + openIdx := strings.Index(line, "(") + if openIdx < 1 || !strings.HasSuffix(line, ")") { + return nil + } + + bundleID := line[:openIdx] + version := line[openIdx+1 : len(line)-1] + if version == "(null)" || version == "" { + version = "unknown" + } + + // Derive publisher from first two segments of bundle ID + // e.g., "com.charcoaldesign.SwiftFormat-for-Xcode.SourceEditorExtension" → "com.charcoaldesign" + publisher := "unknown" + parts := strings.SplitN(bundleID, ".", 3) + if len(parts) >= 2 { + publisher = parts[0] + "." + parts[1] + } + + // Derive a readable name: strip the publisher prefix and common suffixes + name := bundleID + if len(parts) >= 3 { + name = parts[2] + } + name = strings.TrimSuffix(name, ".SourceEditorExtension") + name = strings.TrimSuffix(name, ".Extension") + + source := "user_installed" + _ = enabled // all Xcode extensions are user-installed + + return &model.Extension{ + ID: bundleID, + Name: name, + Version: version, + Publisher: publisher, + IDEType: "xcode", + Source: source, + } +} diff --git a/internal/detector/xcode_extensions_test.go b/internal/detector/xcode_extensions_test.go new file mode 100644 index 0000000..e663486 --- /dev/null +++ b/internal/detector/xcode_extensions_test.go @@ -0,0 +1,80 @@ +package detector + +import ( + "testing" +) + +func TestParsePluginkitLine(t *testing.T) { + tests := []struct { + name string + line string + wantID string + wantVer string + wantPub string + wantName string + wantNil bool + }{ + { + name: "enabled extension", + line: "+ com.charcoaldesign.SwiftFormat-for-Xcode.SourceEditorExtension(0.60.1)", + wantID: "com.charcoaldesign.SwiftFormat-for-Xcode.SourceEditorExtension", + wantVer: "0.60.1", + wantPub: "com.charcoaldesign", + wantName: "SwiftFormat-for-Xcode", + }, + { + name: "disabled extension", + line: "- com.example.MyTool.SourceEditorExtension(1.2.3)", + wantID: "com.example.MyTool.SourceEditorExtension", + wantVer: "1.2.3", + wantPub: "com.example", + wantName: "MyTool", + }, + { + name: "null version", + line: "+ com.example.SomePlugin.Extension((null))", + wantID: "com.example.SomePlugin.Extension", + wantVer: "unknown", + wantPub: "com.example", + wantName: "SomePlugin", + }, + { + name: "empty line", + line: "", + wantNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ext := parsePluginkitLine(tt.line) + if tt.wantNil { + if ext != nil { + t.Errorf("expected nil, got %+v", ext) + } + return + } + if ext == nil { + t.Fatal("expected non-nil extension") + } + if ext.ID != tt.wantID { + t.Errorf("ID: got %q, want %q", ext.ID, tt.wantID) + } + if ext.Version != tt.wantVer { + t.Errorf("Version: got %q, want %q", ext.Version, tt.wantVer) + } + if ext.Publisher != tt.wantPub { + t.Errorf("Publisher: got %q, want %q", ext.Publisher, tt.wantPub) + } + if ext.Name != tt.wantName { + t.Errorf("Name: got %q, want %q", ext.Name, tt.wantName) + } + if ext.IDEType != "xcode" { + t.Errorf("IDEType: got %q, want xcode", ext.IDEType) + } + if ext.Source != "user_installed" { + t.Errorf("Source: got %q, want user_installed", ext.Source) + } + }) + } +} diff --git a/internal/executor/mock.go b/internal/executor/mock.go index cfb79a7..121bb19 100644 --- a/internal/executor/mock.go +++ b/internal/executor/mock.go @@ -296,3 +296,18 @@ func (fi *mockFileInfo) IsDir() bool { return fi.dir } func (fi *mockFileInfo) ModTime() time.Time { return time.Time{} } func (fi *mockFileInfo) Mode() os.FileMode { return 0o644 } func (fi *mockFileInfo) Sys() any { return nil } + +// MockDirEntry creates an os.DirEntry for use with SetDirEntries. +func MockDirEntry(name string, isDir bool) os.DirEntry { + return &mockDirEntry{name: name, dir: isDir} +} + +type mockDirEntry struct { + name string + dir bool +} + +func (e *mockDirEntry) Name() string { return e.name } +func (e *mockDirEntry) IsDir() bool { return e.dir } +func (e *mockDirEntry) Type() os.FileMode { if e.dir { return os.ModeDir }; return 0 } +func (e *mockDirEntry) Info() (os.FileInfo, error) { return &mockFileInfo{name: e.name, dir: e.dir}, nil } diff --git a/internal/model/model.go b/internal/model/model.go index d394e86..7019499 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -52,6 +52,7 @@ type Extension struct { Publisher string `json:"publisher"` InstallDate int64 `json:"install_date"` IDEType string `json:"ide_type"` + Source string `json:"source,omitempty"` // "bundled" or "user_installed" } // MCPConfig represents a detected MCP server configuration (community mode). diff --git a/internal/output/pretty.go b/internal/output/pretty.go index 6c45f09..e245944 100644 --- a/internal/output/pretty.go +++ b/internal/output/pretty.go @@ -111,18 +111,16 @@ func Pretty(w io.Writer, result *model.ScanResult, colorMode string) error { groups[ext.IDEType] = append(groups[ext.IDEType], ext) } for ideType, exts := range groups { - displayType := ideType - switch ideType { - case "vscode": - displayType = "VSCode" - case "openvsx": - displayType = "Cursor" - } + displayType := ideDisplayName(ideType) fmt.Fprintf(w, " %s%s%s%s%*s%s%d found%s\n", c.purple, c.bold, displayType, c.reset, 33-len(displayType), "", c.green, len(exts), c.reset) for _, ext := range exts { - fmt.Fprintf(w, " %-42s %sv%-14s %s%s\n", - truncate(ext.ID, 42), c.dim, truncate(ext.Version, 14), ext.Publisher, c.reset) + sourceTag := "" + if ext.Source == "bundled" { + sourceTag = " [bundled]" + } + fmt.Fprintf(w, " %-42s %sv%-14s %s%s%s\n", + truncate(ext.ID, 42), c.dim, truncate(ext.Version, 14), ext.Publisher, sourceTag, c.reset) } } } else { @@ -212,6 +210,36 @@ func ideDisplayName(ideType string) string { return "Claude" case "microsoft_copilot_desktop": return "Microsoft Copilot" + case "intellij_idea": + return "IntelliJ IDEA" + case "intellij_idea_ce": + return "IntelliJ IDEA CE" + case "pycharm": + return "PyCharm" + case "pycharm_ce": + return "PyCharm CE" + case "webstorm": + return "WebStorm" + case "goland": + return "GoLand" + case "rider": + return "Rider" + case "phpstorm": + return "PhpStorm" + case "rubymine": + return "RubyMine" + case "clion": + return "CLion" + case "datagrip": + return "DataGrip" + case "fleet": + return "Fleet" + case "android_studio": + return "Android Studio" + case "eclipse": + return "Eclipse" + case "xcode": + return "Xcode" default: return ideType } diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 17cb0e2..f04fd12 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -409,6 +409,36 @@ func ideDisplayName(ideType string) string { return "Claude" case "microsoft_copilot_desktop": return "Microsoft Copilot" + case "intellij_idea": + return "IntelliJ IDEA" + case "intellij_idea_ce": + return "IntelliJ IDEA CE" + case "pycharm": + return "PyCharm" + case "pycharm_ce": + return "PyCharm CE" + case "webstorm": + return "WebStorm" + case "goland": + return "GoLand" + case "rider": + return "Rider" + case "phpstorm": + return "PhpStorm" + case "rubymine": + return "RubyMine" + case "clion": + return "CLion" + case "datagrip": + return "DataGrip" + case "fleet": + return "Fleet" + case "android_studio": + return "Android Studio" + case "eclipse": + return "Eclipse" + case "xcode": + return "Xcode" default: return ideType }