diff --git a/cmd/stepsecurity-dev-machine-guard/main.go b/cmd/stepsecurity-dev-machine-guard/main.go index f3ba941..bc81b63 100644 --- a/cmd/stepsecurity-dev-machine-guard/main.go +++ b/cmd/stepsecurity-dev-machine-guard/main.go @@ -34,6 +34,12 @@ func main() { if cfg.EnableNPMScan == nil && config.EnableNPMScan != nil { cfg.EnableNPMScan = config.EnableNPMScan } + if cfg.EnableBrewScan == nil && config.EnableBrewScan != nil { + cfg.EnableBrewScan = config.EnableBrewScan + } + if cfg.EnablePythonScan == nil && config.EnablePythonScan != nil { + cfg.EnablePythonScan = config.EnablePythonScan + } if cfg.ColorMode == "auto" && config.ColorMode != "" { cfg.ColorMode = config.ColorMode } diff --git a/examples/sample-output.json b/examples/sample-output.json index 75ebd37..2f49dac 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": [ @@ -126,11 +133,56 @@ } ], "node_packages": [], + "node_projects": [ + { "path": "/Users/developer/projects/my-app", "package_manager": "npm" }, + { "path": "/Users/developer/projects/api-server", "package_manager": "yarn" }, + { "path": "/Users/developer/projects/frontend", "package_manager": "pnpm" } + ], + "brew_package_manager": { + "name": "homebrew", + "version": "4.3.5", + "path": "/opt/homebrew/bin/brew" + }, + "brew_formulae": [ + { "name": "ca-certificates", "version": "2024.2.2" }, + { "name": "curl", "version": "8.4.0" }, + { "name": "git", "version": "2.43.0" }, + { "name": "openssl@3", "version": "3.2.0" } + ], + "brew_casks": [ + { "name": "visual-studio-code", "version": "1.85.0" }, + { "name": "firefox", "version": "120.0" } + ], + "python_package_managers": [ + { + "name": "python3", + "version": "3.12.0", + "path": "/usr/local/bin/python3" + }, + { + "name": "pip", + "version": "24.0", + "path": "/usr/local/bin/pip3" + } + ], + "python_packages": [ + { "name": "requests", "version": "2.31.0" }, + { "name": "numpy", "version": "1.26.2" }, + { "name": "pip", "version": "24.0" } + ], + "python_projects": [ + { "path": "/Users/developer/projects/ml-pipeline", "package_manager": "poetry" }, + { "path": "/Users/developer/projects/data-analysis", "package_manager": "pip" }, + { "path": "/Users/developer/projects/web-scraper", "package_manager": "uv" } + ], "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 + "node_projects_count": 3, + "brew_formulae_count": 42, + "brew_casks_count": 15, + "python_projects_count": 3 } } \ No newline at end of file diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 89dc6f3..3c509e3 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -11,14 +11,16 @@ import ( // Config holds all parsed CLI flags. type Config struct { - Command string // "", "install", "uninstall", "send-telemetry", "configure", "configure show" - OutputFormat string // "pretty", "json", "html" - OutputFormatSet bool // true if --pretty/--json/--html was explicitly passed (not persisted) - HTMLOutputFile string // set by --html (not persisted) - ColorMode string // "auto", "always", "never" - Verbose bool // --verbose - EnableNPMScan *bool // nil=auto, true/false=explicit - SearchDirs []string // defaults to ["$HOME"] + Command string // "", "install", "uninstall", "send-telemetry", "configure", "configure show" + OutputFormat string // "pretty", "json", "html" + OutputFormatSet bool // true if --pretty/--json/--html was explicitly passed (not persisted) + 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"] } // Parse parses CLI arguments and returns a Config. @@ -69,6 +71,18 @@ func Parse(args []string) (*Config, error) { case arg == "--disable-npm-scan": v := false cfg.EnableNPMScan = &v + case arg == "--enable-brew-scan": + v := true + cfg.EnableBrewScan = &v + case arg == "--disable-brew-scan": + v := false + cfg.EnableBrewScan = &v + case arg == "--enable-python-scan": + v := true + cfg.EnablePythonScan = &v + case arg == "--disable-python-scan": + v := false + cfg.EnablePythonScan = &v case strings.HasPrefix(arg, "--color="): mode := strings.TrimPrefix(arg, "--color=") if mode != "auto" && mode != "always" && mode != "never" { @@ -127,12 +141,16 @@ Output formats (community mode, mutually exclusive): Options: --search-dirs DIR [DIR...] Search DIRs instead of $HOME (replaces default; repeatable) - --enable-npm-scan Enable Node.js package scanning - --disable-npm-scan Disable Node.js package scanning - --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 + --enable-npm-scan Enable Node.js package scanning + --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) + --color=WHEN Color mode: auto | always | never (default: auto) + -v, --version Show version + -h, --help Show this help Examples: %s # Pretty terminal output diff --git a/internal/config/config.go b/internal/config/config.go index 9db2440..c03d585 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,8 @@ var ( ScanFrequencyHours = "{{SCAN_FREQUENCY_HOURS}}" SearchDirs []string EnableNPMScan *bool // nil=auto + EnableBrewScan *bool // nil=auto + EnablePythonScan *bool // nil=auto ColorMode string // "" means auto OutputFormat string // "" means default (pretty) HTMLOutputFile string // "" means not set @@ -31,6 +33,8 @@ type ConfigFile struct { ScanFrequencyHours string `json:"scan_frequency_hours,omitempty"` SearchDirs []string `json:"search_dirs,omitempty"` EnableNPMScan *bool `json:"enable_npm_scan,omitempty"` + EnableBrewScan *bool `json:"enable_brew_scan,omitempty"` + EnablePythonScan *bool `json:"enable_python_scan,omitempty"` ColorMode string `json:"color_mode,omitempty"` OutputFormat string `json:"output_format,omitempty"` HTMLOutputFile string `json:"html_output_file,omitempty"` @@ -79,6 +83,12 @@ func Load() { if cfg.EnableNPMScan != nil && EnableNPMScan == nil { EnableNPMScan = cfg.EnableNPMScan } + if cfg.EnableBrewScan != nil && EnableBrewScan == nil { + EnableBrewScan = cfg.EnableBrewScan + } + if cfg.EnablePythonScan != nil && EnablePythonScan == nil { + EnablePythonScan = cfg.EnablePythonScan + } if cfg.ColorMode != "" && ColorMode == "" { ColorMode = cfg.ColorMode } @@ -156,6 +166,48 @@ func RunConfigure() error { existing.EnableNPMScan = nil // auto } + // Enable brew scan + currentBrew := "auto" + if existing.EnableBrewScan != nil { + if *existing.EnableBrewScan { + currentBrew = "true" + } else { + currentBrew = "false" + } + } + brewInput := promptValue(reader, "Enable Homebrew Scan (auto/true/false)", currentBrew) + switch strings.ToLower(brewInput) { + case "true": + v := true + existing.EnableBrewScan = &v + case "false": + v := false + existing.EnableBrewScan = &v + default: + existing.EnableBrewScan = nil + } + + // Enable python scan + currentPython := "auto" + if existing.EnablePythonScan != nil { + if *existing.EnablePythonScan { + currentPython = "true" + } else { + currentPython = "false" + } + } + pythonInput := promptValue(reader, "Enable Python Scan (auto/true/false)", currentPython) + switch strings.ToLower(pythonInput) { + case "true": + v := true + existing.EnablePythonScan = &v + case "false": + v := false + existing.EnablePythonScan = &v + default: + existing.EnablePythonScan = nil + } + // Color mode currentColor := existing.ColorMode if currentColor == "" { @@ -287,7 +339,9 @@ func ShowConfigure() { fmt.Printf(" %-24s %s\n", "API Key:", maskSecret(cfg.APIKey)) fmt.Printf(" %-24s %s\n", "Scan Frequency:", displayFrequency(cfg.ScanFrequencyHours)) fmt.Printf(" %-24s %s\n", "Search Directories:", displayDirs(cfg.SearchDirs)) - fmt.Printf(" %-24s %s\n", "Enable NPM Scan:", displayNPMScan(cfg.EnableNPMScan)) + fmt.Printf(" %-24s %s\n", "Enable NPM Scan:", displayBoolScan(cfg.EnableNPMScan)) + fmt.Printf(" %-24s %s\n", "Enable Brew Scan:", displayBoolScan(cfg.EnableBrewScan)) + fmt.Printf(" %-24s %s\n", "Enable Python Scan:", displayBoolScan(cfg.EnablePythonScan)) fmt.Printf(" %-24s %s\n", "Color Mode:", displayColorMode(cfg.ColorMode)) fmt.Printf(" %-24s %s\n", "Output Format:", displayOutputFormat(cfg.OutputFormat)) if cfg.OutputFormat == "html" { @@ -330,7 +384,7 @@ func displayDirs(dirs []string) string { return strings.Join(dirs, ", ") } -func displayNPMScan(v *bool) string { +func displayBoolScan(v *bool) string { if v == nil { return "auto" } diff --git a/internal/detector/brew.go b/internal/detector/brew.go new file mode 100644 index 0000000..0c7b170 --- /dev/null +++ b/internal/detector/brew.go @@ -0,0 +1,98 @@ +package detector + +import ( + "context" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +// BrewDetector detects Homebrew installation and packages. +type BrewDetector struct { + exec executor.Executor +} + +func NewBrewDetector(exec executor.Executor) *BrewDetector { + return &BrewDetector{exec: exec} +} + +// DetectBrew checks if Homebrew is installed and returns its version info. +// Returns nil if Homebrew is not found. +func (d *BrewDetector) DetectBrew(ctx context.Context) *model.PkgManager { + path, err := d.exec.LookPath("brew") + if err != nil { + return nil + } + + version := "unknown" + stdout, _, _, err := d.exec.RunWithTimeout(ctx, 10*time.Second, "brew", "--version") + if err == nil { + // "brew --version" outputs "Homebrew 4.3.5\n..." + if line := firstLine(stdout); line != "" { + version = strings.TrimPrefix(line, "Homebrew ") + } + } + + return &model.PkgManager{ + Name: "homebrew", + Version: version, + Path: path, + } +} + +// ListFormulae returns installed Homebrew formulae with versions. +func (d *BrewDetector) ListFormulae(ctx context.Context) []model.BrewPackage { + stdout, _, _, err := d.exec.RunWithTimeout(ctx, 30*time.Second, "brew", "list", "--formula", "--versions") + if err != nil { + return nil + } + return parseBrewList(stdout) +} + +// ListCasks returns installed Homebrew casks with versions. +func (d *BrewDetector) ListCasks(ctx context.Context) []model.BrewPackage { + stdout, _, _, err := d.exec.RunWithTimeout(ctx, 30*time.Second, "brew", "list", "--cask", "--versions") + if err != nil { + return nil + } + return parseBrewList(stdout) +} + +// parseBrewList parses "name version" lines from `brew list --versions` output. +func parseBrewList(stdout string) []model.BrewPackage { + stdout = strings.TrimSpace(stdout) + if stdout == "" { + return nil + } + var packages []model.BrewPackage + for _, line := range strings.Split(stdout, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // Format: "name version [version2 ...]" + parts := strings.Fields(line) + if len(parts) >= 2 { + packages = append(packages, model.BrewPackage{ + Name: parts[0], + Version: parts[1], + }) + } else if len(parts) == 1 { + packages = append(packages, model.BrewPackage{ + Name: parts[0], + Version: "unknown", + }) + } + } + return packages +} + +func firstLine(s string) string { + s = strings.TrimSpace(s) + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s +} diff --git a/internal/detector/brew_test.go b/internal/detector/brew_test.go new file mode 100644 index 0000000..752af52 --- /dev/null +++ b/internal/detector/brew_test.go @@ -0,0 +1,131 @@ +package detector + +import ( + "context" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/progress" +) + +func newTestLogger() *progress.Logger { + return progress.NewNoop() +} + +func TestBrewDetector_Found(t *testing.T) { + mock := executor.NewMock() + mock.SetPath("brew", "/opt/homebrew/bin/brew") + mock.SetCommand("Homebrew 4.3.5\nHomebrew/homebrew-core (git revision abc123)\n", "", 0, "brew", "--version") + + det := NewBrewDetector(mock) + result := det.DetectBrew(context.Background()) + + if result == nil { + t.Fatal("expected brew to be detected") + } + if result.Name != "homebrew" { + t.Errorf("expected name homebrew, got %s", result.Name) + } + if result.Version != "4.3.5" { + t.Errorf("expected version 4.3.5, got %s", result.Version) + } + if result.Path != "/opt/homebrew/bin/brew" { + t.Errorf("expected path /opt/homebrew/bin/brew, got %s", result.Path) + } +} + +func TestBrewDetector_NotFound(t *testing.T) { + mock := executor.NewMock() + det := NewBrewDetector(mock) + result := det.DetectBrew(context.Background()) + + if result != nil { + t.Error("expected nil when brew is not installed") + } +} + +func TestBrewDetector_ListFormulae(t *testing.T) { + mock := executor.NewMock() + mock.SetPath("brew", "/opt/homebrew/bin/brew") + mock.SetCommand("ca-certificates 2024.2.2\ncurl 8.4.0\ngit 2.43.0\nopenssl@3 3.2.0\n", "", 0, "brew", "list", "--formula", "--versions") + + det := NewBrewDetector(mock) + formulae := det.ListFormulae(context.Background()) + + if len(formulae) != 4 { + t.Fatalf("expected 4 formulae, got %d", len(formulae)) + } + if formulae[0].Name != "ca-certificates" || formulae[0].Version != "2024.2.2" { + t.Errorf("unexpected first formula: %+v", formulae[0]) + } +} + +func TestBrewDetector_ListCasks(t *testing.T) { + mock := executor.NewMock() + mock.SetPath("brew", "/opt/homebrew/bin/brew") + mock.SetCommand("firefox 120.0\ngoogle-chrome 120.0.6099.109\nvisual-studio-code 1.85.0\n", "", 0, "brew", "list", "--cask", "--versions") + + det := NewBrewDetector(mock) + casks := det.ListCasks(context.Background()) + + if len(casks) != 3 { + t.Fatalf("expected 3 casks, got %d", len(casks)) + } + if casks[0].Name != "firefox" || casks[0].Version != "120.0" { + t.Errorf("unexpected first cask: %+v", casks[0]) + } +} + +func TestBrewScanner_Formulae(t *testing.T) { + mock := executor.NewMock() + mock.SetPath("brew", "/opt/homebrew/bin/brew") + mock.SetCommand("curl 8.4.0\ngit 2.43.0\n", "", 0, "brew", "list", "--formula", "--versions") + + log := newTestLogger() + scanner := NewBrewScanner(mock, log) + result, ok := scanner.ScanFormulae(context.Background()) + + if !ok { + t.Fatal("expected scan to succeed") + } + if result.ScanType != "formulae" { + t.Errorf("expected scan type formulae, got %s", result.ScanType) + } + if result.RawStdoutBase64 == "" { + t.Error("expected non-empty base64 stdout") + } + if result.ExitCode != 0 { + t.Errorf("expected exit code 0, got %d", result.ExitCode) + } +} + +func TestBrewScanner_Casks(t *testing.T) { + mock := executor.NewMock() + mock.SetPath("brew", "/opt/homebrew/bin/brew") + mock.SetCommand("firefox 120.0\ngoogle-chrome 120.0.6099.109\n", "", 0, "brew", "list", "--cask", "--versions") + + log := newTestLogger() + scanner := NewBrewScanner(mock, log) + result, ok := scanner.ScanCasks(context.Background()) + + if !ok { + t.Fatal("expected scan to succeed") + } + if result.ScanType != "casks" { + t.Errorf("expected scan type casks, got %s", result.ScanType) + } + if result.RawStdoutBase64 == "" { + t.Error("expected non-empty base64 stdout") + } +} + +func TestBrewScanner_NotInstalled(t *testing.T) { + mock := executor.NewMock() + log := newTestLogger() + scanner := NewBrewScanner(mock, log) + + _, ok := scanner.ScanFormulae(context.Background()) + if ok { + t.Error("expected scan to fail when brew is not installed") + } +} diff --git a/internal/detector/brewscan.go b/internal/detector/brewscan.go new file mode 100644 index 0000000..348e5f8 --- /dev/null +++ b/internal/detector/brewscan.go @@ -0,0 +1,92 @@ +package detector + +import ( + "context" + "encoding/base64" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" + "github.com/step-security/dev-machine-guard/internal/progress" +) + +// BrewScanner performs enterprise-mode Homebrew scanning (raw output, base64 encoded). +type BrewScanner struct { + exec executor.Executor + log *progress.Logger +} + +func NewBrewScanner(exec executor.Executor, log *progress.Logger) *BrewScanner { + return &BrewScanner{exec: exec, log: log} +} + +// ScanFormulae runs `brew list --formula --versions` and returns raw base64-encoded output. +func (s *BrewScanner) ScanFormulae(ctx context.Context) (model.BrewScanResult, bool) { + if _, err := s.exec.LookPath("brew"); err != nil { + s.log.Progress(" brew not found in PATH for formulae scan") + return model.BrewScanResult{}, false + } + + s.log.Progress(" Scanning Homebrew formulae...") + start := time.Now() + stdout, stderr, exitCode, _ := s.exec.RunWithTimeout(ctx, 60*time.Second, "brew", "list", "--formula", "--versions") + duration := time.Since(start).Milliseconds() + + errMsg := "" + if exitCode != 0 { + errMsg = "brew list --formula --versions failed" + s.log.Progress(" Brew formulae scan failed: exit_code=%d stderr=%s", exitCode, stderr) + } + + lineCount := len(strings.Split(strings.TrimSpace(stdout), "\n")) + if strings.TrimSpace(stdout) == "" { + lineCount = 0 + } + s.log.Progress(" Brew formulae scan complete: %d lines, exit_code=%d, duration=%dms", lineCount, exitCode, duration) + + return model.BrewScanResult{ + ScanType: "formulae", + RawStdoutBase64: base64.StdEncoding.EncodeToString([]byte(stdout)), + RawStderrBase64: base64.StdEncoding.EncodeToString([]byte(stderr)), + Error: errMsg, + ExitCode: exitCode, + ScanDurationMs: duration, + LineCount: lineCount, + }, true +} + +// ScanCasks runs `brew list --cask --versions` and returns raw base64-encoded output. +func (s *BrewScanner) ScanCasks(ctx context.Context) (model.BrewScanResult, bool) { + if _, err := s.exec.LookPath("brew"); err != nil { + s.log.Progress(" brew not found in PATH for casks scan") + return model.BrewScanResult{}, false + } + + s.log.Progress(" Scanning Homebrew casks...") + start := time.Now() + stdout, stderr, exitCode, _ := s.exec.RunWithTimeout(ctx, 60*time.Second, "brew", "list", "--cask", "--versions") + duration := time.Since(start).Milliseconds() + + errMsg := "" + if exitCode != 0 { + errMsg = "brew list --cask --versions failed" + s.log.Progress(" Brew casks scan failed: exit_code=%d stderr=%s", exitCode, stderr) + } + + lineCount := len(strings.Split(strings.TrimSpace(stdout), "\n")) + if strings.TrimSpace(stdout) == "" { + lineCount = 0 + } + s.log.Progress(" Brew casks scan complete: %d lines, exit_code=%d, duration=%dms", lineCount, exitCode, duration) + + return model.BrewScanResult{ + ScanType: "casks", + RawStdoutBase64: base64.StdEncoding.EncodeToString([]byte(stdout)), + RawStderrBase64: base64.StdEncoding.EncodeToString([]byte(stderr)), + Error: errMsg, + ExitCode: exitCode, + ScanDurationMs: duration, + LineCount: lineCount, + }, true +} 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/mcp.go b/internal/detector/mcp.go index 471277d..542eff2 100644 --- a/internal/detector/mcp.go +++ b/internal/detector/mcp.go @@ -88,9 +88,13 @@ func (d *MCPDetector) DetectEnterprise(_ context.Context) []model.MCPConfigEnter continue } - // Filter JSON configs to extract only MCP-relevant fields - filteredContent := d.filterMCPContent(spec.SourceName, configPath, content) - contentBase64 := base64.StdEncoding.EncodeToString(filteredContent) + // Filter JSON configs to extract only MCP-relevant fields. + // If filtering fails (non-JSON, parse error, etc.), omit content + // to avoid leaking secrets like env vars and auth headers. + var contentBase64 string + if filtered, ok := d.filterMCPContent(spec.SourceName, configPath, content); ok { + contentBase64 = base64.StdEncoding.EncodeToString(filtered) + } results = append(results, model.MCPConfigEnterprise{ ConfigSource: spec.SourceName, @@ -107,8 +111,10 @@ func (d *MCPDetector) DetectEnterprise(_ context.Context) []model.MCPConfigEnter continue } - filteredContent := d.filterMCPContent(projectMCP.SourceName, projectMCP.ConfigPath, content) - contentBase64 := base64.StdEncoding.EncodeToString(filteredContent) + var contentBase64 string + if filtered, ok := d.filterMCPContent(projectMCP.SourceName, projectMCP.ConfigPath, content); ok { + contentBase64 = base64.StdEncoding.EncodeToString(filtered) + } results = append(results, model.MCPConfigEnterprise{ ConfigSource: projectMCP.SourceName, @@ -171,9 +177,11 @@ func (d *MCPDetector) resolveConfigPath(spec mcpConfigSpec, homeDir string) stri } // filterMCPContent extracts MCP-relevant fields from a config file. -func (d *MCPDetector) filterMCPContent(sourceName, configPath string, content []byte) []byte { +// Returns the filtered content and true on success, or nil and false if +// filtering failed (to avoid leaking secrets from raw fallback). +func (d *MCPDetector) filterMCPContent(sourceName, configPath string, content []byte) ([]byte, bool) { if !strings.HasSuffix(configPath, ".json") { - return content // Return as-is for TOML/YAML + return nil, false // Non-JSON formats cannot be safely filtered } jsonInput := content @@ -185,19 +193,19 @@ func (d *MCPDetector) filterMCPContent(sourceName, configPath string, content [] var raw map[string]json.RawMessage if err := json.Unmarshal(jsonInput, &raw); err != nil { - return content // Can't parse, return as-is + return nil, false // Can't parse; don't return raw content } filtered := d.extractMCPServers(raw) if filtered == nil { - return content + return nil, false // No MCP servers found } out, err := json.Marshal(filtered) if err != nil { - return content + return nil, false } - return out + return out, true } // extractMCPServers extracts mcpServers/context_servers/servers, keeping only command/args/serverUrl/url. diff --git a/internal/detector/mcp_test.go b/internal/detector/mcp_test.go index 2a64059..b96499f 100644 --- a/internal/detector/mcp_test.go +++ b/internal/detector/mcp_test.go @@ -2,7 +2,9 @@ package detector import ( "context" + "encoding/base64" "encoding/json" + "strings" "testing" "github.com/step-security/dev-machine-guard/internal/executor" @@ -81,6 +83,102 @@ func TestMCPDetector_Enterprise(t *testing.T) { if results[0].ConfigContentBase64 == "" { t.Error("expected non-empty base64 content") } + + // Verify secrets are stripped from filtered output + decoded, err := base64.StdEncoding.DecodeString(results[0].ConfigContentBase64) + if err != nil { + t.Fatalf("failed to decode base64: %v", err) + } + content := string(decoded) + if strings.Contains(content, "SECRET") { + t.Error("filtered content must not contain env var secrets") + } + if strings.Contains(content, "env") { + t.Error("filtered content must not contain env field") + } + if !strings.Contains(content, "command") { + t.Error("filtered content should contain command field") + } + if !strings.Contains(content, "args") { + t.Error("filtered content should contain args field") + } +} + +func TestMCPDetector_Enterprise_NonJSON_OmitsContent(t *testing.T) { + mock := executor.NewMock() + mock.SetFile("/Users/testuser/.config/open-interpreter/config.yaml", + []byte("api_key: sk-secret-12345\nmodel: gpt-4\n")) + + det := NewMCPDetector(mock) + results := det.DetectEnterprise(context.Background()) + + if len(results) != 1 { + t.Fatalf("expected 1 enterprise config, got %d", len(results)) + } + if results[0].ConfigContentBase64 != "" { + t.Error("non-JSON config must have empty content to avoid leaking secrets") + } + if results[0].ConfigSource != "open_interpreter" { + t.Errorf("expected open_interpreter source, got %s", results[0].ConfigSource) + } +} + +func TestMCPDetector_Enterprise_InvalidJSON_OmitsContent(t *testing.T) { + mock := executor.NewMock() + mock.SetFile("/Users/testuser/Library/Application Support/Claude/claude_desktop_config.json", + []byte(`{invalid json with "env":{"API_KEY":"sk-secret"}}`)) + + det := NewMCPDetector(mock) + results := det.DetectEnterprise(context.Background()) + + if len(results) != 1 { + t.Fatalf("expected 1 enterprise config, got %d", len(results)) + } + if results[0].ConfigContentBase64 != "" { + t.Error("invalid JSON config must have empty content to avoid leaking secrets") + } +} + +func TestMCPDetector_Enterprise_NoMCPServers_OmitsContent(t *testing.T) { + mock := executor.NewMock() + mock.SetFile("/Users/testuser/Library/Application Support/Claude/claude_desktop_config.json", + []byte(`{"theme":"dark","api_key":"sk-secret-12345"}`)) + + det := NewMCPDetector(mock) + results := det.DetectEnterprise(context.Background()) + + if len(results) != 1 { + t.Fatalf("expected 1 enterprise config, got %d", len(results)) + } + if results[0].ConfigContentBase64 != "" { + t.Error("config without mcpServers must have empty content to avoid leaking secrets") + } +} + +func TestFilterMCPContent_StripsSecrets(t *testing.T) { + mock := executor.NewMock() + det := NewMCPDetector(mock) + + input := []byte(`{"mcpServers":{"myserver":{"command":"npx","args":["-y","server"],"env":{"API_KEY":"sk-secret"},"headers":{"Authorization":"Bearer token"}}}}`) + + filtered, ok := det.filterMCPContent("claude_desktop", "/path/config.json", input) + if !ok { + t.Fatal("expected filtering to succeed") + } + + content := string(filtered) + if strings.Contains(content, "sk-secret") { + t.Error("filtered content must not contain API key") + } + if strings.Contains(content, "Bearer") { + t.Error("filtered content must not contain auth headers") + } + if !strings.Contains(content, "command") || !strings.Contains(content, "npx") { + t.Error("filtered content should preserve command") + } + if !strings.Contains(content, "args") { + t.Error("filtered content should preserve args") + } } func TestExtractMCPServers_ClaudeCodeProjectScoped(t *testing.T) { @@ -108,7 +206,10 @@ func TestExtractMCPServers_ClaudeCodeProjectScoped(t *testing.T) { } }`) - filtered := det.filterMCPContent("claude_code", "/Users/test/.claude.json", content) + filtered, ok := det.filterMCPContent("claude_code", "/Users/test/.claude.json", content) + if !ok { + t.Fatal("expected filtering to succeed") + } // Parse the result to verify structure var result map[string]any @@ -162,7 +263,10 @@ func TestExtractMCPServers_VSCodeFormat(t *testing.T) { } }`) - filtered := det.filterMCPContent("vscode", "/Users/test/.vscode/mcp.json", content) + filtered, ok := det.filterMCPContent("vscode", "/Users/test/.vscode/mcp.json", content) + if !ok { + t.Fatal("expected filtering to succeed") + } var result map[string]any if err := json.Unmarshal(filtered, &result); err != nil { diff --git a/internal/detector/nodeproject.go b/internal/detector/nodeproject.go index 9e18030..eaca8ae 100644 --- a/internal/detector/nodeproject.go +++ b/internal/detector/nodeproject.go @@ -2,11 +2,13 @@ package detector import ( "context" + "encoding/json" "os" "path/filepath" "strings" "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" ) const maxNodeProjects = 1000 @@ -21,27 +23,31 @@ func NewNodeProjectDetector(exec executor.Executor) *NodeProjectDetector { } // CountProjects counts the number of Node.js projects found under the given directories. -// It finds package.json files (excluding node_modules) up to a limit. func (d *NodeProjectDetector) CountProjects(_ context.Context, searchDirs []string) int { - count := 0 + return len(d.ListProjects(searchDirs)) +} + +// ListProjects returns Node.js project paths with their detected package manager +// and the dependencies listed in package.json. +func (d *NodeProjectDetector) ListProjects(searchDirs []string) []model.ProjectInfo { + var projects []model.ProjectInfo for _, dir := range searchDirs { - count += d.countInDir(dir) - if count >= maxNodeProjects { - return maxNodeProjects + projects = append(projects, d.listInDir(dir)...) + if len(projects) >= maxNodeProjects { + return projects[:maxNodeProjects] } } - return count + return projects } -func (d *NodeProjectDetector) countInDir(dir string) int { - count := 0 +func (d *NodeProjectDetector) listInDir(dir string) []model.ProjectInfo { + var projects []model.ProjectInfo _ = filepath.WalkDir(dir, func(path string, entry os.DirEntry, err error) error { if err != nil { - return nil // skip inaccessible dirs + return nil } if entry.IsDir() { name := entry.Name() - // Skip node_modules, hidden dirs, and other irrelevant dirs if name == "node_modules" || name == ".git" || name == ".cache" || strings.HasPrefix(name, ".") { return filepath.SkipDir @@ -49,14 +55,55 @@ func (d *NodeProjectDetector) countInDir(dir string) int { return nil } if entry.Name() == "package.json" { - count++ - if count >= maxNodeProjects { + projectDir := filepath.Dir(path) + pm := DetectProjectPM(d.exec, projectDir) + // Only include projects with node_modules installed. + // yarn-berry can use PnP (no node_modules), so check for .pnp.cjs instead. + hasNodeModules := d.exec.DirExists(filepath.Join(projectDir, "node_modules")) + isYarnBerryPnP := pm == "yarn-berry" && d.exec.FileExists(filepath.Join(projectDir, ".pnp.cjs")) + if !hasNodeModules && !isYarnBerryPnP { + return nil + } + pkgs := d.readPackageJSONDeps(path) + projects = append(projects, model.ProjectInfo{ + Path: projectDir, + PackageManager: pm, + Packages: pkgs, + }) + if len(projects) >= maxNodeProjects { return filepath.SkipAll } } return nil }) - return count + return projects +} + +// readPackageJSONDeps reads dependencies + devDependencies from a package.json file. +func (d *NodeProjectDetector) readPackageJSONDeps(packageJSONPath string) []model.PackageDetail { + data, err := d.exec.ReadFile(packageJSONPath) + if err != nil { + return nil + } + var pkg struct { + Dependencies map[string]string `json:"dependencies"` + DevDependencies map[string]string `json:"devDependencies"` + } + if err := json.Unmarshal(data, &pkg); err != nil { + return nil + } + total := len(pkg.Dependencies) + len(pkg.DevDependencies) + if total == 0 { + return nil + } + pkgs := make([]model.PackageDetail, 0, total) + for name, version := range pkg.Dependencies { + pkgs = append(pkgs, model.PackageDetail{Name: name, Version: version}) + } + for name, version := range pkg.DevDependencies { + pkgs = append(pkgs, model.PackageDetail{Name: name, Version: version}) + } + return pkgs } // DetectProjectPM detects which package manager a project uses based on lock files. @@ -71,7 +118,6 @@ func DetectProjectPM(exec executor.Executor, projectDir string) string { return "pnpm" } if exec.FileExists(filepath.Join(projectDir, "yarn.lock")) { - // Distinguish Yarn Classic from Yarn Berry if exec.FileExists(filepath.Join(projectDir, ".yarnrc.yml")) || exec.DirExists(filepath.Join(projectDir, ".yarn", "releases")) { return "yarn-berry" } @@ -80,5 +126,5 @@ func DetectProjectPM(exec executor.Executor, projectDir string) string { if exec.FileExists(filepath.Join(projectDir, "package-lock.json")) { return "npm" } - return "npm" // default + return "npm" } diff --git a/internal/detector/pythonpm.go b/internal/detector/pythonpm.go new file mode 100644 index 0000000..20a867b --- /dev/null +++ b/internal/detector/pythonpm.go @@ -0,0 +1,116 @@ +package detector + +import ( + "context" + "encoding/json" + "regexp" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +var pythonPackageManagers = []pmSpec{ + {"python3", "python3", "--version"}, + {"pip", "pip3", "--version"}, + {"poetry", "poetry", "--version"}, + {"pipenv", "pipenv", "--version"}, + {"uv", "uv", "--version"}, + {"conda", "conda", "--version"}, + {"rye", "rye", "--version"}, +} + +// PythonPMDetector detects installed Python package managers. +type PythonPMDetector struct { + exec executor.Executor +} + +func NewPythonPMDetector(exec executor.Executor) *PythonPMDetector { + return &PythonPMDetector{exec: exec} +} + +func (d *PythonPMDetector) DetectManagers(ctx context.Context) []model.PkgManager { + var results []model.PkgManager + + for _, pm := range pythonPackageManagers { + path, err := d.exec.LookPath(pm.Binary) + if err != nil { + continue + } + + version := "unknown" + stdout, _, _, err := d.exec.RunWithTimeout(ctx, 10*time.Second, pm.Binary, pm.VersionCmd) + if err == nil { + v := parsePythonVersion(pm.Name, stdout) + if v != "" { + version = v + } + } + + results = append(results, model.PkgManager{ + Name: pm.Name, + Version: version, + Path: path, + }) + } + + return results +} + +// ListPackages returns installed Python packages using pip3. +func (d *PythonPMDetector) ListPackages(ctx context.Context) []model.PythonPackage { + if _, err := d.exec.LookPath("pip3"); err != nil { + return nil + } + stdout, _, _, err := d.exec.RunWithTimeout(ctx, 30*time.Second, "pip3", "list", "--format", "json") + if err != nil { + return nil + } + return parsePipListJSON(stdout) +} + +// parsePipListJSON parses `pip list --format json` output: [{"name":"pkg","version":"1.0"},...] +func parsePipListJSON(stdout string) []model.PythonPackage { + stdout = strings.TrimSpace(stdout) + if stdout == "" { + return nil + } + type pipEntry struct { + Name string `json:"name"` + Version string `json:"version"` + } + var entries []pipEntry + if err := json.Unmarshal([]byte(stdout), &entries); err != nil { + return nil + } + packages := make([]model.PythonPackage, len(entries)) + for i, e := range entries { + packages[i] = model.PythonPackage{Name: e.Name, Version: e.Version} + } + return packages +} + +// versionRegex matches a semver-like version number (e.g., 3.12.0, 24.0, 1.8.0). +var versionRegex = regexp.MustCompile(`\d+\.\d+(?:\.\d+)?`) + +// parsePythonVersion extracts the version number from various Python tool output formats: +// - python3 --version → "Python 3.12.0" +// - pip3 --version → "pip 24.0 from /usr/lib/... (python 3.12)" +// - poetry --version → "Poetry (version 1.8.0)" +// - uv --version → "uv 0.4.0" +// - conda --version → "conda 24.1.2" +// - rye --version → "rye 0.35.0" +// - pipenv --version → "pipenv, version 2024.0.1" +func parsePythonVersion(name, stdout string) string { + stdout = strings.TrimSpace(stdout) + if stdout == "" { + return "" + } + // Take first line only + line := firstLine(stdout) + if match := versionRegex.FindString(line); match != "" { + return match + } + return strings.TrimSpace(line) +} diff --git a/internal/detector/pythonpm_test.go b/internal/detector/pythonpm_test.go new file mode 100644 index 0000000..761bb80 --- /dev/null +++ b/internal/detector/pythonpm_test.go @@ -0,0 +1,130 @@ +package detector + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +func TestPythonPMDetector_FindsPip(t *testing.T) { + mock := executor.NewMock() + mock.SetPath("pip3", "/usr/local/bin/pip3") + mock.SetCommand("pip 24.0 from /usr/lib/python3.12/site-packages/pip (python 3.12)\n", "", 0, "pip3", "--version") + + det := NewPythonPMDetector(mock) + results := det.DetectManagers(context.Background()) + + found := false + for _, r := range results { + if r.Name == "pip" { + found = true + if r.Version != "24.0" { + t.Errorf("expected pip version 24.0, got %s", r.Version) + } + } + } + if !found { + t.Error("expected pip to be detected") + } +} + +func TestPythonPMDetector_FindsMultiple(t *testing.T) { + mock := executor.NewMock() + mock.SetPath("python3", "/usr/local/bin/python3") + mock.SetCommand("Python 3.12.0\n", "", 0, "python3", "--version") + mock.SetPath("pip3", "/usr/local/bin/pip3") + mock.SetCommand("pip 24.0 from /usr/lib/python3.12/site-packages/pip (python 3.12)\n", "", 0, "pip3", "--version") + mock.SetPath("uv", "/usr/local/bin/uv") + mock.SetCommand("uv 0.4.0\n", "", 0, "uv", "--version") + + det := NewPythonPMDetector(mock) + results := det.DetectManagers(context.Background()) + + if len(results) != 3 { + t.Fatalf("expected 3 package managers, got %d", len(results)) + } +} + +func TestPythonPMDetector_NoneFound(t *testing.T) { + mock := executor.NewMock() + det := NewPythonPMDetector(mock) + results := det.DetectManagers(context.Background()) + + if len(results) != 0 { + t.Errorf("expected 0 package managers, got %d", len(results)) + } +} + +func TestParsePythonVersion(t *testing.T) { + tests := []struct { + name string + stdout string + expected string + }{ + {"python3", "Python 3.12.0\n", "3.12.0"}, + {"pip", "pip 24.0 from /usr/lib/python3.12/site-packages/pip (python 3.12)\n", "24.0"}, + {"poetry", "Poetry (version 1.8.0)\n", "1.8.0"}, + {"uv", "uv 0.4.0\n", "0.4.0"}, + {"conda", "conda 24.1.2\n", "24.1.2"}, + {"rye", "rye 0.35.0\n", "0.35.0"}, + {"pipenv", "pipenv, version 2024.0.1\n", "2024.0.1"}, + {"empty", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parsePythonVersion(tt.name, tt.stdout) + if got != tt.expected { + t.Errorf("parsePythonVersion(%q, %q) = %q, want %q", tt.name, tt.stdout, got, tt.expected) + } + }) + } +} + +func TestPythonProjectDetector_CountProjects(t *testing.T) { + dir := t.TempDir() + + // project1: has venv — should be detected + mustCreateFile(t, filepath.Join(dir, "project1", "pyproject.toml")) + mustCreateFile(t, filepath.Join(dir, "project1", ".venv", "bin", "pip")) + + // project2: has venv — should be detected + mustCreateFile(t, filepath.Join(dir, "project2", "setup.py")) + mustCreateFile(t, filepath.Join(dir, "project2", "venv", "bin", "pip")) + + // project3: no venv — should be skipped + mustCreateFile(t, filepath.Join(dir, "project3", "Pipfile")) + + mock := executor.NewMock() + // Mock FileExists for venv pip paths + mock.SetFile(filepath.Join(dir, "project1", ".venv", "bin", "pip"), []byte("")) + mock.SetFile(filepath.Join(dir, "project2", "venv", "bin", "pip"), []byte("")) + // Mock pip list output + mock.SetCommand(`[{"name":"flask","version":"3.0.0"}]`, "", 0, + filepath.Join(dir, "project1", ".venv", "bin", "pip"), "list", "--format", "json") + mock.SetCommand(`[{"name":"django","version":"5.0"}]`, "", 0, + filepath.Join(dir, "project2", "venv", "bin", "pip"), "list", "--format", "json") + + det := NewPythonProjectDetector(mock) + projects := det.ListProjects([]string{dir}) + + if len(projects) != 2 { + t.Fatalf("expected 2 venv projects, got %d", len(projects)) + } + if len(projects[0].Packages) == 0 { + t.Error("expected packages in first project") + } +} + +func mustCreateFile(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(""), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/detector/pythonproject.go b/internal/detector/pythonproject.go new file mode 100644 index 0000000..2fb04b8 --- /dev/null +++ b/internal/detector/pythonproject.go @@ -0,0 +1,183 @@ +package detector + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +const maxPythonProjects = 1000 + +// pythonMarkerFiles are files that indicate a Python project directory. +var pythonMarkerFiles = map[string]bool{ + "pyproject.toml": true, + "setup.py": true, + "requirements.txt": true, + "Pipfile": true, +} + +// PythonProjectDetector scans for Python projects with virtual environments. +type PythonProjectDetector struct { + exec executor.Executor +} + +func NewPythonProjectDetector(exec executor.Executor) *PythonProjectDetector { + return &PythonProjectDetector{exec: exec} +} + +// CountProjects counts Python projects with virtual environments. +func (d *PythonProjectDetector) CountProjects(_ context.Context, searchDirs []string) int { + return len(d.ListProjects(searchDirs)) +} + +// ListProjects returns Python projects that have a virtual environment, +// along with the packages installed in each venv. +func (d *PythonProjectDetector) ListProjects(searchDirs []string) []model.ProjectInfo { + var projects []model.ProjectInfo + for _, dir := range searchDirs { + projects = append(projects, d.listInDir(dir)...) + if len(projects) >= maxPythonProjects { + return projects[:maxPythonProjects] + } + } + return projects +} + +// venvDirNames are directory names that indicate a Python virtual environment. +var venvDirNames = []string{".venv", "venv"} + +// findVenvPip returns the path to pip inside a venv, or "" if not found. +func (d *PythonProjectDetector) findVenvPip(projectDir string) string { + for _, vdir := range venvDirNames { + pip := filepath.Join(projectDir, vdir, "bin", "pip") + if d.exec.FileExists(pip) { + return pip + } + } + return "" +} + +// listVenvPackages runs pip list inside the venv and returns the packages. +func (d *PythonProjectDetector) listVenvPackages(ctx context.Context, pipPath string) []model.PackageDetail { + stdout, _, _, err := d.exec.RunWithTimeout(ctx, 15*time.Second, pipPath, "list", "--format", "json") + if err != nil { + return nil + } + stdout = strings.TrimSpace(stdout) + if stdout == "" { + return nil + } + type pipEntry struct { + Name string `json:"name"` + Version string `json:"version"` + } + var entries []pipEntry + if err := json.Unmarshal([]byte(stdout), &entries); err != nil { + return nil + } + pkgs := make([]model.PackageDetail, 0, len(entries)) + for _, e := range entries { + pkgs = append(pkgs, model.PackageDetail{Name: e.Name, Version: e.Version}) + } + return pkgs +} + +// pythonPMFromMarker maps a marker file to its package manager name. +var pythonPMFromMarker = map[string]string{ + "Pipfile": "pipenv", + "pyproject.toml": "pip", + "setup.py": "pip", + "requirements.txt": "pip", +} + +func (d *PythonProjectDetector) listInDir(dir string) []model.ProjectInfo { + ctx := context.Background() + seen := make(map[string]bool) + var projects []model.ProjectInfo + _ = filepath.WalkDir(dir, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return nil + } + if entry.IsDir() { + name := entry.Name() + if name == "node_modules" || name == ".git" || name == ".cache" || + name == "__pycache__" || name == ".tox" || name == "site-packages" || + (strings.HasPrefix(name, ".") && name != ".venv") { + return filepath.SkipDir + } + + // Detect directories that contain a venv even without a marker file. + // A venv/ or .venv/ subdirectory is itself evidence of a Python project. + if !seen[path] { + if pipPath := d.findVenvPip(path); pipPath != "" { + seen[path] = true + pm := d.detectPM(path) + pkgs := d.listVenvPackages(ctx, pipPath) + projects = append(projects, model.ProjectInfo{ + Path: path, + PackageManager: pm, + Packages: pkgs, + }) + if len(projects) >= maxPythonProjects { + return filepath.SkipAll + } + } + } + + return nil + } + if pythonMarkerFiles[entry.Name()] { + projectDir := filepath.Dir(path) + if seen[projectDir] { + return nil + } + seen[projectDir] = true + + // Only include marker-based projects that have a virtual environment + pipPath := d.findVenvPip(projectDir) + if pipPath == "" { + return nil + } + + pm := d.detectPM(projectDir) + + pkgs := d.listVenvPackages(ctx, pipPath) + + projects = append(projects, model.ProjectInfo{ + Path: projectDir, + PackageManager: pm, + Packages: pkgs, + }) + if len(projects) >= maxPythonProjects { + return filepath.SkipAll + } + } + return nil + }) + return projects +} + +// detectPM determines the package manager for a project directory based on lock/marker files. +func (d *PythonProjectDetector) detectPM(projectDir string) string { + if d.exec.FileExists(filepath.Join(projectDir, "poetry.lock")) { + return "poetry" + } + if d.exec.FileExists(filepath.Join(projectDir, "Pipfile.lock")) { + return "pipenv" + } + if d.exec.FileExists(filepath.Join(projectDir, "uv.lock")) { + return "uv" + } + for marker, pm := range pythonPMFromMarker { + if d.exec.FileExists(filepath.Join(projectDir, marker)) { + return pm + } + } + return "pip" +} diff --git a/internal/detector/pythonscan.go b/internal/detector/pythonscan.go new file mode 100644 index 0000000..d1c5c02 --- /dev/null +++ b/internal/detector/pythonscan.go @@ -0,0 +1,85 @@ +package detector + +import ( + "context" + "encoding/base64" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" + "github.com/step-security/dev-machine-guard/internal/progress" +) + +// PythonScanner performs enterprise-mode Python scanning (raw output, base64 encoded). +type PythonScanner struct { + exec executor.Executor + log *progress.Logger +} + +func NewPythonScanner(exec executor.Executor, log *progress.Logger) *PythonScanner { + return &PythonScanner{exec: exec, log: log} +} + +type pythonScanSpec struct { + binary string + name string + versionCmd string + listArgs []string +} + +var pythonScanSpecs = []pythonScanSpec{ + {"pip3", "pip", "--version", []string{"list", "--format", "json"}}, + {"conda", "conda", "--version", []string{"list", "--json"}}, + {"uv", "uv", "--version", []string{"pip", "list", "--format", "json"}}, +} + +// ScanGlobalPackages runs pip3/conda/uv list and returns raw base64-encoded results. +func (s *PythonScanner) ScanGlobalPackages(ctx context.Context) []model.PythonScanResult { + var results []model.PythonScanResult + + for _, spec := range pythonScanSpecs { + binPath, err := s.exec.LookPath(spec.binary) + if err != nil { + continue + } + + s.log.Progress(" Checking %s global packages...", spec.name) + version := s.getVersion(ctx, spec.binary, spec.versionCmd) + + start := time.Now() + args := spec.listArgs + stdout, stderr, exitCode, _ := s.exec.RunWithTimeout(ctx, 60*time.Second, spec.binary, args...) + duration := time.Since(start).Milliseconds() + + errMsg := "" + if exitCode != 0 { + errMsg = spec.binary + " list command failed" + } + + results = append(results, model.PythonScanResult{ + PackageManager: spec.name, + PMVersion: version, + BinaryPath: binPath, + RawStdoutBase64: base64.StdEncoding.EncodeToString([]byte(stdout)), + RawStderrBase64: base64.StdEncoding.EncodeToString([]byte(stderr)), + Error: errMsg, + ExitCode: exitCode, + ScanDurationMs: duration, + }) + } + + return results +} + +func (s *PythonScanner) getVersion(ctx context.Context, binary, versionCmd string) string { + stdout, _, _, err := s.exec.RunWithTimeout(ctx, 10*time.Second, binary, versionCmd) + if err != nil { + return "unknown" + } + v := strings.TrimSpace(stdout) + if v == "" { + return "unknown" + } + return parsePythonVersion(binary, v) +} 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..d8778b3 --- /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/executor_unix.go b/internal/executor/executor_unix.go index dc5b380..0963320 100644 --- a/internal/executor/executor_unix.go +++ b/internal/executor/executor_unix.go @@ -5,6 +5,7 @@ package executor import ( "context" "os" + "runtime" "strings" ) @@ -12,11 +13,44 @@ func (r *Real) IsRoot() bool { return os.Getuid() == 0 } +// resolveUserShell returns the given user's configured login shell on macOS by +// consulting Directory Services (dscl). Returns "" on non-darwin platforms, if +// the lookup fails, or if the resolved path isn't an executable file — in which +// case callers should fall back to /bin/bash. +// +// Mirrors stepsecurity-dev-machine-guard.sh:run_as_logged_in_user. Matters when +// the user's PATH (including npm/pnpm/yarn via nvm/fnm/homebrew) is configured +// only in zsh profile files (.zprofile/.zshrc) — bash -l on such a user sources +// nothing and runs with a stripped PATH, producing empty package scans. +func (r *Real) resolveUserShell(ctx context.Context, username string) string { + if runtime.GOOS != "darwin" || username == "" { + return "" + } + stdout, _, _, err := r.Run(ctx, "dscl", ".", "-read", "/Users/"+username, "UserShell") + if err != nil { + return "" + } + fields := strings.Fields(strings.TrimSpace(stdout)) + if len(fields) < 2 { + return "" + } + shell := fields[1] + info, err := os.Stat(shell) + if err != nil || info.IsDir() || info.Mode()&0o111 == 0 { + return "" + } + return shell +} + func (r *Real) RunAsUser(ctx context.Context, username, command string) (string, error) { if !r.IsRoot() { stdout, _, _, err := r.Run(ctx, "bash", "-c", command) return strings.TrimSpace(stdout), err } - stdout, _, _, err := r.Run(ctx, "sudo", "-H", "-u", username, "bash", "-l", "-c", command) + shell := r.resolveUserShell(ctx, username) + if shell == "" { + shell = "/bin/bash" + } + stdout, _, _, err := r.Run(ctx, "sudo", "-H", "-u", username, shell, "-l", "-c", command) return strings.TrimSpace(stdout), err } diff --git a/internal/executor/mock.go b/internal/executor/mock.go index cfb79a7..b41a703 100644 --- a/internal/executor/mock.go +++ b/internal/executor/mock.go @@ -296,3 +296,25 @@ 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/executor/user_aware.go b/internal/executor/user_aware.go new file mode 100644 index 0000000..b703599 --- /dev/null +++ b/internal/executor/user_aware.go @@ -0,0 +1,86 @@ +package executor + +import ( + "context" + "fmt" + "os" + "os/user" + "strings" + "time" +) + +// UserAwareExecutor wraps an Executor and delegates LookPath and RunWithTimeout +// to the logged-in user when the process is running as root. This ensures that +// commands like "brew list", "pip3 list", "npm --version" etc. execute in the +// correct user context, since many tools refuse to run as root or return +// different results for different users. +// +// All other Executor methods are forwarded unchanged. +type UserAwareExecutor struct { + inner Executor + username string // logged-in user to delegate to; empty = no delegation +} + +// NewUserAwareExecutor returns a wrapped executor that delegates command execution +// to the given user when running as root on Unix. If username is empty or the +// process is not root, all calls pass through to the inner executor unchanged. +func NewUserAwareExecutor(inner Executor, username string) Executor { + if username == "" || !inner.IsRoot() || inner.GOOS() == "windows" { + return inner // no wrapping needed + } + return &UserAwareExecutor{inner: inner, username: username} +} + +func (e *UserAwareExecutor) Run(ctx context.Context, name string, args ...string) (string, string, int, error) { + cmd := name + for _, a := range args { + cmd += " " + a + } + stdout, err := e.inner.RunAsUser(ctx, e.username, cmd) + if err != nil { + return stdout, err.Error(), 1, err + } + return stdout, "", 0, nil +} + +func (e *UserAwareExecutor) RunWithTimeout(ctx context.Context, timeout time.Duration, name string, args ...string) (string, string, int, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + stdout, stderr, code, err := e.Run(ctx, name, args...) + if ctx.Err() == context.DeadlineExceeded { + return stdout, stderr, 124, fmt.Errorf("command timed out after %s", timeout) + } + return stdout, stderr, code, err +} + +func (e *UserAwareExecutor) RunAsUser(ctx context.Context, username, command string) (string, error) { + return e.inner.RunAsUser(ctx, username, command) +} + +func (e *UserAwareExecutor) LookPath(name string) (string, error) { + stdout, err := e.inner.RunAsUser(context.Background(), e.username, "which "+name) + if err != nil || strings.TrimSpace(stdout) == "" { + return "", fmt.Errorf("%s not found in user PATH", name) + } + return strings.TrimSpace(stdout), nil +} + +// --- Pass-through methods --- + +func (e *UserAwareExecutor) FileExists(path string) bool { return e.inner.FileExists(path) } +func (e *UserAwareExecutor) DirExists(path string) bool { return e.inner.DirExists(path) } +func (e *UserAwareExecutor) ReadFile(path string) ([]byte, error) { return e.inner.ReadFile(path) } +func (e *UserAwareExecutor) ReadDir(path string) ([]os.DirEntry, error) { + return e.inner.ReadDir(path) +} +func (e *UserAwareExecutor) Stat(path string) (os.FileInfo, error) { return e.inner.Stat(path) } +func (e *UserAwareExecutor) Hostname() (string, error) { return e.inner.Hostname() } +func (e *UserAwareExecutor) Getenv(key string) string { return e.inner.Getenv(key) } +func (e *UserAwareExecutor) IsRoot() bool { return e.inner.IsRoot() } +func (e *UserAwareExecutor) CurrentUser() (*user.User, error) { return e.inner.CurrentUser() } +func (e *UserAwareExecutor) HomeDir(username string) (string, error) { + return e.inner.HomeDir(username) +} +func (e *UserAwareExecutor) Glob(pattern string) ([]string, error) { return e.inner.Glob(pattern) } +func (e *UserAwareExecutor) LoggedInUser() (*user.User, error) { return e.inner.LoggedInUser() } +func (e *UserAwareExecutor) GOOS() string { return e.inner.GOOS() } diff --git a/internal/model/model.go b/internal/model/model.go index d394e86..4f9a18e 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -2,18 +2,25 @@ package model // ScanResult is the community-mode JSON output structure. type ScanResult struct { - AgentVersion string `json:"agent_version"` - AgentURL string `json:"agent_url"` - ScanTimestamp int64 `json:"scan_timestamp"` - ScanTimestampISO string `json:"scan_timestamp_iso"` - Device Device `json:"device"` - AIAgentsAndTools []AITool `json:"ai_agents_and_tools"` - IDEInstallations []IDE `json:"ide_installations"` - IDEExtensions []Extension `json:"ide_extensions"` - MCPConfigs []MCPConfig `json:"mcp_configs"` - NodePkgManagers []PkgManager `json:"node_package_managers"` - NodePackages []any `json:"node_packages"` - Summary Summary `json:"summary"` + AgentVersion string `json:"agent_version"` + AgentURL string `json:"agent_url"` + ScanTimestamp int64 `json:"scan_timestamp"` + ScanTimestampISO string `json:"scan_timestamp_iso"` + Device Device `json:"device"` + AIAgentsAndTools []AITool `json:"ai_agents_and_tools"` + IDEInstallations []IDE `json:"ide_installations"` + IDEExtensions []Extension `json:"ide_extensions"` + MCPConfigs []MCPConfig `json:"mcp_configs"` + NodePkgManagers []PkgManager `json:"node_package_managers"` + NodePackages []any `json:"node_packages"` + NodeProjects []ProjectInfo `json:"node_projects"` + BrewPkgManager *PkgManager `json:"brew_package_manager,omitempty"` + BrewFormulae []BrewPackage `json:"brew_formulae"` + BrewCasks []BrewPackage `json:"brew_casks"` + PythonPkgManagers []PkgManager `json:"python_package_managers"` + PythonPackages []PythonPackage `json:"python_packages"` + PythonProjects []ProjectInfo `json:"python_projects"` + Summary Summary `json:"summary"` } type Device struct { @@ -52,6 +59,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). @@ -81,6 +89,9 @@ type Summary struct { IDEExtensionsCount int `json:"ide_extensions_count"` MCPConfigsCount int `json:"mcp_configs_count"` NodeProjectsCount int `json:"node_projects_count"` + BrewFormulaeCount int `json:"brew_formulae_count"` + BrewCasksCount int `json:"brew_casks_count"` + PythonProjectsCount int `json:"python_projects_count"` } // NodeScanResult holds raw scan output for enterprise telemetry. @@ -96,3 +107,51 @@ type NodeScanResult struct { ExitCode int `json:"exit_code"` ScanDurationMs int64 `json:"scan_duration_ms"` } + +// PackageDetail represents a single package name and version. +type PackageDetail struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// ProjectInfo represents a detected project directory with its packages. +type ProjectInfo struct { + Path string `json:"path"` + PackageManager string `json:"package_manager,omitempty"` + Packages []PackageDetail `json:"packages,omitempty"` +} + +// BrewPackage represents a single installed Homebrew formula or cask. +type BrewPackage struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// PythonPackage represents a single installed Python package. +type PythonPackage struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// BrewScanResult holds raw Homebrew scan output for enterprise telemetry. +type BrewScanResult struct { + ScanType string `json:"scan_type"` // "formulae" or "casks" + RawStdoutBase64 string `json:"raw_stdout_base64"` + RawStderrBase64 string `json:"raw_stderr_base64"` + Error string `json:"error"` + ExitCode int `json:"exit_code"` + ScanDurationMs int64 `json:"scan_duration_ms"` + LineCount int `json:"line_count"` +} + +// PythonScanResult holds raw Python scan output for enterprise telemetry. +type PythonScanResult struct { + PackageManager string `json:"package_manager"` + PMVersion string `json:"package_manager_version"` + BinaryPath string `json:"binary_path"` // Resolved path to the package manager binary + RawStdoutBase64 string `json:"raw_stdout_base64"` + RawStderrBase64 string `json:"raw_stderr_base64"` + Error string `json:"error"` + ExitCode int `json:"exit_code"` + ScanDurationMs int64 `json:"scan_duration_ms"` +} diff --git a/internal/output/html.go b/internal/output/html.go index cedb602..1f35162 100644 --- a/internal/output/html.go +++ b/internal/output/html.go @@ -11,15 +11,22 @@ import ( ) type htmlData struct { - ScanTime string - Version string - Device model.Device - AITools []model.AITool - IDEInstallations []model.IDE - IDEExtensions []model.Extension - MCPConfigs []model.MCPConfig - NodePkgManagers []model.PkgManager - Summary model.Summary + ScanTime string + Version string + Device model.Device + AITools []model.AITool + IDEInstallations []model.IDE + IDEExtensions []model.Extension + MCPConfigs []model.MCPConfig + NodePkgManagers []model.PkgManager + NodeProjects []model.ProjectInfo + BrewPkgManager *model.PkgManager + BrewFormulae []model.BrewPackage + BrewCasks []model.BrewPackage + PythonPkgManagers []model.PkgManager + PythonPackages []model.PythonPackage + PythonProjects []model.ProjectInfo + Summary model.Summary } func typeLabel(t string) string { @@ -46,20 +53,28 @@ func HTML(outputFile string, result *model.ScanResult) error { scanTime := time.Unix(result.ScanTimestamp, 0).Format("2006-01-02 15:04:05") data := htmlData{ - ScanTime: scanTime, - Version: buildinfo.Version, - Device: result.Device, - AITools: result.AIAgentsAndTools, - IDEInstallations: result.IDEInstallations, - IDEExtensions: result.IDEExtensions, - MCPConfigs: result.MCPConfigs, - NodePkgManagers: result.NodePkgManagers, - Summary: result.Summary, + ScanTime: scanTime, + Version: buildinfo.Version, + Device: result.Device, + AITools: result.AIAgentsAndTools, + IDEInstallations: result.IDEInstallations, + IDEExtensions: result.IDEExtensions, + MCPConfigs: result.MCPConfigs, + NodePkgManagers: result.NodePkgManagers, + NodeProjects: result.NodeProjects, + BrewPkgManager: result.BrewPkgManager, + BrewFormulae: result.BrewFormulae, + BrewCasks: result.BrewCasks, + PythonPkgManagers: result.PythonPkgManagers, + PythonPackages: result.PythonPackages, + PythonProjects: result.PythonProjects, + Summary: result.Summary, } funcMap := template.FuncMap{ "ideDisplayName": ideDisplayName, "typeLabel": typeLabel, + "add": func(a, b int) int { return a + b }, } tmpl, err := template.New("report").Funcs(funcMap).Parse(htmlTemplate) @@ -93,7 +108,7 @@ const htmlTemplate = ` display: flex; gap: 12px; margin-bottom: 28px; flex-wrap: wrap; } .card { - flex: 1; min-width: 140px; background: #fff; border-radius: 10px; + flex: 1; min-width: 120px; background: #fff; border-radius: 10px; padding: 18px 16px; text-align: center; border: 1px solid #e8e0f0; box-shadow: 0 1px 3px rgba(112,55,245,0.06); } @@ -108,14 +123,23 @@ const htmlTemplate = ` .device-grid .field-label { color: #8a94a6; min-width: 90px; font-size: 0.9em; } .device-grid .field-value { font-weight: 500; } .section { margin-bottom: 28px; } - .section h2 { - font-size: 1.1em; color: #7037f5; margin-bottom: 12px; - padding-bottom: 6px; border-bottom: 2px solid #f0ebff; + .section-header { + display: flex; align-items: center; justify-content: space-between; cursor: pointer; + padding-bottom: 6px; border-bottom: 2px solid #f0ebff; margin-bottom: 12px; + user-select: none; } - .section h2 .count { - float: right; background: #f0ebff; color: #7037f5; + .section-header h2 { font-size: 1.1em; color: #7037f5; margin: 0; } + .section-header .count { + background: #f0ebff; color: #7037f5; padding: 2px 10px; border-radius: 10px; font-size: 0.85em; } + .section-header .toggle { + font-size: 1.2em; color: #7037f5; transition: transform 0.2s; + margin-left: 8px; + } + .section-header .toggle.collapsed { transform: rotate(-90deg); } + .section-body { overflow: hidden; transition: max-height 0.3s ease; } + .section-body.collapsed { max-height: 0 !important; overflow: hidden; } table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 10px; overflow: hidden; border: 1px solid #e8e0f0; @@ -131,6 +155,9 @@ const htmlTemplate = ` background: #f0ebff; color: #7037f5; padding: 2px 8px; border-radius: 10px; font-size: 0.8em; } + .project-row { background: #f8f5ff; } + .project-row td { font-weight: 600; color: #7037f5; border-top: 2px solid #e8e0f0; } + .pkg-row td { padding-left: 36px; color: #555; font-size: 0.88em; } .footer { text-align: center; padding: 24px; color: #8a94a6; font-size: 0.85em; border-top: 1px solid #e8e0f0; margin-top: 12px; @@ -142,6 +169,8 @@ const htmlTemplate = ` body { background: #fff; } .header { background: #7037f5; -webkit-print-color-adjust: exact; print-color-adjust: exact; } .card { break-inside: avoid; } + .section-body.collapsed { max-height: none !important; } + .toggle { display: none; } } @media (max-width: 600px) { .summary-cards { flex-direction: column; } @@ -159,11 +188,13 @@ const htmlTemplate = `

Scanned at {{.ScanTime}} · Agent v{{.Version}}

-
{{.Summary.AIAgentsAndToolsCount}}
AI Agents and Tools
-
{{.Summary.IDEInstallationsCount}}
IDEs & Desktop Apps
+
{{.Summary.AIAgentsAndToolsCount}}
AI Agents & Tools
+
{{.Summary.IDEInstallationsCount}}
IDEs & Apps
{{.Summary.IDEExtensionsCount}}
IDE Extensions
{{.Summary.MCPConfigsCount}}
MCP Servers
{{.Summary.NodeProjectsCount}}
Node.js Projects
+
{{add .Summary.BrewFormulaeCount .Summary.BrewCasksCount}}
Brew Packages
+
{{.Summary.PythonProjectsCount}}
Python Venvs
@@ -174,53 +205,157 @@ const htmlTemplate = `
-

AI Agents and Tools {{.Summary.AIAgentsAndToolsCount}}

+
+

AI Agents and Tools {{.Summary.AIAgentsAndToolsCount}}

+ +
+
{{if .AITools}}{{range .AITools}} {{end}}{{else}}{{end}}
NameVersionTypeVendor
{{.Name}}{{.Version}}{{typeLabel .Type}}{{.Vendor}}
None detected
+
-

IDE & AI Desktop Apps {{.Summary.IDEInstallationsCount}}

+
+

IDE & AI Desktop Apps {{.Summary.IDEInstallationsCount}}

+ +
+
{{if .IDEInstallations}}{{range .IDEInstallations}} {{end}}{{else}}{{end}}
NameVersionVendorPath
{{ideDisplayName .IDEType}}{{.Version}}{{.Vendor}}{{.InstallPath}}
None detected
+
-

MCP Servers {{.Summary.MCPConfigsCount}}

+
+

MCP Servers {{.Summary.MCPConfigsCount}}

+ +
+
{{if .MCPConfigs}}{{range .MCPConfigs}} {{end}}{{else}}{{end}}
SourceVendor
{{.ConfigSource}}{{.Vendor}}
None detected
+
-

IDE Extensions {{.Summary.IDEExtensionsCount}}

+
+

IDE Extensions {{.Summary.IDEExtensionsCount}}

+ +
+ +
+ +{{if .NodePkgManagers}} +
+
+

Node.js Projects {{.Summary.NodeProjectsCount}}

+ +
+ +
+{{end}} + +{{if .BrewPkgManager}} +
+
+

Homebrew {{add .Summary.BrewFormulaeCount .Summary.BrewCasksCount}} packages

+ +
+
+{{end}} +{{if .PythonPkgManagers}}
-

Node.js Packages

+
+

Python {{.Summary.PythonProjectsCount}} venvs

+ +
+
- {{if .NodePkgManagers}}{{range .NodePkgManagers}} - {{end}}{{else}}{{end}} + {{range .PythonPkgManagers}} + {{end}}
Package ManagerVersionPath
{{.Name}}{{.Version}}{{.Path}}
No packages found (use --enable-npm-scan)
{{.Name}}{{.Version}}{{.Path}}
+ {{if .PythonPackages}} +

Global Packages ({{len .PythonPackages}})

+ + + {{range .PythonPackages}} + {{end}} +
PackageVersion
{{.Name}}{{.Version}}
+ {{end}} + {{if .PythonProjects}} +

Virtual Environment Projects ({{.Summary.PythonProjectsCount}})

+ + + {{range .PythonProjects}} + {{range .Packages}} + {{end}}{{end}} +
Project PathPMPackages
{{.Path}}{{.PackageManager}}{{len .Packages}}
{{.Name}}{{.Version}}
+ {{end}} +
+{{end}} + ` diff --git a/internal/output/pretty.go b/internal/output/pretty.go index 6c45f09..c168793 100644 --- a/internal/output/pretty.go +++ b/internal/output/pretty.go @@ -55,6 +55,13 @@ func Pretty(w io.Writer, result *model.ScanResult, colorMode string) error { if len(result.NodePkgManagers) > 0 { fmt.Fprintf(w, " %-24s %s%d%s\n", "Node.js Projects", c.green, result.Summary.NodeProjectsCount, c.reset) } + if result.BrewPkgManager != nil { + fmt.Fprintf(w, " %-24s %s%d%s\n", "Homebrew Formulae", c.green, result.Summary.BrewFormulaeCount, c.reset) + fmt.Fprintf(w, " %-24s %s%d%s\n", "Homebrew Casks", c.green, result.Summary.BrewCasksCount, c.reset) + } + if len(result.PythonPkgManagers) > 0 { + fmt.Fprintf(w, " %-24s %s%d%s\n", "Python Projects", c.green, result.Summary.PythonProjectsCount, c.reset) + } fmt.Fprintln(w) // AI AGENTS AND TOOLS @@ -111,18 +118,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 { @@ -139,6 +144,67 @@ func Pretty(w io.Writer, result *model.ScanResult, colorMode string) error { fmt.Fprintln(w) printSectionHeader(w, c, "NODE.JS PROJECTS", result.Summary.NodeProjectsCount) + for _, proj := range result.NodeProjects { + fmt.Fprintf(w, " %s%s%s %s[%s]%s\n", c.bold, proj.Path, c.reset, c.dim, proj.PackageManager, c.reset) + for _, pkg := range proj.Packages { + fmt.Fprintf(w, " %-36s %s%s%s\n", pkg.Name, c.dim, pkg.Version, c.reset) + } + } + fmt.Fprintln(w) + } + + // HOMEBREW (only if brew scan was enabled and brew found) + if result.BrewPkgManager != nil { + fmt.Fprintf(w, " %s%sHOMEBREW%s%*s%sv%s%s\n", + c.purple, c.bold, c.reset, 27, "", c.dim, result.BrewPkgManager.Version, c.reset) + fmt.Fprintln(w) + + if len(result.BrewFormulae) > 0 { + fmt.Fprintf(w, " %s%sFormulae%s%*s%s%d found%s\n", + c.purple, c.bold, c.reset, 25, "", c.green, len(result.BrewFormulae), c.reset) + for _, pkg := range result.BrewFormulae { + fmt.Fprintf(w, " %-36s %s%s%s\n", pkg.Name, c.dim, pkg.Version, c.reset) + } + } else { + fmt.Fprintf(w, " %s%sFormulae%s%*s%s0 found%s\n", + c.purple, c.bold, c.reset, 25, "", c.green, c.reset) + } + fmt.Fprintln(w) + + if len(result.BrewCasks) > 0 { + fmt.Fprintf(w, " %s%sCasks%s%*s%s%d found%s\n", + c.purple, c.bold, c.reset, 28, "", c.green, len(result.BrewCasks), c.reset) + for _, pkg := range result.BrewCasks { + fmt.Fprintf(w, " %-36s %s%s%s\n", pkg.Name, c.dim, pkg.Version, c.reset) + } + } else { + fmt.Fprintf(w, " %s%sCasks%s%*s%s0 found%s\n", + c.purple, c.bold, c.reset, 28, "", c.green, c.reset) + } + fmt.Fprintln(w) + } + + // PYTHON (only if python scan was enabled) + if len(result.PythonPkgManagers) > 0 { + printSectionHeader(w, c, "PYTHON PACKAGE MANAGERS", len(result.PythonPkgManagers)) + for _, pm := range result.PythonPkgManagers { + fmt.Fprintf(w, " %-24s %sv%s%s\n", pm.Name, c.dim, pm.Version, c.reset) + } + fmt.Fprintln(w) + + printSectionHeader(w, c, "PYTHON GLOBAL PACKAGES", len(result.PythonPackages)) + for _, pkg := range result.PythonPackages { + fmt.Fprintf(w, " %-36s %s%s%s\n", pkg.Name, c.dim, pkg.Version, c.reset) + } + fmt.Fprintln(w) + + printSectionHeader(w, c, "PYTHON VENV PROJECTS", result.Summary.PythonProjectsCount) + for _, proj := range result.PythonProjects { + fmt.Fprintf(w, " %s%s%s %s[%s]%s\n", c.bold, proj.Path, c.reset, c.dim, proj.PackageManager, c.reset) + for _, pkg := range proj.Packages { + fmt.Fprintf(w, " %-36s %s%s%s\n", pkg.Name, c.dim, pkg.Version, c.reset) + } + } fmt.Fprintln(w) } @@ -212,6 +278,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/scan/scanner.go b/internal/scan/scanner.go index db7e933..bad67a2 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 { // auto: disabled in community mode var pkgManagers []model.PkgManager - nodeProjectsCount := 0 + var nodeProjects []model.ProjectInfo if npmEnabled { log.StepStart("Detecting package managers") @@ -81,13 +81,70 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { log.StepStart("Scanning Node.js projects") start = time.Now() projectDetector := detector.NewNodeProjectDetector(exec) - nodeProjectsCount = projectDetector.CountProjects(ctx, searchDirs) + nodeProjects = projectDetector.ListProjects(searchDirs) log.StepDone(time.Since(start)) } else { log.StepStart("Node.js package scanning") log.StepSkip("disabled (use --enable-npm-scan to enable)") } + // Homebrew scanning (community mode defaults to off, explicit flag overrides) + brewEnabled := false + if cfg.EnableBrewScan != nil { + brewEnabled = *cfg.EnableBrewScan + } + + var brewPkgManager *model.PkgManager + var brewFormulae []model.BrewPackage + var brewCasks []model.BrewPackage + + if brewEnabled { + log.StepStart("Detecting Homebrew packages") + start = time.Now() + brewDetector := detector.NewBrewDetector(exec) + brewPkgManager = brewDetector.DetectBrew(ctx) + if brewPkgManager != nil { + brewFormulae = brewDetector.ListFormulae(ctx) + brewCasks = brewDetector.ListCasks(ctx) + } + log.StepDone(time.Since(start)) + } else { + log.StepStart("Homebrew package scanning") + log.StepSkip("disabled (use --enable-brew-scan to enable)") + } + + // Python scanning (community mode defaults to off, explicit flag overrides) + pythonEnabled := false + if cfg.EnablePythonScan != nil { + pythonEnabled = *cfg.EnablePythonScan + } + + var pythonPkgManagers []model.PkgManager + var pythonPackages []model.PythonPackage + var pythonProjects []model.ProjectInfo + + if pythonEnabled { + log.StepStart("Detecting Python package managers") + start = time.Now() + pyDetector := detector.NewPythonPMDetector(exec) + pythonPkgManagers = pyDetector.DetectManagers(ctx) + log.StepDone(time.Since(start)) + + log.StepStart("Listing Python packages") + start = time.Now() + pythonPackages = pyDetector.ListPackages(ctx) + log.StepDone(time.Since(start)) + + log.StepStart("Scanning Python projects") + start = time.Now() + pyProjectDetector := detector.NewPythonProjectDetector(exec) + pythonProjects = pyProjectDetector.ListProjects(searchDirs) + log.StepDone(time.Since(start)) + } else { + log.StepStart("Python package scanning") + log.StepSkip("disabled (use --enable-python-scan to enable)") + } + // Ensure no nil slices (JSON must emit [] not null) if aiTools == nil { aiTools = []model.AITool{} @@ -101,27 +158,55 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { if pkgManagers == nil { pkgManagers = []model.PkgManager{} } + if nodeProjects == nil { + nodeProjects = []model.ProjectInfo{} + } + if pythonPkgManagers == nil { + pythonPkgManagers = []model.PkgManager{} + } + if pythonProjects == nil { + pythonProjects = []model.ProjectInfo{} + } + if brewFormulae == nil { + brewFormulae = []model.BrewPackage{} + } + if brewCasks == nil { + brewCasks = []model.BrewPackage{} + } + if pythonPackages == nil { + pythonPackages = []model.PythonPackage{} + } // Build result now := time.Now() result := &model.ScanResult{ - AgentVersion: buildinfo.Version, - AgentURL: buildinfo.AgentURL, - ScanTimestamp: now.Unix(), - ScanTimestampISO: now.UTC().Format(time.RFC3339), - Device: dev, - AIAgentsAndTools: aiTools, - IDEInstallations: ides, - IDEExtensions: extensions, - MCPConfigs: mcpConfigsToCommunity(mcpConfigs), - NodePkgManagers: pkgManagers, - NodePackages: []any{}, + AgentVersion: buildinfo.Version, + AgentURL: buildinfo.AgentURL, + ScanTimestamp: now.Unix(), + ScanTimestampISO: now.UTC().Format(time.RFC3339), + Device: dev, + AIAgentsAndTools: aiTools, + IDEInstallations: ides, + IDEExtensions: extensions, + MCPConfigs: mcpConfigsToCommunity(mcpConfigs), + NodePkgManagers: pkgManagers, + NodePackages: []any{}, + NodeProjects: nodeProjects, + BrewPkgManager: brewPkgManager, + BrewFormulae: brewFormulae, + BrewCasks: brewCasks, + PythonPkgManagers: pythonPkgManagers, + PythonPackages: pythonPackages, + PythonProjects: pythonProjects, Summary: model.Summary{ AIAgentsAndToolsCount: len(aiTools), IDEInstallationsCount: len(ides), IDEExtensionsCount: len(extensions), MCPConfigsCount: len(mcpConfigs), - NodeProjectsCount: nodeProjectsCount, + NodeProjectsCount: len(nodeProjects), + BrewFormulaeCount: len(brewFormulae), + BrewCasksCount: len(brewCasks), + PythonProjectsCount: len(pythonProjects), }, } diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 17cb0e2..d162bd4 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -34,13 +34,18 @@ type Payload struct { CollectedAt int64 `json:"collected_at"` NoUserLoggedIn bool `json:"no_user_logged_in"` - IDEExtensions []model.Extension `json:"ide_extensions"` - IDEInstallations []model.IDE `json:"ide_installations"` - NodePkgManagers []model.PkgManager `json:"node_package_managers"` - NodeGlobalPackages []model.NodeScanResult `json:"node_global_packages"` - NodeProjects []model.NodeScanResult `json:"node_projects"` - AIAgents []model.AITool `json:"ai_agents"` - MCPConfigs []model.MCPConfigEnterprise `json:"mcp_configs"` + IDEExtensions []model.Extension `json:"ide_extensions"` + IDEInstallations []model.IDE `json:"ide_installations"` + NodePkgManagers []model.PkgManager `json:"node_package_managers"` + NodeGlobalPackages []model.NodeScanResult `json:"node_global_packages"` + NodeProjects []model.NodeScanResult `json:"node_projects"` + BrewPkgManager *model.PkgManager `json:"brew_package_manager,omitempty"` + BrewScans []model.BrewScanResult `json:"brew_scans"` + PythonPkgManagers []model.PkgManager `json:"python_package_managers"` + PythonGlobalPackages []model.PythonScanResult `json:"python_global_packages"` + PythonProjects []model.ProjectInfo `json:"python_projects"` + AIAgents []model.AITool `json:"ai_agents"` + MCPConfigs []model.MCPConfigEnterprise `json:"mcp_configs"` ExecutionLogs *ExecutionLogs `json:"execution_logs,omitempty"` PerformanceMetrics *PerformanceMetrics `json:"performance_metrics,omitempty"` @@ -55,10 +60,14 @@ type ExecutionLogs struct { } type PerformanceMetrics struct { - ExtensionsCount int `json:"extensions_count"` - NodePackagesScanMs int64 `json:"node_packages_scan_ms"` - NodeGlobalPkgsCount int `json:"node_global_packages_count"` - NodeProjectsCount int `json:"node_projects_count"` + ExtensionsCount int `json:"extensions_count"` + NodePackagesScanMs int64 `json:"node_packages_scan_ms"` + NodeGlobalPkgsCount int `json:"node_global_packages_count"` + NodeProjectsCount int `json:"node_projects_count"` + BrewFormulaeCount int `json:"brew_formulae_count"` + BrewCasksCount int `json:"brew_casks_count"` + PythonGlobalPkgsCount int `json:"python_global_packages_count"` + PythonProjectsCount int `json:"python_projects_count"` } // Run executes enterprise telemetry: scan, build payload, upload to S3. @@ -111,6 +120,13 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { loggedInUsername = u.Username } + // Create a user-aware executor that delegates commands to the logged-in user + // when running as root. This ensures tools like brew, pip3, npm etc. execute + // in the correct user context (many refuse to run as root or return different + // results). File-based detectors (IDE, extensions, MCP) use the original exec + // since file operations don't need user delegation. + userExec := executor.NewUserAwareExecutor(exec, loggedInUsername) + // Resolve search dirs searchDirs := resolveSearchDirs(exec, cfg.SearchDirs) fmt.Fprintln(os.Stderr) @@ -139,7 +155,7 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { fmt.Fprintln(os.Stderr) log.Progress("Detecting AI CLI tools...") - cliTools := detector.NewAICLIDetector(exec).Detect(ctx) + cliTools := detector.NewAICLIDetector(userExec).Detect(ctx) for _, t := range cliTools { log.Progress(" Found: %s (%s) v%s at %s", t.Name, t.Vendor, t.Version, t.BinaryPath) } @@ -149,7 +165,7 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { fmt.Fprintln(os.Stderr) log.Progress("Detecting general-purpose AI agents...") - agents := detector.NewAgentDetector(exec).Detect(ctx, searchDirs) + agents := detector.NewAgentDetector(userExec).Detect(ctx, searchDirs) for _, a := range agents { log.Progress(" Found: %s (%s) at %s", a.Name, a.Vendor, a.InstallPath) } @@ -159,7 +175,7 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { fmt.Fprintln(os.Stderr) log.Progress("Detecting AI frameworks and runtimes...") - frameworks := detector.NewFrameworkDetector(exec).Detect(ctx) + frameworks := detector.NewFrameworkDetector(userExec).Detect(ctx) for _, f := range frameworks { running := "false" if f.IsRunning != nil && *f.IsRunning { @@ -186,6 +202,80 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { } fmt.Fprintln(os.Stderr) + // Homebrew scanning + brewEnabled := true + if cfg.EnableBrewScan != nil { + brewEnabled = *cfg.EnableBrewScan + } + + var brewPkgMgr *model.PkgManager + var brewScans []model.BrewScanResult + + if brewEnabled { + log.Progress("Detecting Homebrew...") + brewDetector := detector.NewBrewDetector(userExec) + brewPkgMgr = brewDetector.DetectBrew(ctx) + if brewPkgMgr != nil { + log.Progress(" Found: Homebrew v%s at %s", brewPkgMgr.Version, brewPkgMgr.Path) + brewScanner := detector.NewBrewScanner(userExec, log) + if r, ok := brewScanner.ScanFormulae(ctx); ok { + brewScans = append(brewScans, r) + log.Progress(" Formulae scan: exit_code=%d, error=%q, raw_len=%d", r.ExitCode, r.Error, len(r.RawStdoutBase64)) + } else { + log.Progress(" Formulae scan: skipped (brew not in PATH)") + } + if r, ok := brewScanner.ScanCasks(ctx); ok { + brewScans = append(brewScans, r) + log.Progress(" Casks scan: exit_code=%d, error=%q, raw_len=%d", r.ExitCode, r.Error, len(r.RawStdoutBase64)) + } else { + log.Progress(" Casks scan: skipped (brew not in PATH)") + } + log.Progress(" Total brew scans: %d", len(brewScans)) + } else { + log.Progress(" Homebrew not found") + } + fmt.Fprintln(os.Stderr) + } else { + log.Progress("Homebrew scanning is DISABLED") + fmt.Fprintln(os.Stderr) + } + + // Python scanning + pythonEnabled := true + if cfg.EnablePythonScan != nil { + pythonEnabled = *cfg.EnablePythonScan + } + + var pythonPkgManagers []model.PkgManager + var pythonGlobalPkgs []model.PythonScanResult + var pythonProjects []model.ProjectInfo + + if pythonEnabled { + log.Progress("Detecting Python package managers...") + pyDetector := detector.NewPythonPMDetector(userExec) + pythonPkgManagers = pyDetector.DetectManagers(ctx) + for _, pm := range pythonPkgManagers { + log.Progress(" Found: %s v%s at %s", pm.Name, pm.Version, pm.Path) + } + if len(pythonPkgManagers) == 0 { + log.Progress(" No Python package managers found") + } + + log.Progress("Scanning Python global packages...") + pyScanner := detector.NewPythonScanner(userExec, log) + pythonGlobalPkgs = pyScanner.ScanGlobalPackages(ctx) + log.Progress(" Found %d Python global package source(s)", len(pythonGlobalPkgs)) + + log.Progress("Searching for Python projects...") + pyProjectDetector := detector.NewPythonProjectDetector(exec) + pythonProjects = pyProjectDetector.ListProjects(searchDirs) + log.Progress(" Found %d Python projects", len(pythonProjects)) + fmt.Fprintln(os.Stderr) + } else { + log.Progress("Python scanning is DISABLED") + fmt.Fprintln(os.Stderr) + } + // Node.js scanning npmEnabled := true if cfg.EnableNPMScan != nil { @@ -201,7 +291,7 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { log.Progress("Node.js package scanning is ENABLED") log.Progress("Detecting Node.js package managers...") - npmDetector := detector.NewNodePMDetector(exec) + npmDetector := detector.NewNodePMDetector(userExec) pkgManagers = npmDetector.DetectManagers(ctx) for _, pm := range pkgManagers { log.Progress(" Found: %s v%s at %s", pm.Name, pm.Version, pm.Path) @@ -232,6 +322,18 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { if nodeProjects == nil { nodeProjects = []model.NodeScanResult{} } + if brewScans == nil { + brewScans = []model.BrewScanResult{} + } + if pythonPkgManagers == nil { + pythonPkgManagers = []model.PkgManager{} + } + if pythonGlobalPkgs == nil { + pythonGlobalPkgs = []model.PythonScanResult{} + } + if pythonProjects == nil { + pythonProjects = []model.ProjectInfo{} + } // Finalize execution logs before building payload execLogsBase64 := capture.Finalize() @@ -250,13 +352,18 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { CollectedAt: endTime.Unix(), NoUserLoggedIn: dev.UserIdentity == "" || dev.UserIdentity == "unknown", - IDEExtensions: extensions, - IDEInstallations: ides, - NodePkgManagers: pkgManagers, - NodeGlobalPackages: globalPkgs, - NodeProjects: nodeProjects, - AIAgents: allAI, - MCPConfigs: mcpConfigs, + IDEExtensions: extensions, + IDEInstallations: ides, + NodePkgManagers: pkgManagers, + NodeGlobalPackages: globalPkgs, + NodeProjects: nodeProjects, + BrewPkgManager: brewPkgMgr, + BrewScans: brewScans, + PythonPkgManagers: pythonPkgManagers, + PythonGlobalPackages: pythonGlobalPkgs, + PythonProjects: pythonProjects, + AIAgents: allAI, + MCPConfigs: mcpConfigs, ExecutionLogs: &ExecutionLogs{ OutputBase64: execLogsBase64, @@ -267,10 +374,14 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { }, PerformanceMetrics: &PerformanceMetrics{ - ExtensionsCount: len(extensions), - NodePackagesScanMs: nodeScanMs, - NodeGlobalPkgsCount: len(globalPkgs), - NodeProjectsCount: len(nodeProjects), + ExtensionsCount: len(extensions), + NodePackagesScanMs: nodeScanMs, + NodeGlobalPkgsCount: len(globalPkgs), + NodeProjectsCount: len(nodeProjects), + BrewFormulaeCount: brewFormulaeCount(brewScans), + BrewCasksCount: brewCasksCount(brewScans), + PythonGlobalPkgsCount: len(pythonGlobalPkgs), + PythonProjectsCount: len(pythonProjects), }, } @@ -285,6 +396,24 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { return nil } +func brewFormulaeCount(scans []model.BrewScanResult) int { + for _, s := range scans { + if s.ScanType == "formulae" { + return s.LineCount + } + } + return 0 +} + +func brewCasksCount(scans []model.BrewScanResult) int { + for _, s := range scans { + if s.ScanType == "casks" { + return s.LineCount + } + } + return 0 +} + func uploadToS3(ctx context.Context, log *progress.Logger, payload *Payload) error { payloadJSON, err := json.Marshal(payload) if err != nil { @@ -326,26 +455,59 @@ func uploadToS3(ctx context.Context, log *progress.Logger, payload *Payload) err return fmt.Errorf("empty upload URL in response") } - // Upload payload to S3 - log.Progress("Uploading telemetry to S3...") - putReq, err := http.NewRequestWithContext(ctx, http.MethodPut, urlResp.UploadURL, bytes.NewReader(payloadJSON)) - if err != nil { - return fmt.Errorf("creating S3 PUT request: %w", err) - } - putReq.Header.Set("Content-Type", "application/json") + // Upload payload to S3 with retry — use a longer timeout since payloads + // with npm scan data and execution logs can be several MB. + log.Progress("Uploading telemetry to S3 (%d bytes)...", len(payloadJSON)) + s3Client := &http.Client{Timeout: 60 * time.Second} + const maxRetries = 3 + var putResp *http.Response + for attempt := 1; attempt <= maxRetries; attempt++ { + uploadStart := time.Now() + putReq, reqErr := http.NewRequestWithContext(ctx, http.MethodPut, urlResp.UploadURL, bytes.NewReader(payloadJSON)) + if reqErr != nil { + return fmt.Errorf("creating S3 PUT request: %w", reqErr) + } + putReq.Header.Set("Content-Type", "application/json") - putResp, err := client.Do(putReq) - if err != nil { - return fmt.Errorf("uploading to S3: %w", err) + putResp, err = s3Client.Do(putReq) + elapsed := time.Since(uploadStart) + + if err == nil && putResp.StatusCode == http.StatusOK { + log.Progress("Uploaded to S3 in %s", elapsed) + break + } + + // Clean up response body before retry + if putResp != nil { + _, _ = io.Copy(io.Discard, putResp.Body) + _ = putResp.Body.Close() + } + + if attempt == maxRetries { + if err != nil { + return fmt.Errorf("uploading to S3 (payload: %d bytes, elapsed: %s, attempts: %d): %w", + len(payloadJSON), elapsed, maxRetries, err) + } + return fmt.Errorf("S3 upload failed with status %d (payload: %d bytes, attempts: %d)", + putResp.StatusCode, len(payloadJSON), maxRetries) + } + + // Log retry and backoff + backoff := time.Duration(attempt) * 2 * time.Second + if err != nil { + log.Progress("S3 upload attempt %d/%d failed after %s: %v; retrying in %s...", attempt, maxRetries, elapsed, err, backoff) + } else { + log.Progress("S3 upload attempt %d/%d got status %d, retrying in %s...", attempt, maxRetries, putResp.StatusCode, backoff) + } + select { + case <-time.After(backoff): + case <-ctx.Done(): + return ctx.Err() + } } defer func() { _ = putResp.Body.Close() }() _, _ = io.Copy(io.Discard, putResp.Body) - if putResp.StatusCode != http.StatusOK { - return fmt.Errorf("S3 upload failed with status %d", putResp.StatusCode) - } - log.Progress("Uploaded to S3") - // Notify backend log.Progress("Notifying backend of upload...") notifyBody, _ := json.Marshal(map[string]string{ @@ -409,6 +571,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 }