From a8aef8f4c9ebfb41433658065b89862d4f38a9fa Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Tue, 19 May 2026 14:42:26 +0530 Subject: [PATCH] fix(brew): synthesize raw scan output from rich data Signed-off-by: Swarit Pandey --- internal/detector/brew_test.go | 71 +++++++++++---------- internal/detector/brewscan.go | 105 +++++++++++--------------------- internal/telemetry/telemetry.go | 14 ++--- 3 files changed, 79 insertions(+), 111 deletions(-) diff --git a/internal/detector/brew_test.go b/internal/detector/brew_test.go index 752af52..28579c4 100644 --- a/internal/detector/brew_test.go +++ b/internal/detector/brew_test.go @@ -2,9 +2,11 @@ package detector import ( "context" + "encoding/base64" "testing" "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" "github.com/step-security/dev-machine-guard/internal/progress" ) @@ -76,56 +78,61 @@ func TestBrewDetector_ListCasks(t *testing.T) { } } -func TestBrewScanner_Formulae(t *testing.T) { - mock := executor.NewMock() - mock.SetPath("brew", "/opt/homebrew/bin/brew") - mock.SetCommand("curl 8.4.0\ngit 2.43.0\n", "", 0, "brew", "list", "--formula", "--versions") - - log := newTestLogger() - scanner := NewBrewScanner(mock, log) - result, ok := scanner.ScanFormulae(context.Background()) - - if !ok { - t.Fatal("expected scan to succeed") +func TestBrewScanner_FormulaeResult(t *testing.T) { + scanner := NewBrewScanner(executor.NewMock(), newTestLogger()) + pkgs := []model.BrewPackage{ + {Name: "curl", Version: "8.4.0"}, + {Name: "git", Version: "2.43.0"}, } + result := scanner.FormulaeResult(pkgs) + if result.ScanType != "formulae" { t.Errorf("expected scan type formulae, got %s", result.ScanType) } - if result.RawStdoutBase64 == "" { - t.Error("expected non-empty base64 stdout") - } if result.ExitCode != 0 { t.Errorf("expected exit code 0, got %d", result.ExitCode) } + if result.LineCount != 2 { + t.Errorf("expected line count 2, got %d", result.LineCount) + } + decoded, err := base64.StdEncoding.DecodeString(result.RawStdoutBase64) + if err != nil { + t.Fatalf("base64 decode failed: %v", err) + } + want := "curl 8.4.0\ngit 2.43.0\n" + if string(decoded) != want { + t.Errorf("stdout mismatch: got %q, want %q", string(decoded), want) + } } -func TestBrewScanner_Casks(t *testing.T) { - mock := executor.NewMock() - mock.SetPath("brew", "/opt/homebrew/bin/brew") - mock.SetCommand("firefox 120.0\ngoogle-chrome 120.0.6099.109\n", "", 0, "brew", "list", "--cask", "--versions") - - log := newTestLogger() - scanner := NewBrewScanner(mock, log) - result, ok := scanner.ScanCasks(context.Background()) - - if !ok { - t.Fatal("expected scan to succeed") +func TestBrewScanner_CasksResult(t *testing.T) { + scanner := NewBrewScanner(executor.NewMock(), newTestLogger()) + pkgs := []model.BrewPackage{ + {Name: "firefox", Version: "120.0"}, + {Name: "google-chrome", Version: "120.0.6099.109"}, } + result := scanner.CasksResult(pkgs) + if result.ScanType != "casks" { t.Errorf("expected scan type casks, got %s", result.ScanType) } + if result.LineCount != 2 { + t.Errorf("expected line count 2, got %d", result.LineCount) + } if result.RawStdoutBase64 == "" { t.Error("expected non-empty base64 stdout") } } -func TestBrewScanner_NotInstalled(t *testing.T) { - mock := executor.NewMock() - log := newTestLogger() - scanner := NewBrewScanner(mock, log) +func TestBrewScanner_EmptyInput(t *testing.T) { + scanner := NewBrewScanner(executor.NewMock(), newTestLogger()) + result := scanner.FormulaeResult(nil) - _, ok := scanner.ScanFormulae(context.Background()) - if ok { - t.Error("expected scan to fail when brew is not installed") + if result.LineCount != 0 { + t.Errorf("expected line count 0, got %d", result.LineCount) + } + decoded, _ := base64.StdEncoding.DecodeString(result.RawStdoutBase64) + if len(decoded) != 0 { + t.Errorf("expected empty stdout, got %q", string(decoded)) } } diff --git a/internal/detector/brewscan.go b/internal/detector/brewscan.go index d74bb85..cf70fd4 100644 --- a/internal/detector/brewscan.go +++ b/internal/detector/brewscan.go @@ -1,94 +1,57 @@ package detector import ( - "context" "encoding/base64" "strings" - "time" "github.com/step-security/dev-machine-guard/internal/executor" "github.com/step-security/dev-machine-guard/internal/model" "github.com/step-security/dev-machine-guard/internal/progress" ) -// BrewScanner performs enterprise-mode Homebrew scanning (raw output, base64 encoded). +// BrewScanner produces a BrewScanResult for enterprise telemetry by synthesizing +// the raw `brew list --versions` format from the rich package data we already have. +// +// We used to shell out to `brew list --formula|--cask --versions`, but on some hosts +// `brew list --cask --versions` crashes inside Homebrew itself (e.g. nil in a cask's +// depends_on triggers `undefined method 'to_sym' for nil` in cask_struct_generator.rb). +// The rich path (`brew info --json=v2`) is unaffected, so we reuse its data here. type BrewScanner struct { - exec executor.Executor - log *progress.Logger + log *progress.Logger } -func NewBrewScanner(exec executor.Executor, log *progress.Logger) *BrewScanner { - return &BrewScanner{exec: exec, log: log} +// NewBrewScanner keeps the (exec, log) signature for caller compatibility; exec is unused. +func NewBrewScanner(_ executor.Executor, log *progress.Logger) *BrewScanner { + return &BrewScanner{log: log} } -// ScanFormulae runs `brew list --formula --versions` and returns raw base64-encoded output. -func (s *BrewScanner) ScanFormulae(ctx context.Context) (model.BrewScanResult, bool) { - if _, err := s.exec.LookPath("brew"); err != nil { - s.log.Progress(" brew not found in PATH for formulae scan") - return model.BrewScanResult{}, false - } - - s.log.Progress(" Scanning Homebrew formulae...") - start := time.Now() - stdout, stderr, exitCode, _ := s.exec.RunWithTimeout(ctx, 60*time.Second, "brew", "list", "--formula", "--versions") - duration := time.Since(start).Milliseconds() - - errMsg := "" - if exitCode != 0 { - errMsg = "brew list --formula --versions failed" - s.log.Warn("brew formulae scan failed (exit_code=%d): %s — results may be incomplete", exitCode, strings.TrimSpace(stderr)) - } - - lineCount := len(strings.Split(strings.TrimSpace(stdout), "\n")) - if strings.TrimSpace(stdout) == "" { - lineCount = 0 - } - s.log.Progress(" Brew formulae scan complete: %d lines, exit_code=%d, duration=%dms", lineCount, exitCode, duration) - s.log.Debug("brew formulae scan: line_count=%d exit_code=%d duration=%dms stdout_bytes=%d", lineCount, exitCode, duration, len(stdout)) - - return model.BrewScanResult{ - ScanType: "formulae", - RawStdoutBase64: base64.StdEncoding.EncodeToString([]byte(stdout)), - RawStderrBase64: base64.StdEncoding.EncodeToString([]byte(stderr)), - Error: errMsg, - ExitCode: exitCode, - ScanDurationMs: duration, - LineCount: lineCount, - }, true +// FormulaeResult builds a formulae scan result from rich package data. +func (s *BrewScanner) FormulaeResult(pkgs []model.BrewPackage) model.BrewScanResult { + return s.synthesize("formulae", pkgs) } -// ScanCasks runs `brew list --cask --versions` and returns raw base64-encoded output. -func (s *BrewScanner) ScanCasks(ctx context.Context) (model.BrewScanResult, bool) { - if _, err := s.exec.LookPath("brew"); err != nil { - s.log.Progress(" brew not found in PATH for casks scan") - return model.BrewScanResult{}, false - } - - s.log.Progress(" Scanning Homebrew casks...") - start := time.Now() - stdout, stderr, exitCode, _ := s.exec.RunWithTimeout(ctx, 60*time.Second, "brew", "list", "--cask", "--versions") - duration := time.Since(start).Milliseconds() - - errMsg := "" - if exitCode != 0 { - errMsg = "brew list --cask --versions failed" - s.log.Warn("brew casks scan failed (exit_code=%d): %s — results may be incomplete", exitCode, strings.TrimSpace(stderr)) - } +// CasksResult builds a casks scan result from rich package data. +func (s *BrewScanner) CasksResult(pkgs []model.BrewPackage) model.BrewScanResult { + return s.synthesize("casks", pkgs) +} - lineCount := len(strings.Split(strings.TrimSpace(stdout), "\n")) - if strings.TrimSpace(stdout) == "" { - lineCount = 0 +func (s *BrewScanner) synthesize(scanType string, pkgs []model.BrewPackage) model.BrewScanResult { + var b strings.Builder + for _, p := range pkgs { + b.WriteString(p.Name) + b.WriteByte(' ') + b.WriteString(p.Version) + b.WriteByte('\n') } - s.log.Progress(" Brew casks scan complete: %d lines, exit_code=%d, duration=%dms", lineCount, exitCode, duration) - s.log.Debug("brew casks scan: line_count=%d exit_code=%d duration=%dms stdout_bytes=%d", lineCount, exitCode, duration, len(stdout)) - + stdout := b.String() + s.log.Debug("brew %s scan synthesized from rich data: %d packages", scanType, len(pkgs)) return model.BrewScanResult{ - ScanType: "casks", + ScanType: scanType, RawStdoutBase64: base64.StdEncoding.EncodeToString([]byte(stdout)), - RawStderrBase64: base64.StdEncoding.EncodeToString([]byte(stderr)), - Error: errMsg, - ExitCode: exitCode, - ScanDurationMs: duration, - LineCount: lineCount, - }, true + RawStderrBase64: "", + Error: "", + ExitCode: 0, + ScanDurationMs: 0, + LineCount: len(pkgs), + } } diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index a2449f4..993ddb4 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -350,15 +350,13 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err brewCasks = brewDetector.ListCasksRich(ctx) log.Progress(" Formulae: %d, Casks: %d (pre-parsed with metadata)", len(brewFormulae), len(brewCasks)) - // Also collect raw scans for backward compatibility with older backends + // Also emit raw-format scans for backward compatibility with older backends. + // Synthesized from the rich data above — avoids re-invoking `brew list`, + // which can crash inside Homebrew on hosts with malformed cask metadata. brewScanner := detector.NewBrewScanner(userExec, log) - if r, ok := brewScanner.ScanFormulae(ctx); ok { - brewScans = append(brewScans, r) - } - if r, ok := brewScanner.ScanCasks(ctx); ok { - brewScans = append(brewScans, r) - } - log.Progress(" Raw scans: %d", len(brewScans)) + brewScans = append(brewScans, brewScanner.FormulaeResult(brewFormulae)) + brewScans = append(brewScans, brewScanner.CasksResult(brewCasks)) + log.Progress(" Raw scans: %d (synthesized)", len(brewScans)) } else { log.Progress(" Homebrew not found") }