From c8c2b6d937eafbff442bb59d246e75ef59f1b359 Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Fri, 17 Jul 2026 23:40:14 +0530 Subject: [PATCH 1/2] fix(brew): detect Homebrew installed outside PATH Resolve the brew executable from standard install locations (/opt/homebrew, /usr/local, /home/linuxbrew) when it is not on PATH, and read the version from the Homebrew git repo metadata (describe-cache / packed-refs) instead of shelling out to `brew --version`. Detection and formula/cask listing now work for scans run without the user's interactive PATH. --- internal/detector/brew.go | 214 ++++++++++++++++++++++++++++++--- internal/detector/brew_test.go | 94 ++++++++++++++- 2 files changed, 291 insertions(+), 17 deletions(-) diff --git a/internal/detector/brew.go b/internal/detector/brew.go index b441be2..f2ee2d4 100644 --- a/internal/detector/brew.go +++ b/internal/detector/brew.go @@ -3,6 +3,9 @@ package detector import ( "context" "encoding/json" + "fmt" + "path/filepath" + "regexp" "strings" "time" @@ -10,6 +13,19 @@ import ( "github.com/step-security/dev-machine-guard/internal/model" ) +var brewExecutableCandidates = []string{ + "/opt/homebrew/bin/brew", + "/usr/local/bin/brew", + "/home/linuxbrew/.linuxbrew/bin/brew", +} + +type brewExecutable struct { + path string + command string +} + +var brewVersionTagPattern = regexp.MustCompile(`^\d+\.\d+\.\d+(?:[-_A-Za-z0-9.]*)?$`) + // BrewDetector detects Homebrew installation and packages. type BrewDetector struct { exec executor.Executor @@ -22,30 +38,25 @@ func NewBrewDetector(exec executor.Executor) *BrewDetector { // DetectBrew checks if Homebrew is installed and returns its version info. // Returns nil if Homebrew is not found. func (d *BrewDetector) DetectBrew(ctx context.Context) *model.PkgManager { - path, err := d.exec.LookPath("brew") + brew, err := resolveBrewExecutable(d.exec) if err != nil { return nil } - version := "unknown" - stdout, _, _, err := d.exec.RunWithTimeout(ctx, 10*time.Second, "brew", "--version") - if err == nil { - // "brew --version" outputs "Homebrew 4.3.5\n..." - if line := firstLine(stdout); line != "" { - version = strings.TrimPrefix(line, "Homebrew ") - } - } - return &model.PkgManager{ Name: "homebrew", - Version: version, - Path: path, + Version: readBrewVersion(d.exec, brew.path), + Path: brew.path, } } // ListFormulae returns installed Homebrew formulae with versions. func (d *BrewDetector) ListFormulae(ctx context.Context) []model.BrewPackage { - stdout, _, _, err := d.exec.RunWithTimeout(ctx, 30*time.Second, "brew", "list", "--formula", "--versions") + brew, err := resolveBrewExecutable(d.exec) + if err != nil { + return nil + } + stdout, _, _, err := d.exec.RunWithTimeout(ctx, 30*time.Second, brew.command, "list", "--formula", "--versions") if err != nil { return nil } @@ -54,7 +65,11 @@ func (d *BrewDetector) ListFormulae(ctx context.Context) []model.BrewPackage { // ListCasks returns installed Homebrew casks with versions. func (d *BrewDetector) ListCasks(ctx context.Context) []model.BrewPackage { - stdout, _, _, err := d.exec.RunWithTimeout(ctx, 30*time.Second, "brew", "list", "--cask", "--versions") + brew, err := resolveBrewExecutable(d.exec) + if err != nil { + return nil + } + stdout, _, _, err := d.exec.RunWithTimeout(ctx, 30*time.Second, brew.command, "list", "--cask", "--versions") if err != nil { return nil } @@ -98,9 +113,14 @@ func parseBrewList(stdout string) []model.BrewPackage { // Falls back gracefully: if JSON command fails, reads receipts only. // If receipts fail, falls back to basic `brew list --versions`. func (d *BrewDetector) ListFormulaeRich(ctx context.Context) []model.BrewPackage { + brew, err := resolveBrewExecutable(d.exec) + if err != nil { + return nil + } + // Try brew info --json=v2 first (gets everything in one shot) stdout, _, exitCode, err := d.exec.RunWithTimeout(ctx, 60*time.Second, - "brew", "info", "--json=v2", "--installed", "--formula") + brew.command, "info", "--json=v2", "--installed", "--formula") if err == nil && exitCode == 0 { pkgs, parseErr := parseBrewInfoJSON(stdout, "formula") if parseErr == nil && len(pkgs) > 0 { @@ -122,8 +142,13 @@ func (d *BrewDetector) ListFormulaeRich(ctx context.Context) []model.BrewPackage // ListCasksRich returns installed casks with metadata. // Same strategy as ListFormulaeRich. func (d *BrewDetector) ListCasksRich(ctx context.Context) []model.BrewPackage { + brew, err := resolveBrewExecutable(d.exec) + if err != nil { + return nil + } + stdout, _, exitCode, err := d.exec.RunWithTimeout(ctx, 60*time.Second, - "brew", "info", "--json=v2", "--installed", "--cask") + brew.command, "info", "--json=v2", "--installed", "--cask") if err == nil && exitCode == 0 { pkgs, parseErr := parseBrewInfoJSON(stdout, "cask") if parseErr == nil && len(pkgs) > 0 { @@ -207,6 +232,163 @@ func (d *BrewDetector) brewPrefix() string { return "" } +func resolveBrewExecutable(exec executor.Executor) (brewExecutable, error) { + if path, err := exec.LookPath("brew"); err == nil { + return brewExecutable{path: path, command: "brew"}, nil + } + for _, path := range brewExecutableCandidates { + if exec.FileExists(path) { + return brewExecutable{path: path, command: path}, nil + } + } + return brewExecutable{}, fmt.Errorf("brew not found in PATH or standard locations") +} + +func readBrewVersion(exec executor.Executor, brewPath string) string { + for _, repo := range brewRepositoryCandidates(exec, brewPath) { + if version := readBrewVersionFromRepository(exec, repo); version != "" { + return version + } + } + return "unknown" +} + +func brewRepositoryCandidates(exec executor.Executor, brewPath string) []string { + var candidates []string + add := func(path string) { + path = filepath.Clean(path) + if path == "." || path == "/" { + return + } + for _, existing := range candidates { + if existing == path { + return + } + } + candidates = append(candidates, path) + } + addFromBrewPath := func(path string) { + if path == "" { + return + } + binParent := filepath.Clean(filepath.Join(filepath.Dir(path), "..")) + add(binParent) + add(filepath.Join(binParent, "Homebrew")) + } + + addFromBrewPath(brewPath) + if resolved, err := exec.EvalSymlinks(brewPath); err == nil { + addFromBrewPath(resolved) + } + for _, prefix := range []string{"/opt/homebrew", "/usr/local", "/home/linuxbrew/.linuxbrew"} { + add(prefix) + add(filepath.Join(prefix, "Homebrew")) + } + return candidates +} + +func readBrewVersionFromRepository(exec executor.Executor, repo string) string { + gitDir := filepath.Join(repo, ".git") + if data, err := exec.ReadFile(gitDir); err == nil { + if parsed, ok := strings.CutPrefix(strings.TrimSpace(string(data)), "gitdir:"); ok { + gitDir = strings.TrimSpace(parsed) + if !filepath.IsAbs(gitDir) { + gitDir = filepath.Clean(filepath.Join(repo, gitDir)) + } + } + } + + revision := readGitRevision(exec, gitDir) + if revision == "" { + return "" + } + + if data, err := exec.ReadFile(filepath.Join(gitDir, "describe-cache", revision)); err == nil { + version := strings.TrimSpace(string(data)) + if version != "" && !strings.Contains(version, "-dirty") { + return version + } + } + + return readExactTagForRevision(exec, gitDir, revision) +} + +func readGitRevision(exec executor.Executor, gitDir string) string { + data, err := exec.ReadFile(filepath.Join(gitDir, "HEAD")) + if err != nil { + return "" + } + head := strings.TrimSpace(string(data)) + if head == "" { + return "" + } + ref, ok := strings.CutPrefix(head, "ref: ") + if !ok { + return head + } + ref = strings.TrimSpace(ref) + if data, err := exec.ReadFile(filepath.Join(gitDir, filepath.FromSlash(ref))); err == nil { + return strings.TrimSpace(string(data)) + } + return readPackedRef(exec, gitDir, ref) +} + +func readExactTagForRevision(exec executor.Executor, gitDir, revision string) string { + data, err := exec.ReadFile(filepath.Join(gitDir, "packed-refs")) + if err != nil { + return "" + } + + var annotatedTag string + for _, rawLine := range strings.Split(string(data), "\n") { + line := strings.TrimSpace(rawLine) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if peeled, ok := strings.CutPrefix(line, "^"); ok { + if peeled == revision && brewVersionTagPattern.MatchString(annotatedTag) { + return annotatedTag + } + annotatedTag = "" + continue + } + + fields := strings.Fields(line) + if len(fields) != 2 { + annotatedTag = "" + continue + } + tag, ok := strings.CutPrefix(fields[1], "refs/tags/") + if !ok { + annotatedTag = "" + continue + } + if fields[0] == revision && brewVersionTagPattern.MatchString(tag) { + return tag + } + annotatedTag = tag + } + return "" +} + +func readPackedRef(exec executor.Executor, gitDir, ref string) string { + data, err := exec.ReadFile(filepath.Join(gitDir, "packed-refs")) + if err != nil { + return "" + } + for _, rawLine := range strings.Split(string(data), "\n") { + line := strings.TrimSpace(rawLine) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "^") { + continue + } + fields := strings.Fields(line) + if len(fields) == 2 && fields[1] == ref { + return fields[0] + } + } + return "" +} + // brewReceipt represents the INSTALL_RECEIPT.json structure. type brewReceipt struct { Time int64 `json:"time"` diff --git a/internal/detector/brew_test.go b/internal/detector/brew_test.go index 28579c4..ed22ee2 100644 --- a/internal/detector/brew_test.go +++ b/internal/detector/brew_test.go @@ -14,10 +14,16 @@ func newTestLogger() *progress.Logger { return progress.NewNoop() } +func stubBrewGitVersion(mock *executor.Mock, repo, revision, version string) { + mock.SetFile(repo+"/.git/HEAD", []byte("ref: refs/heads/stable\n")) + mock.SetFile(repo+"/.git/refs/heads/stable", []byte(revision+"\n")) + mock.SetFile(repo+"/.git/describe-cache/"+revision, []byte(version+"\n")) +} + func TestBrewDetector_Found(t *testing.T) { mock := executor.NewMock() mock.SetPath("brew", "/opt/homebrew/bin/brew") - mock.SetCommand("Homebrew 4.3.5\nHomebrew/homebrew-core (git revision abc123)\n", "", 0, "brew", "--version") + stubBrewGitVersion(mock, "/opt/homebrew", "abc123", "4.3.5") det := NewBrewDetector(mock) result := det.DetectBrew(context.Background()) @@ -36,6 +42,76 @@ func TestBrewDetector_Found(t *testing.T) { } } +func TestBrewDetector_FoundAtStandardPathOutsidePATH(t *testing.T) { + mock := executor.NewMock() + mock.SetFile("/opt/homebrew/bin/brew", []byte{}) + stubBrewGitVersion(mock, "/opt/homebrew", "abc123", "4.3.5") + + det := NewBrewDetector(mock) + result := det.DetectBrew(context.Background()) + + if result == nil { + t.Fatal("expected brew to be detected") + } + if result.Version != "4.3.5" { + t.Errorf("expected version 4.3.5, got %s", result.Version) + } + if result.Path != "/opt/homebrew/bin/brew" { + t.Errorf("expected path /opt/homebrew/bin/brew, got %s", result.Path) + } +} + +func TestBrewDetector_FoundAtHomebrewRepositoryLayout(t *testing.T) { + mock := executor.NewMock() + mock.SetFile("/usr/local/bin/brew", []byte{}) + stubBrewGitVersion(mock, "/usr/local/Homebrew", "abc123", "4.3.5") + + det := NewBrewDetector(mock) + result := det.DetectBrew(context.Background()) + + if result == nil { + t.Fatal("expected brew to be detected") + } + if result.Version != "4.3.5" { + t.Errorf("expected version 4.3.5, got %s", result.Version) + } + if result.Path != "/usr/local/bin/brew" { + t.Errorf("expected path /usr/local/bin/brew, got %s", result.Path) + } +} + +func TestBrewDetector_VersionFromPackedTag(t *testing.T) { + mock := executor.NewMock() + mock.SetFile("/opt/homebrew/bin/brew", []byte{}) + mock.SetFile("/opt/homebrew/.git/HEAD", []byte("ref: refs/heads/stable\n")) + mock.SetFile("/opt/homebrew/.git/packed-refs", []byte("abc123 refs/heads/stable\nabc123 refs/tags/4.3.5\n")) + + det := NewBrewDetector(mock) + result := det.DetectBrew(context.Background()) + + if result == nil { + t.Fatal("expected brew to be detected") + } + if result.Version != "4.3.5" { + t.Errorf("expected version 4.3.5, got %s", result.Version) + } +} + +func TestBrewDetector_UnknownVersionWhenGitMetadataUnavailable(t *testing.T) { + mock := executor.NewMock() + mock.SetFile("/opt/homebrew/bin/brew", []byte{}) + + det := NewBrewDetector(mock) + result := det.DetectBrew(context.Background()) + + if result == nil { + t.Fatal("expected brew to be detected") + } + if result.Version != "unknown" { + t.Errorf("expected unknown version, got %s", result.Version) + } +} + func TestBrewDetector_NotFound(t *testing.T) { mock := executor.NewMock() det := NewBrewDetector(mock) @@ -62,6 +138,22 @@ func TestBrewDetector_ListFormulae(t *testing.T) { } } +func TestBrewDetector_ListFormulaeAtStandardPathOutsidePATH(t *testing.T) { + mock := executor.NewMock() + mock.SetFile("/opt/homebrew/bin/brew", []byte{}) + mock.SetCommand("curl 8.4.0\ngit 2.43.0\n", "", 0, "/opt/homebrew/bin/brew", "list", "--formula", "--versions") + + det := NewBrewDetector(mock) + formulae := det.ListFormulae(context.Background()) + + if len(formulae) != 2 { + t.Fatalf("expected 2 formulae, got %d", len(formulae)) + } + if formulae[0].Name != "curl" || formulae[0].Version != "8.4.0" { + t.Errorf("unexpected first formula: %+v", formulae[0]) + } +} + func TestBrewDetector_ListCasks(t *testing.T) { mock := executor.NewMock() mock.SetPath("brew", "/opt/homebrew/bin/brew") From e92bd4769cbbd9d923ffca1e357f1b34120de9e4 Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Sat, 18 Jul 2026 12:29:59 +0530 Subject: [PATCH 2/2] =?UTF-8?q?fix(brew):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20use=20resolved=20path,=20normalize=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolveBrewExecutable now uses the resolved absolute path as the command even when found via PATH, so all invocations are PATH-independent. - describe-cache version is reduced to its base tag and validated against brewVersionTagPattern, so strings like "4.3.5-17-g" no longer leak into PkgManager.Version. --- internal/detector/brew.go | 12 ++++++++++-- internal/detector/brew_rich_test.go | 12 ++++++------ internal/detector/brew_test.go | 4 ++-- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/internal/detector/brew.go b/internal/detector/brew.go index f2ee2d4..06b0059 100644 --- a/internal/detector/brew.go +++ b/internal/detector/brew.go @@ -234,7 +234,7 @@ func (d *BrewDetector) brewPrefix() string { func resolveBrewExecutable(exec executor.Executor) (brewExecutable, error) { if path, err := exec.LookPath("brew"); err == nil { - return brewExecutable{path: path, command: "brew"}, nil + return brewExecutable{path: path, command: path}, nil } for _, path := range brewExecutableCandidates { if exec.FileExists(path) { @@ -306,7 +306,15 @@ func readBrewVersionFromRepository(exec executor.Executor, repo string) string { if data, err := exec.ReadFile(filepath.Join(gitDir, "describe-cache", revision)); err == nil { version := strings.TrimSpace(string(data)) if version != "" && !strings.Contains(version, "-dirty") { - return version + // describe-cache may be a bare tag ("4.3.5") or a commits-past-tag + // describe ("4.3.5-17-g"); keep the base tag so non-semver + // noise never leaks into PkgManager.Version. + if base, _, ok := strings.Cut(version, "-"); ok { + version = base + } + if brewVersionTagPattern.MatchString(version) { + return version + } } } diff --git a/internal/detector/brew_rich_test.go b/internal/detector/brew_rich_test.go index 1c9a599..4e9962a 100644 --- a/internal/detector/brew_rich_test.go +++ b/internal/detector/brew_rich_test.go @@ -182,7 +182,7 @@ func TestBrewDetector_ListFormulaeRich_JSONPath(t *testing.T) { ], "casks": [] }` - mock.SetCommand(jsonData, "", 0, "brew", "info", "--json=v2", "--installed", "--formula") + mock.SetCommand(jsonData, "", 0, "/opt/homebrew/bin/brew", "info", "--json=v2", "--installed", "--formula") det := NewBrewDetector(mock) pkgs := det.ListFormulaeRich(context.Background()) @@ -206,10 +206,10 @@ func TestBrewDetector_ListFormulaeRich_FallbackToReceipts(t *testing.T) { mock.SetPath("brew", "/opt/homebrew/bin/brew") // Make the JSON command fail so it falls back - mock.SetCommand("", "error", 1, "brew", "info", "--json=v2", "--installed", "--formula") + mock.SetCommand("", "error", 1, "/opt/homebrew/bin/brew", "info", "--json=v2", "--installed", "--formula") // Basic list output for fallback - mock.SetCommand("curl 8.4.0\ngit 2.43.0\n", "", 0, "brew", "list", "--formula", "--versions") + mock.SetCommand("curl 8.4.0\ngit 2.43.0\n", "", 0, "/opt/homebrew/bin/brew", "list", "--formula", "--versions") // Set up Cellar directory and receipts mock.SetDir("/opt/homebrew/Cellar") @@ -274,7 +274,7 @@ func TestBrewDetector_ListCasksRich_JSONPath(t *testing.T) { } ] }` - mock.SetCommand(jsonData, "", 0, "brew", "info", "--json=v2", "--installed", "--cask") + mock.SetCommand(jsonData, "", 0, "/opt/homebrew/bin/brew", "info", "--json=v2", "--installed", "--cask") det := NewBrewDetector(mock) pkgs := det.ListCasksRich(context.Background()) @@ -298,10 +298,10 @@ func TestBrewDetector_ListCasksRich_FallbackToReceipts(t *testing.T) { mock.SetPath("brew", "/opt/homebrew/bin/brew") // JSON command fails - mock.SetCommand("", "error", 1, "brew", "info", "--json=v2", "--installed", "--cask") + mock.SetCommand("", "error", 1, "/opt/homebrew/bin/brew", "info", "--json=v2", "--installed", "--cask") // Basic list output - mock.SetCommand("firefox 120.0\n", "", 0, "brew", "list", "--cask", "--versions") + mock.SetCommand("firefox 120.0\n", "", 0, "/opt/homebrew/bin/brew", "list", "--cask", "--versions") // Caskroom directory and receipt mock.SetDir("/opt/homebrew/Caskroom") diff --git a/internal/detector/brew_test.go b/internal/detector/brew_test.go index ed22ee2..c2baadf 100644 --- a/internal/detector/brew_test.go +++ b/internal/detector/brew_test.go @@ -125,7 +125,7 @@ func TestBrewDetector_NotFound(t *testing.T) { func TestBrewDetector_ListFormulae(t *testing.T) { mock := executor.NewMock() mock.SetPath("brew", "/opt/homebrew/bin/brew") - mock.SetCommand("ca-certificates 2024.2.2\ncurl 8.4.0\ngit 2.43.0\nopenssl@3 3.2.0\n", "", 0, "brew", "list", "--formula", "--versions") + mock.SetCommand("ca-certificates 2024.2.2\ncurl 8.4.0\ngit 2.43.0\nopenssl@3 3.2.0\n", "", 0, "/opt/homebrew/bin/brew", "list", "--formula", "--versions") det := NewBrewDetector(mock) formulae := det.ListFormulae(context.Background()) @@ -157,7 +157,7 @@ func TestBrewDetector_ListFormulaeAtStandardPathOutsidePATH(t *testing.T) { func TestBrewDetector_ListCasks(t *testing.T) { mock := executor.NewMock() mock.SetPath("brew", "/opt/homebrew/bin/brew") - mock.SetCommand("firefox 120.0\ngoogle-chrome 120.0.6099.109\nvisual-studio-code 1.85.0\n", "", 0, "brew", "list", "--cask", "--versions") + mock.SetCommand("firefox 120.0\ngoogle-chrome 120.0.6099.109\nvisual-studio-code 1.85.0\n", "", 0, "/opt/homebrew/bin/brew", "list", "--cask", "--versions") det := NewBrewDetector(mock) casks := det.ListCasks(context.Background())