diff --git a/CHANGELOG.md b/CHANGELOG.md index db54010c..2388bbf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 See [VERSIONING.md](VERSIONING.md) for why the version starts at 1.8.1. +## [Unreleased] + +### Fixed + +- **The scan no longer opens LM Studio's window on Linux.** `lm-studio` names the desktop application's launcher, not a CLI (LM Studio's CLI is a separate binary, `lms`), and a packaged Electron app does not implement `--version`, so the flag was ignored and the app booted. An Ubuntu 22.04 customer running the agent from a systemd timer had LM Studio appear on their desktop mid-scan, with the probe of `/usr/bin/lm-studio` sitting on the full 10s exec deadline before being killed. Framework specs now carry a per-tool `GUIApp` flag that suppresses the `--version` fallback, so a GUI entry point is reported as installed with whatever on-disk metadata yields and `unknown` otherwise. The flag is opt-in per entry: ollama, LocalAI and Text Generation WebUI are real CLIs and are still exec'd exactly as before. +- **IDE version probes on Linux are static-first and shim-only.** `/` was an exec candidate ahead of `product-info.json` and `.eclipseproduct` — and for every VS Code fork that path is the Electron GUI binary, not the CLI (`/opt/Cursor/cursor` launches Cursor; the CLI is `bin/cursor`), so an install whose `package.json` had moved would launch the app. It is no longer a candidate. Separately, an IDE found only as a name on `$PATH` went straight to ` --version`; it now resolves the symlink and walks up to the install root's `package.json`/`product-info.json` first, which also yields a better version than the shim prints. + +### Added + +- **`execguard` now answers on Linux, not just macOS.** It was a macOS-only gate, so every Linux exec fallback ran unguarded. On Linux it now refuses a binary that is a packaged Electron app's entry point, decided from stats alone: an Electron bundle ships `resources/app.asar` and the Chromium runtime beside its executable, and nothing else does. Only the binary's own directory is examined, which is what separates the app from its CLI — `/usr/share/code/code` sits beside `libffmpeg.so` and is refused, while the shim at `/usr/share/code/bin/code` does not and is allowed. Checking the parent too, as the macOS quarantine probe must because cask installs mark whole trees, would have rejected exactly the shims we need. Electron-only is deliberate: a GTK or Qt app on `$PATH` would need its own signal. macOS and Windows behavior are unchanged. The IDE detector now consults the guard too — it was the one version-probe path that never did. +- **Three static version sources for Linux**, so fewer tools reach an exec at all. **dpkg**: the version of the package that owns the binary, proved from the package's own file manifest rather than assumed from a matching name, so a stale `foo` package cannot lend its version to a hand-installed `/usr/local/bin/foo`; purged packages are rejected. **snap**: the `version` field of `/snap//current/meta/snap.yaml`, which no path rule could reach since `/snap/bin/` symlinks to the snap wrapper. **AppImage**: the version in the filename, the only static source a single-file install has; the tool-name prefix must match. All three are plain file reads, mirroring the existing static pacman-database reads. + ## [1.16.0] - 2026-08-20 ### Added diff --git a/SCAN_COVERAGE.md b/SCAN_COVERAGE.md index 93f02008..341b229a 100644 --- a/SCAN_COVERAGE.md +++ b/SCAN_COVERAGE.md @@ -76,9 +76,26 @@ Binaries are found via `$PATH` lookup (cross-platform). LM Studio is additionall |-----------------------|------------|---------------------------------------------------------------------------------| | Ollama | `ollama` | Checks if process is running | | LocalAI | `local-ai` | Checks if process is running | -| LM Studio | `lm-studio`| GUI: `/Applications/LM Studio.app` (macOS) or `%LOCALAPPDATA%\Programs\LM Studio` (Windows) | +| LM Studio | `lm-studio`| GUI: `/Applications/LM Studio.app` (macOS), `%LOCALAPPDATA%\Programs\LM Studio` (Windows), `~/.local/share/LM Studio` or `/opt/LM Studio` (Linux). Never executed for its version — see below | | Text Generation WebUI | `textgen` | Checks if process is running | +### Version probes never launch a desktop app + +`lm-studio` names the desktop application's launcher, not a CLI (LM Studio's CLI is a +separate binary, `lms`). A packaged Electron app does not implement `--version`, so probing +it that way opens the app's window instead of printing a version. Its version therefore comes +only from on-disk metadata — the macOS bundle `Info.plist`, the Windows uninstall registry, or +on Linux the dpkg entry for the `.deb`, the snap manifest, or the version in an AppImage +filename — and reads `unknown` when none of those resolve. The tool is still reported as +installed either way. + +This is also enforced generically: before any version probe execs a binary, the agent checks +whether it is a packaged Electron app's entry point, which is visible on disk +(`resources/app.asar` and the Chromium runtime sit beside the executable; a CLI shim's +directory holds neither). On macOS the equivalent check is Gatekeeper quarantine assessment. +A refused probe reports `unknown`. Only Electron apps are detected — a GTK or Qt application +on `$PATH` is not. + ## MCP Configuration Sources On Windows, `~` refers to the user's home directory (`%USERPROFILE%`). Claude Desktop uses a Windows-specific path via `%APPDATA%`. diff --git a/internal/detector/agent.go b/internal/detector/agent.go index 81ae0a1e..b9efd439 100644 --- a/internal/detector/agent.go +++ b/internal/detector/agent.go @@ -143,8 +143,8 @@ func (d *AgentDetector) getVersion(ctx context.Context, binaryPath string) strin if v := versionmeta.FromBinary(ctx, d.exec, binaryPath); v != "" { return v } - if !execguard.SafeToExec(ctx, d.exec, binaryPath) { - d.log.Warn("skipping %s version probe: quarantined and rejected by Gatekeeper", binaryPath) + if safe, reason := execguard.SafeToExec(ctx, d.exec, binaryPath); !safe { + d.log.Warn("skipping %s version probe: %s", binaryPath, reason) return "unknown" } d.log.Progress("exec fallback: running %s --version (no metadata version source)", binaryPath) diff --git a/internal/detector/aicli.go b/internal/detector/aicli.go index 267f3b63..5986c186 100644 --- a/internal/detector/aicli.go +++ b/internal/detector/aicli.go @@ -93,8 +93,8 @@ var cliToolDefinitions = []cliToolSpec{ Binaries: []string{"kiro-cli", "kiro", "q"}, ConfigDirs: []string{"~/.q", "~/.kiro", "~/.aws/q"}, VerifyFunc: func(ctx context.Context, exec executor.Executor, log *progress.Logger, binary string) bool { - if !execguard.SafeToExec(ctx, exec, binary) { - log.Warn("skipping %s: quarantined and rejected by Gatekeeper — cannot verify identity", binary) + if safe, reason := execguard.SafeToExec(ctx, exec, binary); !safe { + log.Warn("skipping %s: %s — cannot verify identity", binary, reason) return false } log.Progress("exec fallback: running %s --version (amazon-q identity check)", binary) @@ -133,8 +133,8 @@ var cliToolDefinitions = []cliToolSpec{ if versionmeta.NPMPackageName(exec, binary) == "@github/copilot" { return true } - if !execguard.SafeToExec(ctx, exec, binary) { - log.Warn("skipping %s: quarantined and rejected by Gatekeeper — cannot verify identity", binary) + if safe, reason := execguard.SafeToExec(ctx, exec, binary); !safe { + log.Warn("skipping %s: %s — cannot verify identity", binary, reason) return false } log.Progress("exec fallback: running %s --version (copilot identity check)", binary) @@ -374,8 +374,8 @@ func (d *AICLIDetector) getVersion(ctx context.Context, spec cliToolSpec, binary if spec.VersionFlag != "" { flag = spec.VersionFlag } - if !execguard.SafeToExec(ctx, d.exec, binaryPath) { - d.log.Warn("skipping %s version probe: quarantined and rejected by Gatekeeper", binaryPath) + if safe, reason := execguard.SafeToExec(ctx, d.exec, binaryPath); !safe { + d.log.Warn("skipping %s version probe: %s", binaryPath, reason) return "unknown" } d.log.Progress("exec fallback: running %s %s (no metadata version source)", binaryPath, flag) diff --git a/internal/detector/aicli_agents_test.go b/internal/detector/aicli_agents_test.go index 48d35ae0..e31e551e 100644 --- a/internal/detector/aicli_agents_test.go +++ b/internal/detector/aicli_agents_test.go @@ -416,6 +416,12 @@ func runAICLICase(t *testing.T, tc aicliCase) { allowed := aicliAllowedGlobs(home, goos) for _, pattern := range rec.globs { + // versionmeta's dpkg source globs one :.list per candidate. + // Matched by prefix since the tool name varies per case; it reads the + // package database and launches nothing. + if strings.HasPrefix(pattern, "/var/lib/dpkg/info/") { + continue + } if !allowed[pattern] { t.Errorf("unexpected Glob(%q); the ladders may only glob the targeted install trees", pattern) } diff --git a/internal/detector/configaudit/bunfig.go b/internal/detector/configaudit/bunfig.go index 79b54c2d..b08db982 100644 --- a/internal/detector/configaudit/bunfig.go +++ b/internal/detector/configaudit/bunfig.go @@ -279,8 +279,8 @@ func (d *BunDetector) bunVersion(ctx context.Context) string { if v := versionmeta.FromBinary(ctx, d.exec, path); v != "" { return v } - if !execguard.SafeToExec(ctx, d.exec, path) { - d.log.Warn("skipping %s version probe: quarantined and rejected by Gatekeeper", path) + if safe, reason := execguard.SafeToExec(ctx, d.exec, path); !safe { + d.log.Warn("skipping %s version probe: %s", path, reason) return "unknown" } target = path diff --git a/internal/detector/configaudit/yarn.go b/internal/detector/configaudit/yarn.go index c9df18aa..36ddd118 100644 --- a/internal/detector/configaudit/yarn.go +++ b/internal/detector/configaudit/yarn.go @@ -285,8 +285,8 @@ func (d *YarnDetector) yarnVersion(ctx context.Context) string { if v := versionmeta.FromBinary(ctx, d.exec, path); v != "" { return v } - if !execguard.SafeToExec(ctx, d.exec, path) { - d.log.Warn("skipping %s version probe: quarantined and rejected by Gatekeeper", path) + if safe, reason := execguard.SafeToExec(ctx, d.exec, path); !safe { + d.log.Warn("skipping %s version probe: %s", path, reason) return "unknown" } target = path diff --git a/internal/detector/framework.go b/internal/detector/framework.go index df79d5b1..dd29e7c9 100644 --- a/internal/detector/framework.go +++ b/internal/detector/framework.go @@ -16,13 +16,24 @@ type frameworkSpec struct { Name string BinaryName string ProcessName string + + // GUIApp marks a binary that is the desktop app itself rather than a CLI, + // suppressing the --version exec fallback in getVersion: with no on-disk + // source the tool reports "unknown" instead of being launched. + // + // A packaged Electron app does not implement --version (only the + // unpackaged `electron` binary's default_app does), so the flag is ignored + // and the app boots — reported by an Ubuntu 22.04 customer whose scan + // opened LM Studio's window. + GUIApp bool } var frameworkDefinitions = []frameworkSpec{ - {"ollama", "ollama", "ollama"}, - {"localai", "local-ai", "local-ai"}, - {"lm-studio", "lm-studio", "lm-studio"}, - {"text-generation-webui", "textgen", "textgen"}, + {Name: "ollama", BinaryName: "ollama", ProcessName: "ollama"}, + {Name: "localai", BinaryName: "local-ai", ProcessName: "local-ai"}, + // `lm-studio` is the desktop app's launcher; the CLI is a separate binary, `lms`. + {Name: "lm-studio", BinaryName: "lm-studio", ProcessName: "lm-studio", GUIApp: true}, + {Name: "text-generation-webui", BinaryName: "textgen", ProcessName: "textgen"}, } // FrameworkDetector detects AI frameworks and runtimes. @@ -53,7 +64,7 @@ func (d *FrameworkDetector) Detect(ctx context.Context) []model.AITool { continue } - version := d.getVersion(ctx, binaryPath) + version := d.getVersion(ctx, spec, binaryPath) isRunning := isProcessRunning(ctx, d.exec, spec.ProcessName) results = append(results, model.AITool{ @@ -84,15 +95,21 @@ func (d *FrameworkDetector) Detect(ctx context.Context) []model.AITool { return results } -func (d *FrameworkDetector) getVersion(ctx context.Context, binaryPath string) string { +func (d *FrameworkDetector) getVersion(ctx context.Context, spec frameworkSpec, binaryPath string) string { // Static-first, exec-last (AGENTS.md §3.4). Bonus: skipping exec also // avoids the daemon-warning-decorated output some frameworks (ollama) // prepend to --version. if v := versionmeta.FromBinary(ctx, d.exec, binaryPath); v != "" { return v } - if !execguard.SafeToExec(ctx, d.exec, binaryPath) { - d.log.Warn("skipping %s version probe: quarantined and rejected by Gatekeeper", binaryPath) + // No exec step for a GUI app: "unknown" is the floor (§3.4), and the tool + // is still reported as installed. + if spec.GUIApp { + d.log.Debug("skipping %s version probe: GUI application, --version would launch it", binaryPath) + return "unknown" + } + if safe, reason := execguard.SafeToExec(ctx, d.exec, binaryPath); !safe { + d.log.Warn("skipping %s version probe: %s", binaryPath, reason) return "unknown" } d.log.Progress("exec fallback: running %s --version (no metadata version source)", binaryPath) @@ -124,6 +141,9 @@ func (d *FrameworkDetector) detectLMStudioApp(ctx context.Context) (model.AITool homeDir := getHomeDir(d.exec) for _, candidate := range []string{ filepath.Join(homeDir, ".local", "share", "LM Studio"), + // electron-builder's .deb installs to /opt/; the + // lowercase path is what community repackagings use. + "/opt/LM Studio", "/opt/lm-studio", } { if d.exec.DirExists(candidate) { diff --git a/internal/detector/framework_test.go b/internal/detector/framework_test.go index dd0396e6..3bb47ca6 100644 --- a/internal/detector/framework_test.go +++ b/internal/detector/framework_test.go @@ -3,8 +3,10 @@ package detector import ( "context" "testing" + "time" "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" ) func TestFrameworkDetector_FindsOllama(t *testing.T) { @@ -146,3 +148,97 @@ func TestFrameworkDetector_Windows_FindsOllama(t *testing.T) { t.Error("ollama not found") } } + +// noExecMock turns any subprocess into a test failure. On Linux the whole +// lm-studio path (LookPath, version, /proc liveness) is filesystem reads, so +// a single exec is the regression. +type noExecMock struct { + *executor.Mock + t *testing.T +} + +func (m *noExecMock) Run(_ context.Context, name string, args ...string) (string, string, int, error) { + m.t.Fatalf("unexpected exec: %s %v", name, args) + return "", "", -1, nil +} + +func (m *noExecMock) RunWithTimeout(ctx context.Context, _ time.Duration, name string, args ...string) (string, string, int, error) { + return m.Run(ctx, name, args...) //nolint:contextcheck // trap, never reaches a real command +} + +// The reported machine: the .deb's launcher on PATH, symlinked into the +// electron-builder install root. +func linuxLMStudioMock(t *testing.T) *noExecMock { + t.Helper() + mock := executor.NewMock() + mock.SetGOOS("linux") + mock.SetHomeDir("/home/dev") + mock.SetPath("lm-studio", "/usr/bin/lm-studio") + mock.SetSymlink("/usr/bin/lm-studio", "/opt/LM Studio/lm-studio") + return &noExecMock{Mock: mock, t: t} +} + +func findTool(results []model.AITool, name string) (model.AITool, bool) { + for _, r := range results { + if r.Name == name { + return r, true + } + } + return model.AITool{}, false +} + +func TestFrameworkDetector_LMStudioLinuxIsNeverLaunched(t *testing.T) { + mock := linuxLMStudioMock(t) + + results := NewFrameworkDetector(mock).Detect(context.Background()) + + tool, ok := findTool(results, "lm-studio") + if !ok { + t.Fatal("suppressing the exec must not suppress the detection") + } + if tool.Version != "unknown" { + t.Errorf("version = %q, want unknown", tool.Version) + } + if tool.BinaryPath != "/usr/bin/lm-studio" { + t.Errorf("binary_path = %q, want /usr/bin/lm-studio", tool.BinaryPath) + } +} + +// Still recoverable without launching anything: dpkg records both the file +// list and the version of the .deb that installed the launcher. +func TestFrameworkDetector_LMStudioLinuxVersionFromDpkg(t *testing.T) { + mock := linuxLMStudioMock(t) + mock.SetFile("/var/lib/dpkg/info/lm-studio.list", []byte( + "/opt\n/opt/LM Studio\n/opt/LM Studio/lm-studio\n/usr/bin/lm-studio\n")) + mock.SetFile("/var/lib/dpkg/status", []byte( + "Package: lm-studio\nStatus: install ok installed\nVersion: 0.3.31-1\nArchitecture: amd64\n\n")) + + results := NewFrameworkDetector(mock).Detect(context.Background()) + + tool, ok := findTool(results, "lm-studio") + if !ok { + t.Fatal("lm-studio not found") + } + if tool.Version != "0.3.31" { + t.Errorf("version = %q, want 0.3.31 (upstream part of 0.3.31-1)", tool.Version) + } +} + +// GUIApp is opt-in per entry: ollama is a real CLI and must still be exec'd. +func TestFrameworkDetector_OllamaStillExecsOnLinux(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("linux") + mock.SetHomeDir("/home/dev") + mock.SetPath("ollama", "/usr/local/bin/ollama") + mock.SetCommand("ollama version is 0.5.13\n", "", 0, "/usr/local/bin/ollama", "--version") + + results := NewFrameworkDetector(mock).Detect(context.Background()) + + tool, ok := findTool(results, "ollama") + if !ok { + t.Fatal("ollama not found") + } + if tool.Version != "0.5.13" { + t.Errorf("version = %q, want 0.5.13", tool.Version) + } +} diff --git a/internal/detector/ide.go b/internal/detector/ide.go index 22dea6de..e3b4b758 100644 --- a/internal/detector/ide.go +++ b/internal/detector/ide.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/step-security/dev-machine-guard/internal/execguard" "github.com/step-security/dev-machine-guard/internal/executor" "github.com/step-security/dev-machine-guard/internal/model" ) @@ -336,10 +337,7 @@ func (d *IDEDetector) detectLinux(ctx context.Context, spec ideSpec) (model.IDE, if spec.LinuxBinary != "" { binPath, err := d.exec.LookPath(spec.LinuxBinary) if err == nil { - version := "unknown" - if spec.VersionFlag != "" { - version = runVersionCmd(ctx, d.exec, binPath, spec.VersionFlag) - } + version := d.resolveLinuxVersionFromBinary(ctx, spec, binPath) return model.IDE{ IDEType: spec.IDEType, Version: version, InstallPath: binPath, Vendor: spec.Vendor, IsInstalled: true, @@ -377,17 +375,24 @@ func (d *IDEDetector) resolveLinuxVersion(ctx context.Context, spec ideSpec, ins return v } + // product-info.json at the root of the install dir (JetBrains, some Electron apps) + if v := readJSONVersion(d.exec, filepath.Join(installDir, "product-info.json")); v != "unknown" { + return v + } + + // .eclipseproduct at the root (Eclipse) + if v := readEclipseProductVersion(d.exec, filepath.Join(installDir, ".eclipseproduct")); v != "unknown" { + return v + } + + // Exec last, and only the CLI shim. `/` is the + // Electron GUI binary for every VS Code fork (/opt/Cursor/cursor launches + // Cursor; the CLI is bin/cursor), so it is no longer a candidate. if spec.LinuxBinary != "" && spec.VersionFlag != "" { - // Try binary inside the detected install directory first - for _, relBin := range []string{ - filepath.Join("bin", spec.LinuxBinary), - spec.LinuxBinary, - } { - localBin := filepath.Join(installDir, relBin) - if d.exec.FileExists(localBin) { - if v := runVersionCmd(ctx, d.exec, localBin, spec.VersionFlag); v != "unknown" { - return v - } + localBin := filepath.Join(installDir, "bin", spec.LinuxBinary) + if d.exec.FileExists(localBin) { + if v := runVersionCmd(ctx, d.exec, localBin, spec.VersionFlag); v != "unknown" { + return v } } @@ -399,17 +404,50 @@ func (d *IDEDetector) resolveLinuxVersion(ctx context.Context, spec ideSpec, ins } } - // product-info.json at the root of the install dir (JetBrains, some Electron apps) - if v := readJSONVersion(d.exec, filepath.Join(installDir, "product-info.json")); v != "unknown" { - return v - } + return "unknown" +} - // .eclipseproduct at the root (Eclipse) - if v := readEclipseProductVersion(d.exec, filepath.Join(installDir, ".eclipseproduct")); v != "unknown" { - return v +// resolveLinuxVersionFromBinary versions an IDE found only as a name on +// $PATH, where no install directory matched and so no metadata path is known +// up front. It recovers one by walking up to the install root, and execs only +// if that finds nothing — previously it went straight to ` --version`, +// the same launch-it-sight-unseen shape as the LM Studio bug. +func (d *IDEDetector) resolveLinuxVersionFromBinary(ctx context.Context, spec ideSpec, binPath string) string { + if root, ok := d.linuxInstallRootFromBinary(binPath); ok { + return d.resolveLinuxVersion(ctx, spec, root) } + if spec.VersionFlag == "" { + return "unknown" + } + return runVersionCmd(ctx, d.exec, binPath, spec.VersionFlag) +} - return "unknown" +// linuxInstallRootFromBinary walks up from a resolved binary looking for the +// directory carrying an IDE's version metadata: /usr/bin/code symlinks to +// /usr/share/code/bin/code, two levels below its resources/app/package.json. +// Bounded, so a binary outside any install tree costs a handful of stats. +func (d *IDEDetector) linuxInstallRootFromBinary(binPath string) (string, bool) { + resolved, err := d.exec.EvalSymlinks(binPath) + if err != nil || resolved == "" { + resolved = binPath + } + dir := filepath.Dir(resolved) + for i := 0; i < maxLinuxInstallRootDepth; i++ { + if dir == "" || dir == "/" || dir == "." { + return "", false + } + for _, marker := range []string{ + filepath.Join(dir, "resources", "app", "package.json"), + filepath.Join(dir, "product-info.json"), + filepath.Join(dir, ".eclipseproduct"), + } { + if d.exec.FileExists(marker) { + return dir, true + } + } + dir = filepath.Dir(dir) + } + return "", false } func (d *IDEDetector) detectWindows(ctx context.Context, spec ideSpec) (model.IDE, bool) { @@ -553,8 +591,18 @@ func (d *IDEDetector) resolveInstallDir(resolved string) (string, bool) { return newest, true } +// maxLinuxInstallRootDepth covers the deepest real layout +// (/usr/share/code/bin/code -> /usr/share/code is two, Toolbox one more). +const maxLinuxInstallRootDepth = 3 + // runVersionCmd runs a binary with a version flag and extracts the first line. +// Guarded by execguard — every spec in this table names a desktop application, +// so on Linux it refuses an Electron app's entry point and on macOS a +// Gatekeeper-rejected quarantined binary. func runVersionCmd(ctx context.Context, exec executor.Executor, binary, flag string) string { + if safe, _ := execguard.SafeToExec(ctx, exec, binary); !safe { + return "unknown" + } stdout, _, _, err := exec.RunWithTimeout(ctx, 10*time.Second, binary, flag) if err != nil { return "unknown" diff --git a/internal/detector/ide_test.go b/internal/detector/ide_test.go index 38537d60..cc4d0bbb 100644 --- a/internal/detector/ide_test.go +++ b/internal/detector/ide_test.go @@ -907,3 +907,121 @@ func findIDE(results []model.IDE, ideType string) *model.IDE { } return nil } + +// The install dir's bare `` is the Electron GUI binary for every +// VS Code fork, and used to be an exec candidate ahead of product-info.json. +// Here package.json is absent and only the GUI binary exists. +func TestIDEDetector_Linux_NeverExecsTheInstallDirGUIBinary(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("linux") + installDir := "/opt/Windsurf" + mock.SetDir(installDir) + // No resources/app/package.json, no product-info.json, no .eclipseproduct. + gui := installDir + "/windsurf" + mock.SetFile(gui, []byte{}) + mock.SetCommand("9.9.9-GUI-LAUNCHED", "", 0, gui, "--version") + + found := findIDE(NewIDEDetector(mock).Detect(context.Background()), "windsurf") + if found == nil { + t.Fatal("expected Windsurf to be detected at /opt/Windsurf") + } + if found.Version == "9.9.9-GUI-LAUNCHED" { + t.Fatalf("exec'd %s — the install dir's bare binary is the GUI app, never a version target", gui) + } + if found.Version != "unknown" { + t.Errorf("version = %q, want unknown (no metadata source, no safe exec target)", found.Version) + } +} + +// Phase 2 (a $PATH hit with no matching install dir) used to go straight to +// ` --version`. The shim knows its own install root: /usr/local/bin/code +// resolves into /bin/code, two levels below the package.json. +func TestIDEDetector_Linux_PathFallbackReadsInstallRootBeforeExec(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("linux") + // Non-standard root, so Phase 1 can't match and Phase 2 is the only way in. + root := "/opt/vscode-custom" + mock.SetPath("code", "/usr/local/bin/code") + mock.SetSymlink("/usr/local/bin/code", root+"/bin/code") + mock.SetFile(root+"/resources/app/package.json", []byte(`{"name":"code","version":"1.98.2"}`)) + // Poison both exec candidates: reaching either means the walk didn't run. + for _, b := range []string{"/usr/local/bin/code", root + "/bin/code"} { + mock.SetFile(b, []byte{}) + mock.SetCommand("9.9.9-EXEC'D", "", 0, b, "--version") + } + + found := findIDE(NewIDEDetector(mock).Detect(context.Background()), "vscode") + if found == nil { + t.Fatal("expected VS Code to be detected via the PATH fallback") + } + if found.Version == "9.9.9-EXEC'D" { + t.Fatal("Phase 2 exec'd the PATH binary before walking up to its install root") + } + if found.Version != "1.98.2" { + t.Errorf("version should come from the install root's package.json (1.98.2), got %s", found.Version) + } +} + +// The walk is best effort: a binary outside any recognizable install tree +// still resolves through the exec fallback, costing no detection. +func TestIDEDetector_Linux_PathFallbackStillExecsWhenNoRootFound(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("linux") + mock.SetPath("code", "/usr/local/bin/code") + mock.SetFile("/usr/local/bin/code", []byte{}) + mock.SetCommand("1.98.2\nabcdef\nx64\n", "", 0, "/usr/local/bin/code", "--version") + + found := findIDE(NewIDEDetector(mock).Detect(context.Background()), "vscode") + if found == nil { + t.Fatal("expected VS Code to be detected via the PATH fallback") + } + if found.Version != "1.98.2" { + t.Errorf("version = %q, want 1.98.2 from the exec fallback", found.Version) + } +} + +// When no metadata resolves and the only exec candidate is an Electron app's +// entry point, execguard refuses it. An asar-packed app has no unpacked +// package.json, so this is the realistic shape of "static sources all missed". +func TestIDEDetector_Linux_ExecguardRefusesElectronEntryPoint(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("linux") + root := "/opt/windsurf-custom" + mock.SetPath("windsurf", root+"/windsurf") + mock.SetFile(root+"/resources/app.asar", []byte{}) + mock.SetFile(root+"/libffmpeg.so", []byte{}) + mock.SetFile(root+"/windsurf", []byte{}) + mock.SetCommand("9.9.9-GUI-LAUNCHED", "", 0, root+"/windsurf", "--version") + + found := findIDE(NewIDEDetector(mock).Detect(context.Background()), "windsurf") + if found == nil { + t.Fatal("expected Windsurf to be detected via the PATH fallback") + } + if found.Version == "9.9.9-GUI-LAUNCHED" { + t.Fatal("exec'd the Electron entry point — execguard must refuse it on Linux") + } + if found.Version != "unknown" { + t.Errorf("version = %q, want unknown", found.Version) + } +} + +// A CLI shim one directory below the same bundle must still be exec'd, or +// every VS Code fork on Linux silently loses its version. +func TestIDEDetector_Linux_ExecguardAllowsCLIShimInsideBundle(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("linux") + root := "/opt/windsurf-custom" + mock.SetPath("windsurf", root+"/bin/windsurf") + mock.SetFile(root+"/resources/app.asar", []byte{}) + mock.SetFile(root+"/libffmpeg.so", []byte{}) + mock.SetFile(root+"/bin/windsurf", []byte{}) + mock.SetCommand("1.12.4\n", "", 0, root+"/bin/windsurf", "--version") + + found := findIDE(NewIDEDetector(mock).Detect(context.Background()), "windsurf") + if found == nil { + t.Fatal("expected Windsurf to be detected via the PATH fallback") + } + if found.Version != "1.12.4" { + t.Errorf("version = %q, want 1.12.4 from the CLI shim", found.Version) + } +} diff --git a/internal/detector/nodepm.go b/internal/detector/nodepm.go index 507ffb2a..3b517eff 100644 --- a/internal/detector/nodepm.go +++ b/internal/detector/nodepm.go @@ -60,16 +60,18 @@ func (d *NodePMDetector) DetectManagers(ctx context.Context) []model.PkgManager // the version without launching anything. version = versionmeta.FromBinary(ctx, d.exec, path) } - if path != "" && version == "" && !execguard.SafeToExec(ctx, d.exec, path) { - d.log.Warn("skipping %s version probe: quarantined and rejected by Gatekeeper", path) - } else if path != "" && version == "" { - // Run the exact absolute path the guard assessed, not the bare - // name — a PATH re-resolution at exec time could pick a - // different (unassessed) binary. - d.log.Progress("exec fallback: running %s %s (no metadata version source)", path, pm.VersionCmd) - stdout, _, _, err := d.exec.RunWithTimeout(ctx, 10*time.Second, path, pm.VersionCmd) - if err == nil { - version = strings.TrimSpace(stdout) + if path != "" && version == "" { + if safe, reason := execguard.SafeToExec(ctx, d.exec, path); !safe { + d.log.Warn("skipping %s version probe: %s", path, reason) + } else { + // Run the exact absolute path the guard assessed, not the bare + // name — a PATH re-resolution at exec time could pick a + // different (unassessed) binary. + d.log.Progress("exec fallback: running %s %s (no metadata version source)", path, pm.VersionCmd) + stdout, _, _, err := d.exec.RunWithTimeout(ctx, 10*time.Second, path, pm.VersionCmd) + if err == nil { + version = strings.TrimSpace(stdout) + } } } diff --git a/internal/detector/nodepm_fallback.go b/internal/detector/nodepm_fallback.go index a1d8707e..04c94507 100644 --- a/internal/detector/nodepm_fallback.go +++ b/internal/detector/nodepm_fallback.go @@ -171,8 +171,8 @@ func runPMVersion(ctx context.Context, exec executor.Executor, log *progress.Log if v := versionmeta.FromBinary(ctx, exec, binPath); v != "" { return v } - if !execguard.SafeToExec(ctx, exec, binPath) { - log.Warn("skipping %s version probe: quarantined and rejected by Gatekeeper", binPath) + if safe, reason := execguard.SafeToExec(ctx, exec, binPath); !safe { + log.Warn("skipping %s version probe: %s", binPath, reason) return "" } log.Progress("exec fallback: running %s %s (no metadata version source)", binPath, versionCmd) diff --git a/internal/detector/pythonpm.go b/internal/detector/pythonpm.go index 41c99045..79ee1b84 100644 --- a/internal/detector/pythonpm.go +++ b/internal/detector/pythonpm.go @@ -62,8 +62,8 @@ func (d *PythonPMDetector) DetectManagers(ctx context.Context) []model.PkgManage // layouts carry the version in the install path. if v := versionmeta.FromBinary(ctx, d.exec, path); v != "" { version = v - } else if !execguard.SafeToExec(ctx, d.exec, path) { - d.log.Warn("skipping %s version probe: quarantined and rejected by Gatekeeper", path) + } else if safe, reason := execguard.SafeToExec(ctx, d.exec, path); !safe { + d.log.Warn("skipping %s version probe: %s", path, reason) } else { // Run the exact absolute path the guard assessed, not the bare // name — a PATH re-resolution at exec time could pick a diff --git a/internal/execguard/execguard.go b/internal/execguard/execguard.go index 38407633..090ba948 100644 --- a/internal/execguard/execguard.go +++ b/internal/execguard/execguard.go @@ -10,6 +10,11 @@ // would happen, so version probes can skip the exec (reporting "unknown") // instead of triggering it. This generalizes the existing IsAppleCLTStub // guard (which prevents the analogous Command Line Tools install prompt). +// +// On Linux the cause differs and the effect is the same: a packaged Electron +// app does not implement --version (only the unpackaged `electron` binary's +// default app does), so the flag is ignored and its window opens on the user's +// desktop. Reported by an Ubuntu 22.04 customer via LM Studio. package execguard import ( @@ -24,7 +29,11 @@ import ( const probeTimeout = 5 * time.Second // SafeToExec reports whether launching binaryPath is safe from a -// GUI-popup perspective. Non-macOS platforms always return true. +// GUI-popup perspective, and when it is not, why. Windows always returns true. +// +// The reason is returned rather than left to callers to describe: they log it, +// and the cause is platform-specific, so a hardcoded message goes stale the +// moment a platform is added. It is "" when safe. // // On macOS it resolves symlinks, then checks the binary and its containing // directory for the com.apple.quarantine attribute (cask installs quarantine @@ -37,19 +46,68 @@ const probeTimeout = 5 * time.Second // Both probes execute only Apple-provided utilities (/usr/bin/xattr, // /usr/sbin/spctl), which carry none of the third-party-binary risk this // package exists to avoid. -func SafeToExec(ctx context.Context, exec executor.Executor, binaryPath string) bool { - if exec.GOOS() != model.PlatformDarwin || binaryPath == "" { - return true +// +// On Linux it answers whether the binary is an Electron app's GUI entry point, +// purely from stats. +func SafeToExec(ctx context.Context, exec executor.Executor, binaryPath string) (bool, string) { + if binaryPath == "" { + return true, "" } resolved, err := exec.EvalSymlinks(binaryPath) if err != nil || resolved == "" { resolved = binaryPath } - if !isQuarantined(ctx, exec, resolved) && !isQuarantined(ctx, exec, parentDir(resolved)) { - return true + + switch exec.GOOS() { + case model.PlatformLinux: + if isElectronAppEntryPoint(exec, resolved) { + return false, "it is a packaged Electron app's entry point, which would open its window instead of printing a version" + } + return true, "" + case model.PlatformDarwin: + if !isQuarantined(ctx, exec, resolved) && !isQuarantined(ctx, exec, parentDir(resolved)) { + return true, "" + } + _, _, exitCode, err := exec.RunWithTimeout(ctx, probeTimeout, "/usr/sbin/spctl", "--assess", "--type", "execute", resolved) + if err == nil && exitCode == 0 { + return true, "" + } + return false, "it is quarantined and Gatekeeper rejected it" + default: + return true, "" } - _, _, exitCode, err := exec.RunWithTimeout(ctx, probeTimeout, "/usr/sbin/spctl", "--assess", "--type", "execute", resolved) - return err == nil && exitCode == 0 +} + +// electronBundleMarkers are files an Electron app ships beside its executable +// and nothing else ships: the packed app archive and the Chromium runtime. +var electronBundleMarkers = []string{ + "resources/app.asar", + "libffmpeg.so", + "chrome_100_percent.pak", + "icudtl.dat", +} + +// isElectronAppEntryPoint reports whether resolved is the GUI executable at +// the root of an Electron app tree. +// +// Only the binary's OWN directory is examined, which is what separates the app +// from its CLI: /usr/share/code/code sits beside libffmpeg.so, while the shim +// at /usr/share/code/bin/code does not. Checking the parent too — as the macOS +// quarantine probe must, since cask installs mark whole trees — would reject +// exactly the shims we need. +// +// Electron-only is deliberate: a GTK or Qt app on $PATH needs its own signal. +func isElectronAppEntryPoint(exec executor.Executor, resolved string) bool { + dir := parentDir(resolved) + if dir == "" { + return false + } + for _, marker := range electronBundleMarkers { + if exec.FileExists(dir + "/" + marker) { + return true + } + } + return false } // isQuarantined reports whether path carries the com.apple.quarantine diff --git a/internal/execguard/execguard_test.go b/internal/execguard/execguard_test.go index 0e4a8260..99268bf7 100644 --- a/internal/execguard/execguard_test.go +++ b/internal/execguard/execguard_test.go @@ -2,7 +2,9 @@ package execguard import ( "context" + "strings" "testing" + "time" "github.com/step-security/dev-machine-guard/internal/executor" ) @@ -24,7 +26,7 @@ func TestSafeToExec(t *testing.T) { mock := executor.NewMock() mock.SetSymlink(binary, resolved) // xattr unstubbed -> errors -> attribute absent. - if !SafeToExec(context.Background(), mock, binary) { + if safe, _ := SafeToExec(context.Background(), mock, binary); !safe { t.Error("unquarantined binary should be safe to exec") } }) @@ -34,7 +36,7 @@ func TestSafeToExec(t *testing.T) { mock.SetSymlink(binary, resolved) quarantineStub(mock, resolved) mock.SetCommand("", "rejected", 3, "/usr/sbin/spctl", spctlArgs...) - if SafeToExec(context.Background(), mock, binary) { + if safe, _ := SafeToExec(context.Background(), mock, binary); safe { t.Error("quarantined + Gatekeeper-rejected binary must not be exec'd") } }) @@ -44,7 +46,7 @@ func TestSafeToExec(t *testing.T) { mock.SetSymlink(binary, resolved) quarantineStub(mock, resolved) mock.SetCommand("accepted", "", 0, "/usr/sbin/spctl", spctlArgs...) - if !SafeToExec(context.Background(), mock, binary) { + if safe, _ := SafeToExec(context.Background(), mock, binary); !safe { t.Error("quarantined but notarized (spctl-accepted) binary should be safe") } }) @@ -55,7 +57,7 @@ func TestSafeToExec(t *testing.T) { // Binary itself clean (partially-cleared install), containing dir quarantined. quarantineStub(mock, "/opt/homebrew/Caskroom/cursor-cli/2026.03.11") mock.SetCommand("", "rejected", 3, "/usr/sbin/spctl", spctlArgs...) - if SafeToExec(context.Background(), mock, binary) { + if safe, _ := SafeToExec(context.Background(), mock, binary); safe { t.Error("quarantined install dir must trigger assessment and reject") } }) @@ -65,7 +67,7 @@ func TestSafeToExec(t *testing.T) { mock.SetSymlink(binary, resolved) quarantineStub(mock, resolved) // spctl unstubbed -> errors -> treat as rejected. - if SafeToExec(context.Background(), mock, binary) { + if safe, _ := SafeToExec(context.Background(), mock, binary); safe { t.Error("quarantined binary with failing spctl must not be exec'd") } }) @@ -75,15 +77,169 @@ func TestSafeToExec(t *testing.T) { mock := executor.NewMock() mock.SetGOOS(goos) quarantineStub(mock, binary) - if !SafeToExec(context.Background(), mock, binary) { + if safe, _ := SafeToExec(context.Background(), mock, binary); !safe { t.Errorf("GOOS=%s: quarantine is a macOS concept; must be safe", goos) } } }) t.Run("empty path is safe", func(t *testing.T) { - if !SafeToExec(context.Background(), executor.NewMock(), "") { + if safe, _ := SafeToExec(context.Background(), executor.NewMock(), ""); !safe { t.Error("empty path should be a no-op (safe)") } }) } + +// Linux: Electron app entry points. The distinction that matters is app-root +// vs. CLI-shim, and it is visible on disk — the shim lives one directory down, +// beside none of the bundle files. +func linuxMock(files ...string) *executor.Mock { + mock := executor.NewMock() + mock.SetGOOS("linux") + for _, f := range files { + mock.SetFile(f, []byte{}) + } + return mock +} + +func TestSafeToExec_Linux(t *testing.T) { + tests := []struct { + name string + binary string + symlink string // resolved target, "" for none + files []string + want bool + }{ + { + name: "LM Studio's .deb launcher is refused", + binary: "/usr/bin/lm-studio", + symlink: "/opt/LM Studio/lm-studio", + files: []string{"/opt/LM Studio/resources/app.asar", "/opt/LM Studio/libffmpeg.so"}, + want: false, + }, + { + // The case the guard must not break. + name: "a VS Code fork's CLI shim is allowed", + binary: "/usr/share/code/bin/code", + files: []string{"/usr/share/code/resources/app.asar", "/usr/share/code/libffmpeg.so"}, + want: true, + }, + { + name: "the same fork's GUI binary is refused", + binary: "/usr/share/code/code", + files: []string{"/usr/share/code/resources/app.asar", "/usr/share/code/libffmpeg.so"}, + want: false, + }, + { + name: "Chromium runtime data alone is enough to identify a bundle", + binary: "/opt/Some App/some-app", + files: []string{"/opt/Some App/icudtl.dat"}, + want: false, + }, + { + name: "an ordinary CLI in /usr/bin is allowed", + binary: "/usr/bin/ollama", + want: true, + }, + { + name: "a CLI beside other CLIs is allowed", + binary: "/usr/local/bin/ollama", + files: []string{"/usr/local/bin/lm-studio", "/usr/local/bin/code"}, + want: true, + }, + { + name: "a bare binary name with no directory is allowed", + binary: "ollama", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := linuxMock(tt.files...) + if tt.symlink != "" { + mock.SetSymlink(tt.binary, tt.symlink) + } + if got, _ := SafeToExec(context.Background(), mock, tt.binary); got != tt.want { + t.Errorf("SafeToExec(%q) = %v, want %v", tt.binary, got, tt.want) + } + }) + } +} + +// The Linux arm must reach for no subprocess at all. +func TestSafeToExec_LinuxLaunchesNothing(t *testing.T) { + mock := linuxMock("/opt/LM Studio/resources/app.asar") + mock.SetSymlink("/usr/bin/lm-studio", "/opt/LM Studio/lm-studio") + trap := &trapExecutor{Mock: mock, t: t} + + if safe, _ := SafeToExec(context.Background(), trap, "/usr/bin/lm-studio"); safe { + t.Error("Electron app entry point must be refused") + } +} + +type trapExecutor struct { + *executor.Mock + t *testing.T +} + +func (e *trapExecutor) Run(_ context.Context, name string, args ...string) (string, string, int, error) { + e.t.Fatalf("unexpected exec: %s %v", name, args) + return "", "", -1, nil +} + +func (e *trapExecutor) RunWithTimeout(ctx context.Context, _ time.Duration, name string, args ...string) (string, string, int, error) { + return e.Run(ctx, name, args...) +} + +// Windows is unchanged. +func TestSafeToExec_WindowsAlwaysSafe(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + mock.SetFile(`C:\Program Files\LM Studio\resources\app.asar`, []byte{}) + if safe, _ := SafeToExec(context.Background(), mock, `C:\Program Files\LM Studio\LM Studio.exe`); !safe { + t.Error("Windows must be unaffected") + } +} + +// The refusal reason is returned rather than written at each call site, because +// ten callers log it and a hardcoded string went stale the moment Linux gained +// a verdict — every Linux refusal claimed Gatekeeper quarantine. +func TestSafeToExec_ReasonMatchesPlatform(t *testing.T) { + t.Run("linux names the Electron app, not Gatekeeper", func(t *testing.T) { + mock := linuxMock("/opt/LM Studio/resources/app.asar") + mock.SetSymlink("/usr/bin/lm-studio", "/opt/LM Studio/lm-studio") + + safe, reason := SafeToExec(context.Background(), mock, "/usr/bin/lm-studio") + if safe { + t.Fatal("expected refusal") + } + if !strings.Contains(reason, "Electron") { + t.Errorf("reason = %q, want it to name the Electron app", reason) + } + if strings.Contains(reason, "Gatekeeper") || strings.Contains(reason, "quarantine") { + t.Errorf("reason = %q, must not claim macOS quarantine on linux", reason) + } + }) + + t.Run("darwin names Gatekeeper", func(t *testing.T) { + mock := executor.NewMock() + mock.SetSymlink(binary, resolved) + quarantineStub(mock, resolved) + mock.SetCommand("", "rejected", 3, "/usr/sbin/spctl", "--assess", "--type", "execute", resolved) + + safe, reason := SafeToExec(context.Background(), mock, binary) + if safe { + t.Fatal("expected refusal") + } + if !strings.Contains(reason, "Gatekeeper") { + t.Errorf("reason = %q, want it to name Gatekeeper", reason) + } + }) + + t.Run("no reason when safe", func(t *testing.T) { + if safe, reason := SafeToExec(context.Background(), linuxMock(), "/usr/bin/ollama"); !safe || reason != "" { + t.Errorf("SafeToExec = (%v, %q), want (true, \"\")", safe, reason) + } + }) +} diff --git a/internal/versionmeta/linux.go b/internal/versionmeta/linux.go new file mode 100644 index 00000000..32e1e97b --- /dev/null +++ b/internal/versionmeta/linux.go @@ -0,0 +1,186 @@ +// Linux static version sources: package database, snap manifest, AppImage +// filename. Nothing here launches the tool. + +package versionmeta + +import ( + "strings" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +const ( + dpkgStatusPath = "/var/lib/dpkg/status" + dpkgInfoDir = "/var/lib/dpkg/info" +) + +// versionFromDpkg returns the version of the Debian/Ubuntu package that owns +// one of paths. Read straight off the dpkg database, no dpkg-query. +// +// Ownership is proved from the package's own file manifest, never assumed +// from a matching name: a stale `foo` package and a hand-installed +// /usr/local/bin/foo coexist happily and their versions differ. Only packages +// named exactly `base` are considered — scanning every *.list means reading +// thousands of files. +func versionFromDpkg(exec executor.Executor, base string, paths []string) string { + if base == "" { + return "" + } + if v := dpkgVersionIfOwned(exec, dpkgInfoDir+"/"+base+".list", base, paths); v != "" { + return v + } + + // Multi-Arch: same records the file list as `:.list`. Finding + // those needs a glob over a directory holding tens of thousands of entries + // on Ubuntu, so it only runs when the plain name missed. + entries, err := exec.Glob(dpkgInfoDir + "/" + base + ":*.list") + if err != nil { + return "" + } + for _, manifest := range entries { + if v := dpkgVersionIfOwned(exec, manifest, base, paths); v != "" { + return v + } + } + return "" +} + +// dpkgVersionIfOwned returns pkg's version if the manifest lists one of paths. +func dpkgVersionIfOwned(exec executor.Executor, manifest, pkg string, paths []string) string { + data, err := exec.ReadFile(manifest) + if err != nil { + return "" + } + if !dpkgManifestLists(string(data), paths) { + return "" + } + return dpkgStatusVersion(exec, pkg) +} + +// dpkgManifestLists reports whether a `.list` manifest (one absolute path per +// line) names any of paths. dpkg lists a packaged symlink and its target, so +// callers can pass the binary as found and its resolved form. +func dpkgManifestLists(manifest string, paths []string) bool { + for _, line := range strings.Split(manifest, "\n") { + line = strings.TrimRight(line, "\r") + for _, p := range paths { + if p != "" && line == p { + return true + } + } + } + return false +} + +// dpkgStatusVersion reads the Version field of pkg's blank-line-delimited +// stanza in /var/lib/dpkg/status. Only "installed" counts: dpkg keeps stanzas +// for purged packages, whose version describes nothing on disk. +func dpkgStatusVersion(exec executor.Executor, pkg string) string { + data, err := exec.ReadFile(dpkgStatusPath) + if err != nil { + return "" + } + var name, version string + var installed bool + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimRight(line, "\r") + if line == "" { // stanza boundary + if name == pkg && installed { + return normalizeDebianVersion(version) + } + name, version, installed = "", "", false + continue + } + switch { + case strings.HasPrefix(line, "Package:"): + name = strings.TrimSpace(strings.TrimPrefix(line, "Package:")) + case strings.HasPrefix(line, "Version:"): + version = strings.TrimSpace(strings.TrimPrefix(line, "Version:")) + case strings.HasPrefix(line, "Status:"): + installed = strings.HasSuffix(strings.TrimSpace(line), " installed") + } + } + if name == pkg && installed { // file not newline-terminated + return normalizeDebianVersion(version) + } + return "" +} + +// normalizeDebianVersion reduces [epoch:]upstream[-revision] to the upstream +// part, matching what the tool reports about itself: "1:0.3.31-2" -> "0.3.31". +// Per Debian policy the revision is everything after the last hyphen, so this +// is exact rather than heuristic. +func normalizeDebianVersion(v string) string { + if i := strings.Index(v, ":"); i >= 0 { + v = v[i+1:] + } + if i := strings.LastIndex(v, "-"); i > 0 { + v = v[:i] + } + if !isVersionLike(v) { + return "" + } + return v +} + +// versionFromAppImage extracts the version from an AppImage filename +// (LM-Studio-0.3.31-x64.AppImage -> 0.3.31). A single-file install has no +// install tree and no package entry, so the filename is the only static +// source. The segments before the version must name the tool, so a symlink +// into someone else's AppImage can't lend its version. +func versionFromAppImage(resolved, base string) string { + segments := splitPath(resolved) + name := segments[len(segments)-1] + if !strings.HasSuffix(strings.ToLower(name), ".appimage") { + return "" + } + stem := name[:len(name)-len(".AppImage")] + + parts := strings.Split(stem, "-") + for i := 1; i < len(parts); i++ { + if !isVersionLike(parts[i]) { + continue + } + if matchesTool(strings.Join(parts[:i], "-"), base) { + return strings.TrimPrefix(parts[i], "v") + } + } + return "" +} + +// versionFromSnap reads the version from /snap//current/meta/snap.yaml. +// The path rules can't reach it: /snap/bin/ symlinks to the snap +// wrapper, so resolving it walks away from the install. +// +// That path is definitionally the right snap's manifest, so unlike dpkg no +// ownership proof is needed; `name:` is checked only against the directory it +// came from. The tool name is not compared — `snap run .` exposes +// apps under names that differ from the snap's own. +func versionFromSnap(exec executor.Executor, binaryPath string) string { + segments := splitPath(binaryPath) + if len(segments) < 3 || segments[0] != "snap" || segments[1] != "bin" { + return "" + } + snapName := segments[2] + if i := strings.Index(snapName, "."); i > 0 { + snapName = snapName[:i] + } + data, err := exec.ReadFile("/snap/" + snapName + "/current/meta/snap.yaml") + if err != nil { + return "" + } + + var name, version string + for _, line := range strings.Split(string(data), "\n") { + switch { + case strings.HasPrefix(line, "name:"): + name = strings.Trim(strings.TrimSpace(strings.TrimPrefix(line, "name:")), `"'`) + case strings.HasPrefix(line, "version:"): + version = strings.Trim(strings.TrimSpace(strings.TrimPrefix(line, "version:")), `"'`) + } + } + if name != snapName || !isVersionLike(version) { + return "" + } + return strings.TrimPrefix(version, "v") +} diff --git a/internal/versionmeta/linux_test.go b/internal/versionmeta/linux_test.go new file mode 100644 index 00000000..8aa3fde1 --- /dev/null +++ b/internal/versionmeta/linux_test.go @@ -0,0 +1,259 @@ +package versionmeta + +import ( + "context" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +// The Ubuntu box from the LM Studio report: the .deb's launcher on PATH, +// symlinked into the electron-builder install root. +func dpkgMock(list, status string) *executor.Mock { + mock := executor.NewMock() + mock.SetGOOS("linux") + mock.SetSymlink("/usr/bin/lm-studio", "/opt/LM Studio/lm-studio") + if list != "" { + mock.SetFile("/var/lib/dpkg/info/lm-studio.list", []byte(list)) + } + if status != "" { + mock.SetFile("/var/lib/dpkg/status", []byte(status)) + } + return mock +} + +const dpkgOwnedList = "/opt\n/opt/LM Studio\n/opt/LM Studio/lm-studio\n/usr/bin/lm-studio\n" + +func TestFromBinary_Dpkg(t *testing.T) { + tests := []struct { + name string + list string + status string + want string + }{ + { + name: "package owns the launcher", + list: dpkgOwnedList, + status: "Package: lm-studio\nStatus: install ok installed\nVersion: 0.3.31-1\n\n", + want: "0.3.31", + }, + { + // Matching either the PATH entry or its target is enough. + name: "matches the resolved target", + list: "/opt/LM Studio/lm-studio\n", + status: "Package: lm-studio\nStatus: install ok installed\nVersion: 0.3.31\n\n", + want: "0.3.31", + }, + { + // A stale deb and a hand-installed launcher coexist; lending one's + // version to the other is a silent wrong answer. + name: "same-named package that owns nothing is rejected", + list: "/usr/share/doc/lm-studio/copyright\n", + status: "Package: lm-studio\nStatus: install ok installed\nVersion: 0.2.9\n\n", + want: "", + }, + { + name: "purged package is rejected", + list: dpkgOwnedList, + status: "Package: lm-studio\nStatus: deinstall ok config-files\nVersion: 0.2.9\n\n", + want: "", + }, + { + name: "epoch is stripped", + list: dpkgOwnedList, + status: "Package: lm-studio\nStatus: install ok installed\nVersion: 2:0.3.31-1ubuntu2\n\n", + want: "0.3.31", + }, + { + // Neither first nor last, and no leaking across stanza boundaries. + name: "finds the stanza among others", + list: dpkgOwnedList, + status: "Package: zlib1g\nStatus: install ok installed\nVersion: 1:1.2.11.dfsg-2\n\n" + + "Package: lm-studio\nStatus: install ok installed\nVersion: 0.3.31-1\n\n" + + "Package: zsh\nStatus: install ok installed\nVersion: 5.8.1-1\n", + want: "0.3.31", + }, + { + name: "unparseable version falls through to the caller's fallback", + list: dpkgOwnedList, + status: "Package: lm-studio\nStatus: install ok installed\nVersion: nightly\n\n", + want: "", + }, + { + name: "no dpkg database at all", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FromBinary(context.Background(), dpkgMock(tt.list, tt.status), "/usr/bin/lm-studio") + if got != tt.want { + t.Errorf("FromBinary = %q, want %q", got, tt.want) + } + }) + } +} + +// /var/lib/dpkg on a Mac never describes what is installed there. +func TestFromBinary_DpkgIsLinuxOnly(t *testing.T) { + mock := dpkgMock(dpkgOwnedList, "Package: lm-studio\nStatus: install ok installed\nVersion: 0.3.31-1\n\n") + mock.SetGOOS("darwin") + + if got := FromBinary(context.Background(), mock, "/usr/bin/lm-studio"); got != "" { + t.Errorf("FromBinary = %q, want \"\" on darwin", got) + } +} + +// Multi-Arch: same packages record their file list under :.list. +func TestFromBinary_DpkgMultiarchManifest(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("linux") + mock.SetSymlink("/usr/bin/local-ai", "/usr/bin/local-ai") + mock.SetGlob("/var/lib/dpkg/info/local-ai:*.list", []string{"/var/lib/dpkg/info/local-ai:amd64.list"}) + mock.SetFile("/var/lib/dpkg/info/local-ai:amd64.list", []byte("/usr/bin/local-ai\n")) + mock.SetFile("/var/lib/dpkg/status", []byte( + "Package: local-ai\nStatus: install ok installed\nVersion: 2.24.1-3\nArchitecture: amd64\n\n")) + + if got := FromBinary(context.Background(), mock, "/usr/bin/local-ai"); got != "2.24.1" { + t.Errorf("FromBinary = %q, want 2.24.1", got) + } +} + +func TestFromBinary_AppImage(t *testing.T) { + tests := []struct { + name string + binary string + resolved string + want string + }{ + { + // The LM Studio install dpkg can't see: one file, no package entry. + name: "electron-builder naming", + binary: "/home/dev/.local/bin/lm-studio", + resolved: "/home/dev/Applications/LM-Studio-0.3.31-x64.AppImage", + want: "0.3.31", + }, + { + name: "debian-style revision after the version", + binary: "/home/dev/.local/bin/lm-studio", + resolved: "/home/dev/Applications/LM-Studio-0.3.31-1-x64.AppImage", + want: "0.3.31", + }, + { + name: "single-segment product name", + binary: "/usr/local/bin/cursor", + resolved: "/opt/appimages/Cursor-1.5.9-x86_64.AppImage", + want: "1.5.9", + }, + { + // "linux" must not be mistaken for a version. + name: "no version in the filename", + binary: "/usr/local/bin/nvim", + resolved: "/opt/appimages/nvim-linux-x86_64.AppImage", + want: "", + }, + { + name: "prefix names a different tool", + binary: "/usr/local/bin/lm-studio", + resolved: "/opt/appimages/Cursor-1.5.9-x86_64.AppImage", + want: "", + }, + { + name: "not an AppImage at all", + binary: "/usr/local/bin/lm-studio", + resolved: "/opt/lm-studio/LM-Studio-0.3.31-x64.bin", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("linux") + mock.SetSymlink(tt.binary, tt.resolved) + if got := FromBinary(context.Background(), mock, tt.binary); got != tt.want { + t.Errorf("FromBinary = %q, want %q", got, tt.want) + } + }) + } +} + +func TestFromBinary_Snap(t *testing.T) { + // /snap/bin/ symlinks to the snap wrapper, so resolving the binary + // walks away from the install — the manifest is the only way in. + snapMock := func(yaml string) *executor.Mock { + mock := executor.NewMock() + mock.SetGOOS("linux") + mock.SetSymlink("/snap/bin/local-ai", "/usr/bin/snap") + if yaml != "" { + mock.SetFile("/snap/local-ai/current/meta/snap.yaml", []byte(yaml)) + } + return mock + } + + tests := []struct { + name string + yaml string + want string + }{ + { + name: "manifest names this snap", + yaml: "name: local-ai\nversion: 2.24.1\nsummary: LocalAI\nbase: core22\n", + want: "2.24.1", + }, + { + name: "quoted version", + yaml: "name: local-ai\nversion: '2.24.1'\n", + want: "2.24.1", + }, + { + name: "manifest disagrees with its own directory", + yaml: "name: something-else\nversion: 9.9.9\n", + want: "", + }, + { + // Snap versions are free-form strings. + name: "non-version version string", + yaml: "name: local-ai\nversion: stable\n", + want: "", + }, + { + name: "no manifest", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := FromBinary(context.Background(), snapMock(tt.yaml), "/snap/bin/local-ai"); got != tt.want { + t.Errorf("FromBinary = %q, want %q", got, tt.want) + } + }) + } +} + +// `snap run .` exposes apps under a name that differs from the +// snap's own, so the tool name is not part of the check. +func TestFromBinary_SnapSecondaryApp(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("linux") + mock.SetSymlink("/snap/bin/local-ai.cli", "/usr/bin/snap") + mock.SetFile("/snap/local-ai/current/meta/snap.yaml", []byte("name: local-ai\nversion: 2.24.1\n")) + + if got := FromBinary(context.Background(), mock, "/snap/bin/local-ai.cli"); got != "2.24.1" { + t.Errorf("FromBinary = %q, want 2.24.1", got) + } +} + +// A snap alias has no directory of its own, so there is no manifest to read. +func TestFromBinary_SnapAliasHasNoManifest(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("linux") + mock.SetSymlink("/snap/bin/lai", "/usr/bin/snap") + mock.SetFile("/snap/local-ai/current/meta/snap.yaml", []byte("name: local-ai\nversion: 2.24.1\n")) + + if got := FromBinary(context.Background(), mock, "/snap/bin/lai"); got != "" { + t.Errorf("FromBinary = %q, want \"\"", got) + } +} diff --git a/internal/versionmeta/versionmeta.go b/internal/versionmeta/versionmeta.go index b1841610..5574f773 100644 --- a/internal/versionmeta/versionmeta.go +++ b/internal/versionmeta/versionmeta.go @@ -40,7 +40,15 @@ import ( // means the formula owns the binary, so is its version (npm-installed // binaries under a brew node live in node_modules and are claimed or // rejected by rule 1 before this can misfire). -// 4. macOS app bundle: CFBundleShortVersionString of the enclosing .app. +// 4. dpkg (Linux): the version of the package whose own file manifest lists +// this path. A binary dpkg still owns but that self-updated in place +// reports the packaged version; rules 1-3 claim the layouts where that +// happens before this is reached. +// 5. snap (Linux): the `version` field of the snap manifest, which the path +// rules can't reach (/snap/bin/ symlinks to the snap wrapper). +// 6. AppImage (Linux): the version in the filename, the only static source a +// single-file install has. +// 7. macOS app bundle: CFBundleShortVersionString of the enclosing .app. func FromBinary(ctx context.Context, exec executor.Executor, binaryPath string) string { if binaryPath == "" { return "" @@ -67,6 +75,17 @@ func FromBinary(ctx context.Context, exec executor.Executor, binaryPath string) if v := versionFromHomebrew(resolved); v != "" { return v } + if exec.GOOS() == model.PlatformLinux { + if v := versionFromDpkg(exec, base, []string{binaryPath, resolved}); v != "" { + return v + } + if v := versionFromSnap(exec, binaryPath); v != "" { + return v + } + if v := versionFromAppImage(resolved, base); v != "" { + return v + } + } if exec.GOOS() == model.PlatformDarwin { if v := versionFromAppBundle(ctx, exec, resolved); v != "" { return v