Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 39 additions & 32 deletions internal/detector/brew_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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))
}
}
105 changes: 34 additions & 71 deletions internal/detector/brewscan.go
Original file line number Diff line number Diff line change
@@ -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),
}
}
14 changes: 6 additions & 8 deletions internal/telemetry/telemetry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
Loading