diff --git a/.gitignore b/.gitignore index e50978c..37c778e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ .vscode/ .idea/ .claude/ +.plans/ # Output files *.log @@ -17,8 +18,9 @@ !docs/**/*.html !images/**/*.html -# Go build artifacts -/stepsecurity-dev-machine-guard +# Go build artifacts — never commit compiled binaries +**/stepsecurity-dev-machine-guard +*.exe dist/ # Temporary files diff --git a/Makefile b/Makefile index 3f99b0a..d65293f 100644 --- a/Makefile +++ b/Makefile @@ -9,11 +9,17 @@ LDFLAGS := -s -w \ -X $(MODULE)/internal/buildinfo.ReleaseTag=$(TAG) \ -X $(MODULE)/internal/buildinfo.ReleaseBranch=$(BRANCH) -.PHONY: build test lint clean smoke +.PHONY: build build-windows deploy-windows test lint clean smoke build: go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/stepsecurity-dev-machine-guard +build-windows: + GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY).exe ./cmd/stepsecurity-dev-machine-guard + +deploy-windows: + @bash scripts/deploy-windows.sh $(DEPLOY_ARGS) + test: go test ./... -v -race -count=1 diff --git a/cmd/stepsecurity-dev-machine-guard/main.go b/cmd/stepsecurity-dev-machine-guard/main.go index 916d038..645db8c 100644 --- a/cmd/stepsecurity-dev-machine-guard/main.go +++ b/cmd/stepsecurity-dev-machine-guard/main.go @@ -3,6 +3,7 @@ package main import ( "fmt" "os" + "runtime" "github.com/step-security/dev-machine-guard/internal/buildinfo" "github.com/step-security/dev-machine-guard/internal/cli" @@ -83,7 +84,11 @@ func main() { } case "install": - fmt.Fprintf(os.Stdout, "StepSecurity Dev Machine Guard v%s\n\n", buildinfo.Version) + _, _ = fmt.Fprintf(os.Stdout, "StepSecurity Dev Machine Guard v%s\n\n", buildinfo.Version) + if runtime.GOOS == "windows" { + log.Error("Scheduled scanning is not yet supported on Windows. Use the scan command directly.") + os.Exit(1) + } if !config.IsEnterpriseMode() { log.Error("Enterprise configuration not found. Run '%s configure' or download the script from your StepSecurity dashboard.", os.Args[0]) os.Exit(1) @@ -100,7 +105,11 @@ func main() { } case "uninstall": - fmt.Fprintf(os.Stdout, "StepSecurity Dev Machine Guard v%s\n\n", buildinfo.Version) + _, _ = fmt.Fprintf(os.Stdout, "StepSecurity Dev Machine Guard v%s\n\n", buildinfo.Version) + if runtime.GOOS == "windows" { + log.Error("Scheduled scanning is not yet supported on Windows. Use the scan command directly.") + os.Exit(1) + } if err := launchd.Uninstall(exec, log); err != nil { log.Error("%v", err) os.Exit(1) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 7953d37..3c7ceaf 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -93,13 +93,13 @@ func Parse(args []string) (*Config, error) { case arg == "--verbose": cfg.Verbose = true case arg == "-v" || arg == "--version" || arg == "version": - fmt.Fprintf(os.Stdout, "StepSecurity Dev Machine Guard v%s\n", buildinfo.VersionString()) + _, _ = fmt.Fprintf(os.Stdout, "StepSecurity Dev Machine Guard v%s\n", buildinfo.VersionString()) os.Exit(0) case arg == "-h" || arg == "--help" || arg == "help": printHelp() os.Exit(0) default: - return nil, fmt.Errorf("Unknown option: %s\nRun '%s --help' for usage information.", arg, filepath.Base(os.Args[0])) + return nil, fmt.Errorf("unknown option: %s, run '%s --help' for usage information", arg, filepath.Base(os.Args[0])) } i++ } @@ -109,7 +109,7 @@ func Parse(args []string) (*Config, error) { func printHelp() { name := filepath.Base(os.Args[0]) - fmt.Fprintf(os.Stdout, `StepSecurity Dev Machine Guard v%s + _, _ = fmt.Fprintf(os.Stdout, `StepSecurity Dev Machine Guard v%s Usage: %s [COMMAND] [OPTIONS] diff --git a/internal/detector/agent.go b/internal/detector/agent.go index b5efcd8..6007894 100644 --- a/internal/detector/agent.go +++ b/internal/detector/agent.go @@ -2,6 +2,7 @@ package detector import ( "context" + "path/filepath" "regexp" "strconv" "strings" @@ -67,7 +68,7 @@ func (d *AgentDetector) Detect(ctx context.Context, searchDirs []string) []model func (d *AgentDetector) findAgent(spec agentSpec, homeDir string) (string, bool) { // Check detection paths for _, relPath := range spec.DetectionPaths { - fullPath := homeDir + "/" + relPath + fullPath := filepath.Join(homeDir, relPath) if d.exec.DirExists(fullPath) || d.exec.FileExists(fullPath) { return fullPath, true } @@ -103,12 +104,23 @@ func (d *AgentDetector) getVersion(ctx context.Context, spec agentSpec) string { // detectClaudeCowork checks for Claude Cowork (a mode within Claude Desktop 0.7+). func (d *AgentDetector) detectClaudeCowork(ctx context.Context) (model.AITool, bool) { - claudePath := "/Applications/Claude.app" - if !d.exec.DirExists(claudePath) { - return model.AITool{}, false + var claudePath, version string + + if d.exec.GOOS() == "windows" { + localAppData := d.exec.Getenv("LOCALAPPDATA") + claudePath = filepath.Join(localAppData, "Programs", "Claude") + if !d.exec.DirExists(claudePath) { + return model.AITool{}, false + } + version = readRegistryVersion(ctx, d.exec, "Claude") + } else { + claudePath = "/Applications/Claude.app" + if !d.exec.DirExists(claudePath) { + return model.AITool{}, false + } + version = readPlistVersion(ctx, d.exec, filepath.Join(claudePath, "Contents", "Info.plist")) } - version := readPlistVersion(ctx, d.exec, claudePath+"/Contents/Info.plist") if version == "unknown" { return model.AITool{}, false } diff --git a/internal/detector/agent_test.go b/internal/detector/agent_test.go index 9a35bbc..949e368 100644 --- a/internal/detector/agent_test.go +++ b/internal/detector/agent_test.go @@ -96,3 +96,45 @@ func TestIsCoworkVersion(t *testing.T) { } } } + +func TestAgentDetector_Windows_ClaudeCowork(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + mock.SetHomeDir(`C:\Users\testuser`) + mock.SetEnv("LOCALAPPDATA", `C:\Users\testuser\AppData\Local`) + + // detectClaudeCowork on Windows uses filepath.Join(localAppData, "Programs", "Claude"). + // On macOS host, filepath.Join keeps backslashes and inserts "/": + claudePath := `C:\Users\testuser\AppData\Local` + "/Programs/Claude" + mock.SetDir(claudePath) + + // Version via readRegistryVersion with appName "Claude". + // First registry root tried by readRegistryVersion. + mock.SetCommand( + "HKLM\\SOFTWARE\\...\\Claude\n DisplayVersion REG_SZ 0.7.5\n", + "", 0, + "reg", "query", `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`, "/s", "/f", "Claude", "/d", + ) + + det := NewAgentDetector(mock) + results := det.Detect(context.Background(), []string{`C:\Users\testuser`}) + + found := false + for _, r := range results { + if r.Name == "claude-cowork" { + found = true + if r.Vendor != "Anthropic" { + t.Errorf("expected Anthropic, got %s", r.Vendor) + } + if r.Version != "0.7.5" { + t.Errorf("expected 0.7.5, got %s", r.Version) + } + if r.InstallPath != claudePath { + t.Errorf("expected install path %s, got %s", claudePath, r.InstallPath) + } + } + } + if !found { + t.Error("claude-cowork not found") + } +} diff --git a/internal/detector/aicli.go b/internal/detector/aicli.go index 9ae34d6..a02b266 100644 --- a/internal/detector/aicli.go +++ b/internal/detector/aicli.go @@ -2,6 +2,8 @@ package detector import ( "context" + "os" + "path/filepath" "strings" "time" @@ -121,11 +123,17 @@ func (d *AICLIDetector) Detect(ctx context.Context) []model.AITool { func (d *AICLIDetector) findBinary(ctx context.Context, spec cliToolSpec, homeDir string) (string, bool) { for _, bin := range spec.Binaries { expanded := expandTilde(bin, homeDir) - if strings.Contains(expanded, "/") { - // Absolute/relative path - check if it exists + if expanded != bin { + // Path was expanded from tilde — it's a home-relative path, check if it exists if d.exec.FileExists(expanded) { return expanded, true } + // On Windows, also try with .exe suffix + if d.exec.GOOS() == "windows" && !strings.HasSuffix(expanded, ".exe") { + if d.exec.FileExists(expanded + ".exe") { + return expanded + ".exe", true + } + } continue } // Search in PATH @@ -149,12 +157,25 @@ func (d *AICLIDetector) getVersion(ctx context.Context, spec cliToolSpec, binary if len(lines) > 0 { v := strings.TrimSpace(lines[0]) if v != "" { - return v + return cleanVersionString(v) } } return "unknown" } +// cleanVersionString strips a leading tool name prefix from version output. +// e.g. "codex-cli 0.118.0" -> "0.118.0", "aider 0.86.2" -> "0.86.2" +func cleanVersionString(v string) string { + parts := strings.Fields(v) + for _, p := range parts { + trimmed := strings.TrimLeft(p, "v") + if len(trimmed) > 0 && trimmed[0] >= '0' && trimmed[0] <= '9' { + return p + } + } + return v +} + func (d *AICLIDetector) findConfigDir(spec cliToolSpec, homeDir string) string { for _, dir := range spec.ConfigDirs { expanded := expandTilde(dir, homeDir) @@ -167,7 +188,7 @@ func (d *AICLIDetector) findConfigDir(spec cliToolSpec, homeDir string) string { func expandTilde(path, homeDir string) string { if strings.HasPrefix(path, "~/") { - return homeDir + path[1:] + return filepath.Join(homeDir, filepath.FromSlash(path[2:])) } return path } @@ -175,7 +196,22 @@ func expandTilde(path, homeDir string) string { func getHomeDir(exec executor.Executor) string { u, err := exec.CurrentUser() if err != nil { - return "/tmp" + return os.TempDir() } return u.HomeDir } + +// resolveEnvPath replaces %ENVVAR% patterns in Windows-style paths using the executor. +func resolveEnvPath(exec executor.Executor, path string) string { + for strings.Contains(path, "%") { + start := strings.Index(path, "%") + end := strings.Index(path[start+1:], "%") + if end < 0 { + break + } + envName := path[start+1 : start+1+end] + envVal := exec.Getenv(envName) + path = path[:start] + envVal + path[start+2+end:] + } + return filepath.FromSlash(path) +} diff --git a/internal/detector/extension.go b/internal/detector/extension.go index c27715a..cd00c11 100644 --- a/internal/detector/extension.go +++ b/internal/detector/extension.go @@ -3,6 +3,7 @@ package detector import ( "context" "encoding/json" + "path/filepath" "strings" "github.com/step-security/dev-machine-guard/internal/executor" @@ -78,7 +79,7 @@ func (d *ExtensionDetector) collectFromDir(extDir, ideType string) []model.Exten } // Get install date from directory modification time - info, err := d.exec.Stat(extDir + "/" + dirname) + info, err := d.exec.Stat(filepath.Join(extDir, dirname)) if err == nil { ext.InstallDate = info.ModTime().Unix() } @@ -130,7 +131,7 @@ func parseExtensionDir(dirname, ideType string) *model.Extension { // loadObsolete reads the .obsolete file and returns a set of dirname -> true. func (d *ExtensionDetector) loadObsolete(extDir string) map[string]bool { - obsoleteFile := extDir + "/.obsolete" + obsoleteFile := filepath.Join(extDir, ".obsolete") data, err := d.exec.ReadFile(obsoleteFile) if err != nil { return nil diff --git a/internal/detector/framework.go b/internal/detector/framework.go index 5482564..b9dd4a8 100644 --- a/internal/detector/framework.go +++ b/internal/detector/framework.go @@ -2,6 +2,7 @@ package detector import ( "context" + "path/filepath" "strings" "time" @@ -41,7 +42,7 @@ func (d *FrameworkDetector) Detect(ctx context.Context) []model.AITool { } version := d.getVersion(ctx, binaryPath) - isRunning := d.isProcessRunning(ctx, spec.ProcessName) + isRunning := isProcessRunning(ctx, d.exec, spec.ProcessName) results = append(results, model.AITool{ Name: spec.Name, @@ -86,31 +87,32 @@ func (d *FrameworkDetector) getVersion(ctx context.Context, binaryPath string) s return "unknown" } -func (d *FrameworkDetector) isProcessRunning(ctx context.Context, processName string) bool { - _, _, exitCode, _ := d.exec.Run(ctx, "pgrep", "-x", processName) - return exitCode == 0 -} - func (d *FrameworkDetector) detectLMStudioApp(ctx context.Context) (model.AITool, bool) { - appPath := "/Applications/LM Studio.app" - if !d.exec.DirExists(appPath) { - return model.AITool{}, false - } - - version := readPlistVersion(ctx, d.exec, appPath+"/Contents/Info.plist") + var appPath, version string - isRunning := false - _, _, exitCode, _ := d.exec.Run(ctx, "pgrep", "-f", "LM Studio") - if exitCode == 0 { - isRunning = true + if d.exec.GOOS() == "windows" { + localAppData := d.exec.Getenv("LOCALAPPDATA") + appPath = filepath.Join(localAppData, "Programs", "LM Studio") + if !d.exec.DirExists(appPath) { + return model.AITool{}, false + } + version = readRegistryVersion(ctx, d.exec, "LM Studio") + } else { + appPath = "/Applications/LM Studio.app" + if !d.exec.DirExists(appPath) { + return model.AITool{}, false + } + version = readPlistVersion(ctx, d.exec, filepath.Join(appPath, "Contents", "Info.plist")) } + running := isProcessRunningFuzzy(ctx, d.exec, "LM Studio") + return model.AITool{ Name: "lm-studio", Vendor: "LM Studio", Type: "framework", Version: version, BinaryPath: appPath, - IsRunning: &isRunning, + IsRunning: &running, }, true } diff --git a/internal/detector/framework_test.go b/internal/detector/framework_test.go index 8c519a9..9ce4d91 100644 --- a/internal/detector/framework_test.go +++ b/internal/detector/framework_test.go @@ -74,3 +74,45 @@ func TestFrameworkDetector_LMStudioApp(t *testing.T) { t.Error("lm-studio not found") } } + +func TestFrameworkDetector_Windows_FindsOllama(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + mock.SetPath("ollama", `C:\Program Files\Ollama\ollama.exe`) + + mock.SetCommand("0.5.4\n", "", 0, `C:\Program Files\Ollama\ollama.exe`, "--version") + + // isProcessRunning on Windows: tasklist /FI "IMAGENAME eq ollama.exe" /NH + mock.SetCommand( + "ollama.exe 12345 Console 1 100,000 K\n", + "", 0, + "tasklist", "/FI", "IMAGENAME eq ollama.exe", "/NH", + ) + + // LM Studio app detection on Windows also runs; ensure it doesn't interfere. + // detectLMStudioApp will try Getenv("LOCALAPPDATA") which is empty, so DirExists will fail. + // isProcessRunningFuzzy on Windows calls tasklist /NH + mock.SetCommand("", "", 1, "tasklist", "/NH") + + det := NewFrameworkDetector(mock) + results := det.Detect(context.Background()) + + found := false + for _, r := range results { + if r.Name == "ollama" { + found = true + if r.Type != "framework" { + t.Errorf("expected framework, got %s", r.Type) + } + if r.Version != "0.5.4" { + t.Errorf("expected 0.5.4, got %s", r.Version) + } + if r.IsRunning == nil || !*r.IsRunning { + t.Error("expected is_running=true") + } + } + } + if !found { + t.Error("ollama not found") + } +} diff --git a/internal/detector/ide.go b/internal/detector/ide.go index 975eb26..d3e3197 100644 --- a/internal/detector/ide.go +++ b/internal/detector/ide.go @@ -2,6 +2,7 @@ package detector import ( "context" + "path/filepath" "strings" "time" @@ -10,22 +11,56 @@ import ( ) type ideSpec struct { - AppName string - IDEType string - Vendor string - AppPath string - BinaryPath string // relative to AppPath - VersionFlag string + AppName string + IDEType string + Vendor string + AppPath string // macOS: /Applications/X.app + BinaryPath string // macOS: relative to AppPath + WinPaths []string // Windows: candidate install dirs (may contain %ENVVAR%) + WinBinary string // Windows: binary relative to install dir + VersionFlag string } var ideDefinitions = []ideSpec{ - {"Visual Studio Code", "vscode", "Microsoft", "/Applications/Visual Studio Code.app", "Contents/Resources/app/bin/code", "--version"}, - {"Cursor", "cursor", "Cursor", "/Applications/Cursor.app", "Contents/Resources/app/bin/cursor", "--version"}, - {"Windsurf", "windsurf", "Codeium", "/Applications/Windsurf.app", "Contents/MacOS/Windsurf", "--version"}, - {"Antigravity", "antigravity", "Google", "/Applications/Antigravity.app", "Contents/MacOS/Antigravity", "--version"}, - {"Zed", "zed", "Zed", "/Applications/Zed.app", "Contents/MacOS/zed", ""}, - {"Claude", "claude_desktop", "Anthropic", "/Applications/Claude.app", "", ""}, - {"Microsoft Copilot", "microsoft_copilot_desktop", "Microsoft", "/Applications/Copilot.app", "", ""}, + { + AppName: "Visual Studio Code", IDEType: "vscode", Vendor: "Microsoft", + AppPath: "/Applications/Visual Studio Code.app", BinaryPath: "Contents/Resources/app/bin/code", + WinPaths: []string{`%PROGRAMFILES%\Microsoft VS Code`, `%LOCALAPPDATA%\Programs\Microsoft VS Code`}, WinBinary: `bin\code.cmd`, + VersionFlag: "--version", + }, + { + AppName: "Cursor", IDEType: "cursor", Vendor: "Cursor", + AppPath: "/Applications/Cursor.app", BinaryPath: "Contents/Resources/app/bin/cursor", + WinPaths: []string{`%LOCALAPPDATA%\Programs\cursor`}, WinBinary: "Cursor.exe", + VersionFlag: "--version", + }, + { + AppName: "Windsurf", IDEType: "windsurf", Vendor: "Codeium", + AppPath: "/Applications/Windsurf.app", BinaryPath: "Contents/MacOS/Windsurf", + WinPaths: []string{`%LOCALAPPDATA%\Programs\Windsurf`}, WinBinary: "Windsurf.exe", + VersionFlag: "--version", + }, + { + AppName: "Antigravity", IDEType: "antigravity", Vendor: "Google", + AppPath: "/Applications/Antigravity.app", BinaryPath: "Contents/MacOS/Antigravity", + WinPaths: []string{`%LOCALAPPDATA%\Programs\Antigravity`}, WinBinary: "Antigravity.exe", + VersionFlag: "--version", + }, + { + AppName: "Zed", IDEType: "zed", Vendor: "Zed", + AppPath: "/Applications/Zed.app", BinaryPath: "Contents/MacOS/zed", + WinPaths: []string{`%LOCALAPPDATA%\Zed`}, WinBinary: "zed.exe", + }, + { + AppName: "Claude", IDEType: "claude_desktop", Vendor: "Anthropic", + AppPath: "/Applications/Claude.app", + WinPaths: []string{`%LOCALAPPDATA%\Programs\Claude`}, + }, + { + AppName: "Microsoft Copilot", IDEType: "microsoft_copilot_desktop", Vendor: "Microsoft", + AppPath: "/Applications/Copilot.app", + WinPaths: []string{`%LOCALAPPDATA%\Programs\Copilot`}, + }, } // IDEDetector detects installed IDEs and AI desktop apps. @@ -41,47 +76,93 @@ func (d *IDEDetector) Detect(ctx context.Context) []model.IDE { var results []model.IDE for _, spec := range ideDefinitions { - if !d.exec.DirExists(spec.AppPath) { + if d.exec.GOOS() == "windows" { + if ide, ok := d.detectWindows(ctx, spec); ok { + results = append(results, ide) + } + } else { + if ide, ok := d.detectDarwin(ctx, spec); ok { + results = append(results, ide) + } + } + } + + return results +} + +func (d *IDEDetector) detectDarwin(ctx context.Context, spec ideSpec) (model.IDE, bool) { + if !d.exec.DirExists(spec.AppPath) { + return model.IDE{}, false + } + + version := "unknown" + + // Try version from binary + if spec.BinaryPath != "" && spec.VersionFlag != "" { + binaryFull := filepath.Join(spec.AppPath, spec.BinaryPath) + if d.exec.FileExists(binaryFull) { + version = runVersionCmd(ctx, d.exec, binaryFull, spec.VersionFlag) + } + } + + // Fallback: Info.plist + if version == "unknown" { + version = readPlistVersion(ctx, d.exec, filepath.Join(spec.AppPath, "Contents", "Info.plist")) + } + + return model.IDE{ + IDEType: spec.IDEType, Version: version, InstallPath: spec.AppPath, + Vendor: spec.Vendor, IsInstalled: true, + }, true +} + +func (d *IDEDetector) detectWindows(ctx context.Context, spec ideSpec) (model.IDE, bool) { + for _, winPath := range spec.WinPaths { + resolved := resolveEnvPath(d.exec, winPath) + if !d.exec.DirExists(resolved) { continue } version := "unknown" // Try version from binary - if spec.BinaryPath != "" && spec.VersionFlag != "" { - binaryFull := spec.AppPath + "/" + spec.BinaryPath + if spec.WinBinary != "" && spec.VersionFlag != "" { + binaryFull := filepath.Join(resolved, spec.WinBinary) if d.exec.FileExists(binaryFull) { - stdout, _, _, err := d.exec.RunWithTimeout(ctx, 10*time.Second, binaryFull, spec.VersionFlag) - if err == nil { - lines := strings.SplitN(stdout, "\n", 2) - if len(lines) > 0 { - v := strings.TrimSpace(lines[0]) - if v != "" { - version = v - } - } - } + version = runVersionCmd(ctx, d.exec, binaryFull, spec.VersionFlag) } } - // Fallback: Info.plist + // Fallback: registry if version == "unknown" { - version = readPlistVersion(ctx, d.exec, spec.AppPath+"/Contents/Info.plist") + version = readRegistryVersion(ctx, d.exec, spec.AppName) } - results = append(results, model.IDE{ - IDEType: spec.IDEType, - Version: version, - InstallPath: spec.AppPath, - Vendor: spec.Vendor, - IsInstalled: true, - }) + return model.IDE{ + IDEType: spec.IDEType, Version: version, InstallPath: resolved, + Vendor: spec.Vendor, IsInstalled: true, + }, true } + return model.IDE{}, false +} - return results +// runVersionCmd runs a binary with a version flag and extracts the first line. +func runVersionCmd(ctx context.Context, exec executor.Executor, binary, flag string) string { + stdout, _, _, err := exec.RunWithTimeout(ctx, 10*time.Second, binary, flag) + if err != nil { + return "unknown" + } + lines := strings.SplitN(stdout, "\n", 2) + if len(lines) > 0 { + v := strings.TrimSpace(lines[0]) + if v != "" { + return v + } + } + return "unknown" } -// readPlistVersion reads CFBundleShortVersionString from an Info.plist. +// readPlistVersion reads CFBundleShortVersionString from an Info.plist (macOS). func readPlistVersion(ctx context.Context, exec executor.Executor, plistPath string) string { if !exec.FileExists(plistPath) { return "unknown" @@ -95,3 +176,28 @@ func readPlistVersion(ctx context.Context, exec executor.Executor, plistPath str } return "unknown" } + +// readRegistryVersion searches Windows Uninstall registry keys for DisplayVersion. +func readRegistryVersion(ctx context.Context, exec executor.Executor, appName string) string { + for _, root := range []string{ + `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`, + `HKLM\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall`, + `HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`, + } { + stdout, _, _, err := exec.Run(ctx, "reg", "query", root, "/s", "/f", appName, "/d") + if err != nil { + continue + } + // Parse "DisplayVersion REG_SZ x.y.z" from reg query output + for _, line := range strings.Split(stdout, "\n") { + line = strings.TrimSpace(line) + if strings.Contains(line, "DisplayVersion") { + parts := strings.Fields(line) + if len(parts) >= 3 { + return parts[len(parts)-1] + } + } + } + } + return "unknown" +} diff --git a/internal/detector/ide_test.go b/internal/detector/ide_test.go index 2178d5f..b8de1bd 100644 --- a/internal/detector/ide_test.go +++ b/internal/detector/ide_test.go @@ -77,3 +77,81 @@ func TestIDEDetector_MultipleIDEs(t *testing.T) { t.Fatalf("expected 2 IDEs, got %d", len(results)) } } + +func TestIDEDetector_Windows_FindsVSCode(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + mock.SetEnv("LOCALAPPDATA", `C:\Users\testuser\AppData\Local`) + mock.SetEnv("PROGRAMFILES", `C:\Program Files`) + + // resolveEnvPath("%PROGRAMFILES%\Microsoft VS Code") on macOS produces + // the backslash-containing path since filepath.FromSlash is a no-op. + vscodePath := `C:\Program Files\Microsoft VS Code` + mock.SetDir(vscodePath) + + // filepath.Join on macOS uses "/" between parts, keeps existing backslashes. + binaryPath := vscodePath + `/bin\code.cmd` + mock.SetFile(binaryPath, []byte{}) + mock.SetCommand("1.96.0\n1234abcd\nx64", "", 0, binaryPath, "--version") + + det := NewIDEDetector(mock) + results := det.Detect(context.Background()) + + if len(results) != 1 { + t.Fatalf("expected 1 IDE, got %d", len(results)) + } + if results[0].IDEType != "vscode" { + t.Errorf("expected vscode, got %s", results[0].IDEType) + } + if results[0].Version != "1.96.0" { + t.Errorf("expected 1.96.0, got %s", results[0].Version) + } + if results[0].Vendor != "Microsoft" { + t.Errorf("expected Microsoft, got %s", results[0].Vendor) + } + if !results[0].IsInstalled { + t.Error("expected is_installed=true") + } + if results[0].InstallPath != vscodePath { + t.Errorf("expected install path %s, got %s", vscodePath, results[0].InstallPath) + } +} + +func TestIDEDetector_Windows_FindsClaude(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + mock.SetEnv("LOCALAPPDATA", `C:\Users\testuser\AppData\Local`) + + // resolveEnvPath("%LOCALAPPDATA%\Programs\Claude") on macOS: + // result is "C:\Users\testuser\AppData\Local\Programs\Claude" + // (all backslashes since the spec uses backslashes throughout) + claudePath := `C:\Users\testuser\AppData\Local\Programs\Claude` + mock.SetDir(claudePath) + + // Claude has no WinBinary, so version falls back to readRegistryVersion. + // Registry query tries multiple roots; succeed on the first one. + mock.SetCommand( + "HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Claude\n DisplayVersion REG_SZ 0.8.2\n", + "", 0, + "reg", "query", `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`, "/s", "/f", "Claude", "/d", + ) + + det := NewIDEDetector(mock) + results := det.Detect(context.Background()) + + if len(results) != 1 { + t.Fatalf("expected 1 IDE, got %d", len(results)) + } + if results[0].IDEType != "claude_desktop" { + t.Errorf("expected claude_desktop, got %s", results[0].IDEType) + } + if results[0].Version != "0.8.2" { + t.Errorf("expected 0.8.2, got %s", results[0].Version) + } + if results[0].Vendor != "Anthropic" { + t.Errorf("expected Anthropic, got %s", results[0].Vendor) + } + if !results[0].IsInstalled { + t.Error("expected is_installed=true") + } +} diff --git a/internal/detector/mcp.go b/internal/detector/mcp.go index b6e1f6e..a7379ca 100644 --- a/internal/detector/mcp.go +++ b/internal/detector/mcp.go @@ -11,21 +11,22 @@ import ( ) type mcpConfigSpec struct { - SourceName string - ConfigPath string // relative to home; uses ~ prefix - Vendor string + SourceName string + ConfigPath string // macOS/Unix path (~/... expanded) + WinConfigPath string // Windows path (%ENVVAR%/... expanded); empty means same as ConfigPath + Vendor string } var mcpConfigDefinitions = []mcpConfigSpec{ - {"claude_desktop", "~/Library/Application Support/Claude/claude_desktop_config.json", "Anthropic"}, - {"claude_code", "~/.claude/settings.json", "Anthropic"}, - {"claude_code", "~/.claude.json", "Anthropic"}, - {"cursor", "~/.cursor/mcp.json", "Cursor"}, - {"windsurf", "~/.codeium/windsurf/mcp_config.json", "Codeium"}, - {"antigravity", "~/.gemini/antigravity/mcp_config.json", "Google"}, - {"zed", "~/.config/zed/settings.json", "Zed"}, - {"open_interpreter", "~/.config/open-interpreter/config.yaml", "OpenSource"}, - {"codex", "~/.codex/config.toml", "OpenAI"}, + {"claude_desktop", "~/Library/Application Support/Claude/claude_desktop_config.json", "%APPDATA%/Claude/claude_desktop_config.json", "Anthropic"}, + {"claude_code", "~/.claude/settings.json", "", "Anthropic"}, + {"claude_code", "~/.claude.json", "", "Anthropic"}, + {"cursor", "~/.cursor/mcp.json", "", "Cursor"}, + {"windsurf", "~/.codeium/windsurf/mcp_config.json", "", "Codeium"}, + {"antigravity", "~/.gemini/antigravity/mcp_config.json", "", "Google"}, + {"zed", "~/.config/zed/settings.json", "", "Zed"}, + {"open_interpreter", "~/.config/open-interpreter/config.yaml", "", "OpenSource"}, + {"codex", "~/.codex/config.toml", "", "OpenAI"}, } // MCPDetector collects MCP configuration files. @@ -44,7 +45,7 @@ func (d *MCPDetector) Detect(_ context.Context, userIdentity string, enterprise var results []model.MCPConfig for _, spec := range mcpConfigDefinitions { - configPath := expandTilde(spec.ConfigPath, homeDir) + configPath := d.resolveConfigPath(spec, homeDir) if !d.exec.FileExists(configPath) { continue @@ -66,7 +67,7 @@ func (d *MCPDetector) DetectEnterprise(_ context.Context) []model.MCPConfigEnter var results []model.MCPConfigEnterprise for _, spec := range mcpConfigDefinitions { - configPath := expandTilde(spec.ConfigPath, homeDir) + configPath := d.resolveConfigPath(spec, homeDir) if !d.exec.FileExists(configPath) { continue @@ -92,6 +93,14 @@ func (d *MCPDetector) DetectEnterprise(_ context.Context) []model.MCPConfigEnter return results } +// resolveConfigPath returns the appropriate config path for the current platform. +func (d *MCPDetector) resolveConfigPath(spec mcpConfigSpec, homeDir string) string { + if d.exec.GOOS() == "windows" && spec.WinConfigPath != "" { + return resolveEnvPath(d.exec, spec.WinConfigPath) + } + return expandTilde(spec.ConfigPath, homeDir) +} + // filterMCPContent extracts MCP-relevant fields from a config file. func (d *MCPDetector) filterMCPContent(sourceName, configPath string, content []byte) []byte { if !strings.HasSuffix(configPath, ".json") { @@ -183,7 +192,7 @@ func stripJSONCComments(input []byte) []byte { // Block comment if i+1 < len(input) && input[i] == '/' && input[i+1] == '*' { i += 2 - for i+1 < len(input) && !(input[i] == '*' && input[i+1] == '/') { + for i+1 < len(input) && (input[i] != '*' || input[i+1] != '/') { i++ } i += 2 // skip */ diff --git a/internal/detector/mcp_test.go b/internal/detector/mcp_test.go index 682dd5c..f3380b9 100644 --- a/internal/detector/mcp_test.go +++ b/internal/detector/mcp_test.go @@ -81,3 +81,33 @@ func TestMCPDetector_Enterprise(t *testing.T) { t.Error("expected non-empty base64 content") } } + +func TestMCPDetector_Windows_FindsConfigs(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + mock.SetHomeDir(`C:\Users\testuser`) + mock.SetEnv("APPDATA", `C:\Users\testuser\AppData\Roaming`) + + // claude_desktop WinConfigPath: "%APPDATA%/Claude/claude_desktop_config.json" + // After resolveEnvPath on macOS host: + // env replacement -> "C:\Users\testuser\AppData\Roaming/Claude/claude_desktop_config.json" + // filepath.FromSlash (macOS no-op) -> same + claudeConfigPath := `C:\Users\testuser\AppData\Roaming` + "/Claude/claude_desktop_config.json" + mock.SetFile(claudeConfigPath, []byte(`{"mcpServers":{}}`)) + + det := NewMCPDetector(mock) + results := det.Detect(context.Background(), "testuser", false) + + if len(results) != 1 { + t.Fatalf("expected 1 config, got %d", len(results)) + } + if results[0].ConfigSource != "claude_desktop" { + t.Errorf("expected claude_desktop, got %s", results[0].ConfigSource) + } + if results[0].ConfigPath != claudeConfigPath { + t.Errorf("expected config path %s, got %s", claudeConfigPath, results[0].ConfigPath) + } + if results[0].Vendor != "Anthropic" { + t.Errorf("expected Anthropic, got %s", results[0].Vendor) + } +} diff --git a/internal/detector/nodepm_test.go b/internal/detector/nodepm_test.go index cb4687d..9f55eb0 100644 --- a/internal/detector/nodepm_test.go +++ b/internal/detector/nodepm_test.go @@ -2,6 +2,7 @@ package detector import ( "context" + "path/filepath" "testing" "github.com/step-security/dev-machine-guard/internal/executor" @@ -51,6 +52,58 @@ func TestNodePMDetector_NoneFound(t *testing.T) { } } +func TestNodePMDetector_Windows_FindsNPM(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + mock.SetPath("npm", `C:\Program Files\nodejs\npm.cmd`) + mock.SetCommand("10.2.0\n", "", 0, "npm", "--version") + + det := NewNodePMDetector(mock) + results := det.DetectManagers(context.Background()) + + if len(results) < 1 { + t.Fatal("expected at least 1 package manager on Windows") + } + if results[0].Name != "npm" { + t.Errorf("expected npm, got %s", results[0].Name) + } + if results[0].Version != "10.2.0" { + t.Errorf("expected 10.2.0, got %s", results[0].Version) + } + if results[0].Path != `C:\Program Files\nodejs\npm.cmd` { + t.Errorf("expected Windows path, got %s", results[0].Path) + } +} + +func TestDetectProjectPM_Windows(t *testing.T) { + // Note: filepath.Join is host-OS dependent; on macOS it uses "/" even for + // Windows-style project dirs. We use filepath.Join here to match what + // DetectProjectPM produces internally. + projectDir := `C:\Users\dev\myapp` + tests := []struct { + name string + lockFile string + expected string + }{ + {"npm lock", "package-lock.json", "npm"}, + {"yarn lock", "yarn.lock", "yarn"}, + {"pnpm lock", "pnpm-lock.yaml", "pnpm"}, + {"bun lock", "bun.lock", "bun"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + mock.SetFile(filepath.Join(projectDir, tt.lockFile), []byte{}) + got := DetectProjectPM(mock, projectDir) + if got != tt.expected { + t.Errorf("expected %s, got %s", tt.expected, got) + } + }) + } +} + func TestDetectProjectPM(t *testing.T) { tests := []struct { name string diff --git a/internal/detector/nodeproject.go b/internal/detector/nodeproject.go index fb9db5e..9e18030 100644 --- a/internal/detector/nodeproject.go +++ b/internal/detector/nodeproject.go @@ -61,7 +61,7 @@ func (d *NodeProjectDetector) countInDir(dir string) int { // DetectProjectPM detects which package manager a project uses based on lock files. func DetectProjectPM(exec executor.Executor, projectDir string) string { - if strings.Contains(projectDir, "/.bun/install/") { + if strings.Contains(filepath.ToSlash(projectDir), "/.bun/install/") { return "bun" } if exec.FileExists(filepath.Join(projectDir, "bun.lock")) || exec.FileExists(filepath.Join(projectDir, "bun.lockb")) { diff --git a/internal/detector/nodescan.go b/internal/detector/nodescan.go index 141affd..6c42fbc 100644 --- a/internal/detector/nodescan.go +++ b/internal/detector/nodescan.go @@ -105,7 +105,8 @@ func (s *NodeScanner) scanYarnGlobal(ctx context.Context) (model.NodeScanResult, } start := time.Now() - stdout, stderr, exitCode, _ := s.exec.RunWithTimeout(ctx, 60*time.Second, "bash", "-c", "cd '"+globalDir+"' && yarn list --json --depth=0") + shellCmd := "cd " + platformShellQuote(s.exec, globalDir) + " && yarn list --json --depth=0" + stdout, stderr, exitCode, _ := runShellCmd(ctx, s.exec, 60*time.Second, shellCmd) duration := time.Since(start).Milliseconds() errMsg := "" @@ -189,7 +190,7 @@ func (s *NodeScanner) ScanProjects(ctx context.Context, searchDirs []string) []m return nil } projectDir := filepath.Dir(path) - if strings.Contains(projectDir, "/node_modules/") { + if isInsideNodeModules(projectDir) { return nil } // Get modification time for sorting @@ -281,11 +282,11 @@ func (s *NodeScanner) scanProject(ctx context.Context, projectDir string) model. } start := time.Now() - shellCmd := "cd " + shellQuote(projectDir) + " && " + cmd + cmdStr := "cd " + platformShellQuote(s.exec, projectDir) + " && " + cmd for _, a := range args { - shellCmd += " " + a + cmdStr += " " + a } - stdout, stderr, exitCode, _ := s.exec.RunWithTimeout(ctx, 30*time.Second, "bash", "-c", shellCmd) + stdout, stderr, exitCode, _ := runShellCmd(ctx, s.exec, 30*time.Second, cmdStr) duration := time.Since(start).Milliseconds() errMsg := "" @@ -322,6 +323,10 @@ func (s *NodeScanner) getOutput(ctx context.Context, binary string, args ...stri return strings.TrimSpace(stdout) } -func shellQuote(s string) string { - return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" +// isInsideNodeModules returns true if the path contains a node_modules component. +// Uses strings.ReplaceAll instead of filepath.ToSlash so the check works +// regardless of the host OS (important for cross-platform mock tests). +func isInsideNodeModules(projectDir string) bool { + normalized := strings.ReplaceAll(projectDir, "\\", "/") + return strings.Contains(normalized, "/node_modules/") } diff --git a/internal/detector/nodescan_test.go b/internal/detector/nodescan_test.go new file mode 100644 index 0000000..63ff8d2 --- /dev/null +++ b/internal/detector/nodescan_test.go @@ -0,0 +1,244 @@ +package detector + +import ( + "context" + "encoding/base64" + "path/filepath" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/progress" +) + +func newTestScanner(exec *executor.Mock) *NodeScanner { + log := progress.NewLogger(false) + return NewNodeScanner(exec, log) +} + +func TestNodeScanner_ScanNPMGlobal(t *testing.T) { + mock := executor.NewMock() + mock.SetPath("npm", "/usr/local/bin/npm") + mock.SetCommand("10.2.0\n", "", 0, "npm", "--version") + mock.SetCommand("/usr/local\n", "", 0, "npm", "config", "get", "prefix") + mock.SetCommand(`{"dependencies":{"express":{"version":"4.18.2"}}}`, "", 0, "npm", "list", "-g", "--json", "--depth=3") + + scanner := newTestScanner(mock) + results := scanner.ScanGlobalPackages(context.Background()) + + npmFound := false + for _, r := range results { + if r.PackageManager == "npm" { + npmFound = true + if r.ProjectPath != "/usr/local" { + t.Errorf("expected ProjectPath /usr/local, got %s", r.ProjectPath) + } + if r.PMVersion != "10.2.0" { + t.Errorf("expected PMVersion 10.2.0, got %s", r.PMVersion) + } + if r.ExitCode != 0 { + t.Errorf("expected ExitCode 0, got %d", r.ExitCode) + } + decoded, _ := base64.StdEncoding.DecodeString(r.RawStdoutBase64) + if len(decoded) == 0 { + t.Error("expected non-empty RawStdoutBase64") + } + } + } + if !npmFound { + t.Fatal("expected npm in global scan results") + } +} + +func TestNodeScanner_ScanNPMGlobal_Windows(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + mock.SetPath("npm", `C:\Program Files\nodejs\npm.cmd`) + mock.SetCommand("10.2.0\n", "", 0, "npm", "--version") + // npm config get prefix returns a Windows-style path on real Windows. + // The code stores it directly (no filepath.* processing), so the mock + // value flows through unchanged. + mock.SetCommand(`C:\Users\dev\AppData\Roaming\npm`+"\n", "", 0, "npm", "config", "get", "prefix") + mock.SetCommand(`{"dependencies":{"express":{"version":"4.18.2"}}}`, "", 0, "npm", "list", "-g", "--json", "--depth=3") + + scanner := newTestScanner(mock) + results := scanner.ScanGlobalPackages(context.Background()) + + npmFound := false + for _, r := range results { + if r.PackageManager == "npm" { + npmFound = true + if r.ProjectPath != `C:\Users\dev\AppData\Roaming\npm` { + t.Errorf("expected Windows npm prefix, got %s", r.ProjectPath) + } + if r.PMVersion != "10.2.0" { + t.Errorf("expected PMVersion 10.2.0, got %s", r.PMVersion) + } + if r.ExitCode != 0 { + t.Errorf("expected ExitCode 0, got %d", r.ExitCode) + } + } + } + if !npmFound { + t.Fatal("expected npm in global scan results on Windows") + } +} + +func TestNodeScanner_ScanYarnGlobal_Windows(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + mock.SetPath("yarn", `C:\Program Files\nodejs\yarn.cmd`) + mock.SetCommand("1.22.19\n", "", 0, "yarn", "--version") + mock.SetCommand(`C:\Users\dev\AppData\Local\Yarn\Data\global`+"\n", "", 0, "yarn", "global", "dir") + // runShellCmd dispatches to cmd /c on Windows; platformShellQuote uses double quotes + mock.SetCommand(`{"type":"tree","data":{"trees":[]}}`, "", 0, + "cmd", "/c", `cd "C:\Users\dev\AppData\Local\Yarn\Data\global" && yarn list --json --depth=0`) + + scanner := newTestScanner(mock) + results := scanner.ScanGlobalPackages(context.Background()) + + yarnFound := false + for _, r := range results { + if r.PackageManager == "yarn" { + yarnFound = true + if r.ProjectPath != `C:\Users\dev\AppData\Local\Yarn\Data\global` { + t.Errorf("expected Windows yarn global dir, got %s", r.ProjectPath) + } + if r.PMVersion != "1.22.19" { + t.Errorf("expected PMVersion 1.22.19, got %s", r.PMVersion) + } + } + } + if !yarnFound { + t.Fatal("expected yarn in global scan results on Windows") + } +} + +func TestNodeScanner_ScanPnpmGlobal_Windows(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + mock.SetPath("pnpm", `C:\Users\dev\AppData\Local\pnpm\pnpm.cmd`) + mock.SetCommand("9.1.0\n", "", 0, "pnpm", "--version") + // pnpm root -g returns the global node_modules dir. The code calls + // filepath.Dir on it. Since filepath.Dir is host-OS dependent, we use + // forward slashes here so the test works on macOS hosts too. + mock.SetCommand("C:/Users/dev/AppData/Local/pnpm/global/5/node_modules\n", "", 0, "pnpm", "root", "-g") + mock.SetCommand(`{"dependencies":{"typescript":{"version":"5.4.0"}}}`, "", 0, "pnpm", "list", "-g", "--json", "--depth=3") + + scanner := newTestScanner(mock) + results := scanner.ScanGlobalPackages(context.Background()) + + pnpmFound := false + for _, r := range results { + if r.PackageManager == "pnpm" { + pnpmFound = true + // filepath.Dir strips the last component (node_modules) + expected := "C:/Users/dev/AppData/Local/pnpm/global/5" + if r.ProjectPath != expected { + t.Errorf("expected ProjectPath %s, got %s", expected, r.ProjectPath) + } + if r.PMVersion != "9.1.0" { + t.Errorf("expected PMVersion 9.1.0, got %s", r.PMVersion) + } + } + } + if !pnpmFound { + t.Fatal("expected pnpm in global scan results on Windows") + } +} + +func TestNodeScanner_ScanProject_Windows(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + mock.SetPath("npm", `C:\Program Files\nodejs\npm.cmd`) + mock.SetCommand("10.2.0\n", "", 0, "npm", "--version") + // DetectProjectPM uses filepath.Join which is host-dependent; + // construct the mock file path the same way the code will. + mock.SetFile(filepath.Join(`C:\Users\dev\myapp`, "package-lock.json"), []byte{}) + mock.SetCommand(`{"dependencies":{"lodash":{"version":"4.17.21"}}}`, "", 0, + "cmd", "/c", `cd "C:\Users\dev\myapp" && npm ls --json --depth=3`) + + scanner := newTestScanner(mock) + result := scanner.scanProject(context.Background(), `C:\Users\dev\myapp`) + + if result.PackageManager != "npm" { + t.Errorf("expected npm, got %s", result.PackageManager) + } + if result.ProjectPath != `C:\Users\dev\myapp` { + t.Errorf("expected project path C:\\Users\\dev\\myapp, got %s", result.ProjectPath) + } + if result.ExitCode != 0 { + t.Errorf("expected ExitCode 0, got %d", result.ExitCode) + } + if result.PMVersion != "10.2.0" { + t.Errorf("expected PMVersion 10.2.0, got %s", result.PMVersion) + } + decoded, _ := base64.StdEncoding.DecodeString(result.RawStdoutBase64) + if len(decoded) == 0 { + t.Error("expected non-empty RawStdoutBase64") + } +} + +func TestNodeScanner_ScanProject_YarnBerry_Windows(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + mock.SetPath("yarn", `C:\Program Files\nodejs\yarn.cmd`) + mock.SetCommand("4.1.0\n", "", 0, "yarn", "--version") + // Use filepath.Join to construct mock file paths matching the code's behavior. + projectDir := `C:\Users\dev\myapp` + mock.SetFile(filepath.Join(projectDir, "yarn.lock"), []byte{}) + mock.SetFile(filepath.Join(projectDir, ".yarnrc.yml"), []byte{}) + mock.SetCommand(`{"name":"myapp","children":[]}`, "", 0, + "cmd", "/c", `cd "C:\Users\dev\myapp" && yarn info --all --json`) + + scanner := newTestScanner(mock) + result := scanner.scanProject(context.Background(), projectDir) + + if result.PackageManager != "yarn-berry" { + t.Errorf("expected yarn-berry, got %s", result.PackageManager) + } + if result.PMVersion != "4.1.0" { + t.Errorf("expected PMVersion 4.1.0, got %s", result.PMVersion) + } + if result.ExitCode != 0 { + t.Errorf("expected ExitCode 0, got %d", result.ExitCode) + } +} + +func TestNodeScanner_ScanGlobalPackages_NoneInstalled(t *testing.T) { + mock := executor.NewMock() + scanner := newTestScanner(mock) + results := scanner.ScanGlobalPackages(context.Background()) + + if len(results) != 0 { + t.Errorf("expected 0 results when no PMs installed, got %d", len(results)) + } +} + +func TestIsInsideNodeModules(t *testing.T) { + tests := []struct { + path string + want bool + }{ + // Unix-style paths + {"/Users/dev/node_modules/foo", true}, + {"/Users/dev/myapp", false}, + {"/Users/dev/node_modules_backup/foo", false}, + {"/node_modules/", true}, + // Windows-style paths (backslashes) + {`C:\Users\dev\node_modules\foo`, true}, + {`C:\Users\dev\myapp`, false}, + {`C:\node_modules\pkg`, true}, + {`\node_modules\`, true}, + // Edge cases + {"node_modules", false}, + {"", false}, + } + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + got := isInsideNodeModules(tt.path) + if got != tt.want { + t.Errorf("isInsideNodeModules(%q) = %v, want %v", tt.path, got, tt.want) + } + }) + } +} diff --git a/internal/detector/process.go b/internal/detector/process.go new file mode 100644 index 0000000..27705d3 --- /dev/null +++ b/internal/detector/process.go @@ -0,0 +1,31 @@ +package detector + +import ( + "context" + "strings" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +// isProcessRunning checks if a process with the given name is running. +// On Unix, uses pgrep -x; on Windows, uses tasklist with IMAGENAME filter. +func isProcessRunning(ctx context.Context, exec executor.Executor, name string) bool { + if exec.GOOS() == "windows" { + stdout, _, exitCode, _ := exec.Run(ctx, "tasklist", "/FI", + "IMAGENAME eq "+name+".exe", "/NH") + return exitCode == 0 && !strings.Contains(stdout, "INFO: No tasks") + } + _, _, exitCode, _ := exec.Run(ctx, "pgrep", "-x", name) + return exitCode == 0 +} + +// isProcessRunningFuzzy checks if any process matches a substring pattern. +// On Unix, uses pgrep -f; on Windows, scans tasklist output. +func isProcessRunningFuzzy(ctx context.Context, exec executor.Executor, pattern string) bool { + if exec.GOOS() == "windows" { + stdout, _, _, _ := exec.Run(ctx, "tasklist", "/NH") + return strings.Contains(strings.ToLower(stdout), strings.ToLower(pattern)) + } + _, _, exitCode, _ := exec.Run(ctx, "pgrep", "-f", pattern) + return exitCode == 0 +} diff --git a/internal/detector/shellcmd.go b/internal/detector/shellcmd.go new file mode 100644 index 0000000..a47c971 --- /dev/null +++ b/internal/detector/shellcmd.go @@ -0,0 +1,29 @@ +package detector + +import ( + "context" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +// runShellCmd runs a shell command string using the platform-appropriate shell. +// On Unix: bash -c "command" +// On Windows: cmd /c "command" +func runShellCmd(ctx context.Context, exec executor.Executor, timeout time.Duration, command string) (string, string, int, error) { + if exec.GOOS() == "windows" { + return exec.RunWithTimeout(ctx, timeout, "cmd", "/c", command) + } + return exec.RunWithTimeout(ctx, timeout, "bash", "-c", command) +} + +// platformShellQuote quotes a string for use in a shell command. +// On Unix: single quotes with escaping. +// On Windows: double quotes with escaping. +func platformShellQuote(exec executor.Executor, s string) string { + if exec.GOOS() == "windows" { + return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"` + } + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" +} diff --git a/internal/device/device.go b/internal/device/device.go index ba0d366..0ad396e 100644 --- a/internal/device/device.go +++ b/internal/device/device.go @@ -11,19 +11,64 @@ import ( // Gather collects device information (hostname, serial, OS version, user identity). func Gather(ctx context.Context, exec executor.Executor) model.Device { hostname, _ := exec.Hostname() - serial := getSerialNumber(ctx, exec) - osVersion := getOSVersion(ctx, exec) userIdentity := getDeveloperIdentity(exec) + platform := exec.GOOS() + + var serial, osVersion string + switch platform { + case "windows": + serial = getSerialNumberWindows(ctx, exec) + osVersion = getOSVersionWindows(ctx, exec) + default: + serial = getSerialNumber(ctx, exec) + osVersion = getOSVersion(ctx, exec) + } return model.Device{ Hostname: hostname, SerialNumber: serial, OSVersion: osVersion, - Platform: "darwin", + Platform: platform, UserIdentity: userIdentity, } } +func getSerialNumberWindows(ctx context.Context, exec executor.Executor) string { + // Try wmic + stdout, _, _, err := exec.Run(ctx, "wmic", "bios", "get", "serialnumber") + if err == nil { + lines := strings.Split(strings.TrimSpace(stdout), "\n") + if len(lines) >= 2 { + serial := strings.TrimSpace(lines[1]) + if serial != "" && serial != "SerialNumber" { + return serial + } + } + } + // Fallback: PowerShell + stdout, _, _, err = exec.Run(ctx, "powershell", "-NoProfile", "-Command", + "(Get-CimInstance -ClassName Win32_BIOS).SerialNumber") + if err == nil { + s := strings.TrimSpace(stdout) + if s != "" { + return s + } + } + return "unknown" +} + +func getOSVersionWindows(ctx context.Context, exec executor.Executor) string { + stdout, _, _, err := exec.Run(ctx, "powershell", "-NoProfile", "-Command", + "[System.Environment]::OSVersion.Version.ToString()") + if err == nil { + v := strings.TrimSpace(stdout) + if v != "" { + return v + } + } + return "unknown" +} + func getSerialNumber(ctx context.Context, exec executor.Executor) string { // Try ioreg first stdout, _, _, err := exec.Run(ctx, "ioreg", "-l") diff --git a/internal/device/device_test.go b/internal/device/device_test.go index 9289ad9..bcaa320 100644 --- a/internal/device/device_test.go +++ b/internal/device/device_test.go @@ -57,3 +57,38 @@ func TestGather_EmailIdentity(t *testing.T) { t.Errorf("identity: expected dev@example.com, got %s", dev.UserIdentity) } } + +func TestGather_Windows(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + mock.SetHostname("WIN-DESKTOP") + mock.SetHomeDir(`C:\Users\testuser`) + mock.SetUsername("testuser") + + // wmic for serial number + mock.SetCommand("SerialNumber\nWIN-SERIAL-123\n", "", 0, + "wmic", "bios", "get", "serialnumber") + + // PowerShell for OS version + mock.SetCommand("10.0.22631.0\n", "", 0, + "powershell", "-NoProfile", "-Command", + "[System.Environment]::OSVersion.Version.ToString()") + + dev := Gather(context.Background(), mock) + + if dev.Hostname != "WIN-DESKTOP" { + t.Errorf("hostname: expected WIN-DESKTOP, got %s", dev.Hostname) + } + if dev.Platform != "windows" { + t.Errorf("platform: expected windows, got %s", dev.Platform) + } + if dev.SerialNumber != "WIN-SERIAL-123" { + t.Errorf("serial: expected WIN-SERIAL-123, got %s", dev.SerialNumber) + } + if dev.OSVersion != "10.0.22631.0" { + t.Errorf("os_version: expected 10.0.22631.0, got %s", dev.OSVersion) + } + if dev.UserIdentity != "testuser" { + t.Errorf("user_identity: expected testuser, got %s", dev.UserIdentity) + } +} diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 49d7248..ff5f30d 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -9,7 +9,6 @@ import ( "os/user" "path/filepath" "runtime" - "strings" "time" ) @@ -82,15 +81,6 @@ func (r *Real) RunWithTimeout(ctx context.Context, timeout time.Duration, name s return stdout, stderr, code, err } -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) - return strings.TrimSpace(stdout), err -} - func (r *Real) LookPath(name string) (string, error) { return exec.LookPath(name) } @@ -125,10 +115,6 @@ func (r *Real) Getenv(key string) string { return os.Getenv(key) } -func (r *Real) IsRoot() bool { - return os.Getuid() == 0 -} - func (r *Real) CurrentUser() (*user.User, error) { return user.Current() } diff --git a/internal/executor/executor_unix.go b/internal/executor/executor_unix.go new file mode 100644 index 0000000..dc5b380 --- /dev/null +++ b/internal/executor/executor_unix.go @@ -0,0 +1,22 @@ +//go:build !windows + +package executor + +import ( + "context" + "os" + "strings" +) + +func (r *Real) IsRoot() bool { + return os.Getuid() == 0 +} + +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) + return strings.TrimSpace(stdout), err +} diff --git a/internal/executor/executor_windows.go b/internal/executor/executor_windows.go new file mode 100644 index 0000000..f5f041d --- /dev/null +++ b/internal/executor/executor_windows.go @@ -0,0 +1,20 @@ +//go:build windows + +package executor + +import ( + "context" + "os/exec" + "strings" +) + +func (r *Real) IsRoot() bool { + cmd := exec.Command("net", "session") + err := cmd.Run() + return err == nil +} + +func (r *Real) RunAsUser(ctx context.Context, _ string, command string) (string, error) { + stdout, _, _, err := r.Run(ctx, "cmd", "/c", command) + return strings.TrimSpace(stdout), err +} diff --git a/internal/executor/mock.go b/internal/executor/mock.go index b1ab5df..153b9e4 100644 --- a/internal/executor/mock.go +++ b/internal/executor/mock.go @@ -145,6 +145,12 @@ func (m *Mock) SetGlob(pattern string, matches []string) { m.globs[pattern] = matches } +func (m *Mock) SetGOOS(goos string) { + m.mu.Lock() + defer m.mu.Unlock() + m.goos = goos +} + // --- Executor interface --- func (m *Mock) Run(_ context.Context, name string, args ...string) (string, string, int, error) { diff --git a/internal/launchd/launchd.go b/internal/launchd/launchd.go index e173f4f..4539082 100644 --- a/internal/launchd/launchd.go +++ b/internal/launchd/launchd.go @@ -80,7 +80,7 @@ func Install(exec executor.Executor, log *progress.Logger) error { if err != nil { return fmt.Errorf("creating plist file: %w", err) } - defer f.Close() + defer func() { _ = f.Close() }() tmpl, err := template.New("plist").Parse(plistTmpl) if err != nil { @@ -136,7 +136,7 @@ func doUninstall(ctx context.Context, exec executor.Executor, log *progress.Logg // Remove plist if exec.FileExists(plistPath) { - os.Remove(plistPath) + _ = os.Remove(plistPath) log.Progress("Removed plist file: %s", plistPath) } diff --git a/internal/lock/lock.go b/internal/lock/lock.go index 9654605..f9d488f 100644 --- a/internal/lock/lock.go +++ b/internal/lock/lock.go @@ -6,13 +6,10 @@ import ( "os" "strconv" "strings" - "syscall" "github.com/step-security/dev-machine-guard/internal/executor" ) -const lockFilePath = "/tmp/stepsecurity-dev-machine-guard.lock" - // Lock represents an acquired instance lock. type Lock struct { path string @@ -45,7 +42,7 @@ func Acquire(_ executor.Executor) (*Lock, error) { } _, err = fmt.Fprintf(f, "%d", os.Getpid()) - f.Close() + _ = f.Close() if err != nil { _ = os.Remove(lockFilePath) return nil, fmt.Errorf("writing PID to lock file: %w", err) @@ -61,16 +58,3 @@ func (l *Lock) Release() { } } -// isProcessAlive checks if a process with the given PID exists. -// Returns true if the process is alive (signal 0 succeeds or returns EPERM). -func isProcessAlive(pid int) bool { - err := syscall.Kill(pid, 0) - if err == nil { - return true // process exists and we can signal it - } - // EPERM means the process exists but we don't have permission to signal it - if errors.Is(err, syscall.EPERM) { - return true - } - return false // ESRCH or other error — process doesn't exist -} diff --git a/internal/lock/lock_unix.go b/internal/lock/lock_unix.go new file mode 100644 index 0000000..6a198c8 --- /dev/null +++ b/internal/lock/lock_unix.go @@ -0,0 +1,24 @@ +//go:build !windows + +package lock + +import ( + "errors" + "syscall" +) + +var lockFilePath = "/tmp/stepsecurity-dev-machine-guard.lock" + +// isProcessAlive checks if a process with the given PID exists. +// Returns true if the process is alive (signal 0 succeeds or returns EPERM). +func isProcessAlive(pid int) bool { + err := syscall.Kill(pid, 0) + if err == nil { + return true // process exists and we can signal it + } + // EPERM means the process exists but we don't have permission to signal it + if errors.Is(err, syscall.EPERM) { + return true + } + return false // ESRCH or other error — process doesn't exist +} diff --git a/internal/lock/lock_windows.go b/internal/lock/lock_windows.go new file mode 100644 index 0000000..92c4cfc --- /dev/null +++ b/internal/lock/lock_windows.go @@ -0,0 +1,23 @@ +//go:build windows + +package lock + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +var lockFilePath = filepath.Join(os.TempDir(), "stepsecurity-dev-machine-guard.lock") + +// isProcessAlive checks if a process with the given PID exists using tasklist. +func isProcessAlive(pid int) bool { + cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid), "/NH") + output, err := cmd.Output() + if err != nil { + return false + } + return !strings.Contains(string(output), "No tasks") +} diff --git a/internal/output/html.go b/internal/output/html.go index 4c8afc0..cedb602 100644 --- a/internal/output/html.go +++ b/internal/output/html.go @@ -41,7 +41,7 @@ func HTML(outputFile string, result *model.ScanResult) error { if err != nil { return fmt.Errorf("creating HTML file: %w", err) } - defer f.Close() + defer func() { _ = f.Close() }() scanTime := time.Unix(result.ScanTimestamp, 0).Format("2006-01-02 15:04:05") @@ -169,7 +169,7 @@ const htmlTemplate = `