Skip to content

Commit 76b4e51

Browse files
feat(windows): ide plugin detection
Signed-off-by: Swarit Pandey <swarit@stepsecurity.io>
1 parent d407c15 commit 76b4e51

6 files changed

Lines changed: 684 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ See [VERSIONING.md](VERSIONING.md) for why the version starts at 1.8.1.
1616
- **Glob-based Windows path matching**: `detectWindows` now supports wildcard patterns in `WinPaths` for IDEs that embed version numbers in folder names (e.g., `C:\Program Files\JetBrains\GoLand 2025.1.3\`). Picks the newest installation when multiple versions are present.
1717
- **`product-info.json` version extraction**: Reads the JetBrains `product-info.json` file for accurate marketing version numbers (avoids registry build numbers).
1818
- **`.eclipseproduct` version extraction**: Reads Eclipse's `.eclipseproduct` properties file for version detection on Windows (Eclipse does not register in the Windows registry).
19+
- **JetBrains plugin detection** (macOS + Windows): Detects user-installed plugins for all JetBrains IDEs by reading `product-info.json` to resolve the config directory, then scanning the plugins directory. Version extracted from JAR filenames.
1920

2021
## [1.9.2] - 2026-04-15
2122

SCAN_COVERAGE.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,16 @@ This document catalogs everything Dev Machine Guard detects. Contributions to ex
7373
| Open Interpreter | `~/.config/open-interpreter/config.yaml` | OpenSource|
7474
| Codex | `~/.codex/config.toml` | OpenAI |
7575

76-
## IDE Extensions
76+
## IDE Extensions & Plugins
7777

78-
| IDE | Extensions Directory | Format |
79-
|-------------|--------------------------------|-------------------------------|
80-
| VS Code | `~/.vscode/extensions` | `publisher.name-version` |
81-
| Cursor | `~/.cursor/extensions` | `publisher.name-version` |
78+
| IDE | Extensions/Plugins Directory | Format |
79+
|------------------|-------------------------------------------------------------------------------|-------------------------------|
80+
| VS Code | `~/.vscode/extensions` | `publisher.name-version` |
81+
| Cursor | `~/.cursor/extensions` | `publisher.name-version` |
82+
| JetBrains IDEs | macOS: `~/Library/Application Support/JetBrains/<dataDir>/plugins/` | `<name>/lib/<name>-version.jar` |
83+
| | Windows: `%APPDATA%\JetBrains\<dataDir>\plugins\` | |
84+
85+
JetBrains plugin detection reads `product-info.json` from the IDE install path to resolve the `dataDirectoryName` (e.g., `GoLand2025.1`), then scans user-installed plugins. Only user-installed plugins are reported (bundled plugins in the install directory are excluded).
8286

8387
## Node.js Package Scanning (Optional)
8488

internal/detector/jetbrains.go

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
package detector
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"path/filepath"
7+
"strings"
8+
9+
"github.com/step-security/dev-machine-guard/internal/executor"
10+
"github.com/step-security/dev-machine-guard/internal/model"
11+
)
12+
13+
// jetbrainsProductInfo holds the fields we read from product-info.json.
14+
type jetbrainsProductInfo struct {
15+
DataDirectoryName string `json:"dataDirectoryName"`
16+
ProductVendor string `json:"productVendor"`
17+
}
18+
19+
// JetBrainsPluginDetector collects user-installed plugins from JetBrains-family IDEs.
20+
//
21+
// It reads product-info.json from each detected IDE's install path to obtain
22+
// the dataDirectoryName and productVendor, then scans the platform-specific
23+
// config directory for user-installed plugins:
24+
//
25+
// - macOS: ~/Library/Application Support/<vendor>/<dataDirectoryName>/plugins/
26+
// - Windows: %APPDATA%/<vendor>/<dataDirectoryName>/plugins/
27+
//
28+
// Also checks for custom plugin path overrides in idea.properties.
29+
// Plugin version is extracted from JAR filenames matching <pluginDirName>-<version>.jar.
30+
type JetBrainsPluginDetector struct {
31+
exec executor.Executor
32+
}
33+
34+
func NewJetBrainsPluginDetector(exec executor.Executor) *JetBrainsPluginDetector {
35+
return &JetBrainsPluginDetector{exec: exec}
36+
}
37+
38+
// Detect scans for user-installed plugins across all detected JetBrains-family IDEs.
39+
func (d *JetBrainsPluginDetector) Detect(_ context.Context, ides []model.IDE) []model.Extension {
40+
var results []model.Extension
41+
42+
for _, ide := range ides {
43+
pluginsDir := d.resolvePluginsDir(ide)
44+
if pluginsDir == "" {
45+
continue
46+
}
47+
48+
plugins := d.collectPlugins(pluginsDir, ide.IDEType)
49+
results = append(results, plugins...)
50+
}
51+
52+
return results
53+
}
54+
55+
// resolvePluginsDir reads product-info.json from the IDE's install path,
56+
// then returns the platform-specific user plugins path.
57+
// Returns "" if the IDE doesn't have product-info.json (non-JetBrains IDEs).
58+
//
59+
// Resolution order:
60+
// 1. Check idea.plugins.path in user's idea.properties (custom override)
61+
// 2. Use default: <config-base>/<vendor>/<dataDirectoryName>/plugins/
62+
func (d *JetBrainsPluginDetector) resolvePluginsDir(ide model.IDE) string {
63+
info := d.readProductInfo(ide.InstallPath)
64+
if info.DataDirectoryName == "" {
65+
return ""
66+
}
67+
68+
vendor := info.ProductVendor
69+
if vendor == "" {
70+
vendor = "JetBrains" // safe default for older installations
71+
}
72+
73+
configDir := d.resolveConfigDir(vendor, info.DataDirectoryName)
74+
if configDir == "" {
75+
return ""
76+
}
77+
78+
// Check for custom plugin path override in idea.properties
79+
if customPath := d.readCustomPluginsPath(configDir); customPath != "" {
80+
return customPath
81+
}
82+
83+
return filepath.Join(configDir, "plugins")
84+
}
85+
86+
// resolveConfigDir returns the platform-specific JetBrains config directory.
87+
// macOS: ~/Library/Application Support/<vendor>/<dataDirectoryName>/
88+
// Windows: %APPDATA%/<vendor>/<dataDirectoryName>/ (also checks %LOCALAPPDATA%)
89+
func (d *JetBrainsPluginDetector) resolveConfigDir(vendor, dataDirName string) string {
90+
if d.exec.GOOS() == "windows" {
91+
// Most JetBrains IDEs use APPDATA; Android Studio uses LOCALAPPDATA
92+
for _, envVar := range []string{"APPDATA", "LOCALAPPDATA"} {
93+
base := d.exec.Getenv(envVar)
94+
if base == "" {
95+
continue
96+
}
97+
dir := filepath.Join(base, vendor, dataDirName)
98+
if d.exec.DirExists(dir) {
99+
return dir
100+
}
101+
}
102+
// Fall back to APPDATA even if dir doesn't exist yet
103+
appData := d.exec.Getenv("APPDATA")
104+
if appData != "" {
105+
return filepath.Join(appData, vendor, dataDirName)
106+
}
107+
return ""
108+
}
109+
110+
homeDir := getHomeDir(d.exec)
111+
return filepath.Join(homeDir, "Library", "Application Support", vendor, dataDirName)
112+
}
113+
114+
// readProductInfo reads dataDirectoryName and productVendor from product-info.json.
115+
// On macOS: <installPath>/Contents/Resources/product-info.json
116+
// On Windows: <installPath>/product-info.json
117+
func (d *JetBrainsPluginDetector) readProductInfo(installPath string) jetbrainsProductInfo {
118+
var productInfoPath string
119+
if d.exec.GOOS() == "windows" {
120+
productInfoPath = filepath.Join(installPath, "product-info.json")
121+
} else {
122+
productInfoPath = filepath.Join(installPath, "Contents", "Resources", "product-info.json")
123+
}
124+
125+
data, err := d.exec.ReadFile(productInfoPath)
126+
if err != nil {
127+
return jetbrainsProductInfo{}
128+
}
129+
130+
var info jetbrainsProductInfo
131+
if err := json.Unmarshal(data, &info); err != nil {
132+
return jetbrainsProductInfo{}
133+
}
134+
return info
135+
}
136+
137+
// readCustomPluginsPath checks for idea.plugins.path override in idea.properties.
138+
// Returns the custom path if set, or "" if not found/overridden.
139+
func (d *JetBrainsPluginDetector) readCustomPluginsPath(configDir string) string {
140+
propsPath := filepath.Join(configDir, "idea.properties")
141+
data, err := d.exec.ReadFile(propsPath)
142+
if err != nil {
143+
return ""
144+
}
145+
146+
for _, line := range strings.Split(string(data), "\n") {
147+
line = strings.TrimSpace(line)
148+
// Skip comments
149+
if strings.HasPrefix(line, "#") || strings.HasPrefix(line, "!") {
150+
continue
151+
}
152+
if strings.HasPrefix(line, "idea.plugins.path=") {
153+
path := strings.TrimPrefix(line, "idea.plugins.path=")
154+
path = strings.TrimSpace(path)
155+
if path != "" {
156+
return path
157+
}
158+
}
159+
}
160+
161+
return ""
162+
}
163+
164+
// collectPlugins lists plugin subdirectories and extracts name/version from each.
165+
func (d *JetBrainsPluginDetector) collectPlugins(pluginsDir, ideType string) []model.Extension {
166+
if !d.exec.DirExists(pluginsDir) {
167+
return nil
168+
}
169+
170+
entries, err := d.exec.ReadDir(pluginsDir)
171+
if err != nil {
172+
return nil
173+
}
174+
175+
var results []model.Extension
176+
for _, entry := range entries {
177+
if !entry.IsDir() {
178+
continue
179+
}
180+
181+
pluginName := entry.Name()
182+
libDir := filepath.Join(pluginsDir, pluginName, "lib")
183+
version := d.parsePluginVersion(libDir, pluginName)
184+
185+
ext := model.Extension{
186+
ID: pluginName,
187+
Name: pluginName,
188+
Version: version,
189+
IDEType: ideType,
190+
}
191+
192+
// Get install date from plugin directory mtime
193+
info, err := d.exec.Stat(filepath.Join(pluginsDir, pluginName))
194+
if err == nil {
195+
ext.InstallDate = info.ModTime().Unix()
196+
}
197+
198+
results = append(results, ext)
199+
}
200+
201+
return results
202+
}
203+
204+
// parsePluginVersion finds the main plugin JAR matching <pluginDirName>-<version>.jar
205+
// in the lib/ directory and extracts the version string.
206+
func (d *JetBrainsPluginDetector) parsePluginVersion(libDir, pluginDirName string) string {
207+
entries, err := d.exec.ReadDir(libDir)
208+
if err != nil {
209+
return "unknown"
210+
}
211+
212+
prefix := strings.ToLower(pluginDirName + "-")
213+
for _, entry := range entries {
214+
name := entry.Name()
215+
if entry.IsDir() || !strings.HasSuffix(name, ".jar") {
216+
continue
217+
}
218+
219+
lower := strings.ToLower(name)
220+
if !strings.HasPrefix(lower, prefix) {
221+
continue
222+
}
223+
224+
// Skip auxiliary JARs (e.g., IdeaVIM-2.27.2-searchableOptions.jar)
225+
remainder := name[len(pluginDirName)+1 : len(name)-4] // strip prefix + "-" and ".jar"
226+
if strings.Contains(remainder, "-") {
227+
continue
228+
}
229+
230+
if remainder != "" {
231+
return remainder
232+
}
233+
}
234+
235+
return "unknown"
236+
}

0 commit comments

Comments
 (0)