Skip to content

Commit 59ce45e

Browse files
feat: implement user-aware executor for correct command context and enhance brew and python scanning
1 parent 822ce37 commit 59ce45e

4 files changed

Lines changed: 165 additions & 17 deletions

File tree

internal/detector/brewscan.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package detector
33
import (
44
"context"
55
"encoding/base64"
6+
"strings"
67
"time"
78

89
"github.com/step-security/dev-machine-guard/internal/executor"
@@ -23,6 +24,7 @@ func NewBrewScanner(exec executor.Executor, log *progress.Logger) *BrewScanner {
2324
// ScanFormulae runs `brew list --formula --versions` and returns raw base64-encoded output.
2425
func (s *BrewScanner) ScanFormulae(ctx context.Context) (model.BrewScanResult, bool) {
2526
if _, err := s.exec.LookPath("brew"); err != nil {
27+
s.log.Progress(" brew not found in PATH for formulae scan")
2628
return model.BrewScanResult{}, false
2729
}
2830

@@ -34,8 +36,15 @@ func (s *BrewScanner) ScanFormulae(ctx context.Context) (model.BrewScanResult, b
3436
errMsg := ""
3537
if exitCode != 0 {
3638
errMsg = "brew list --formula --versions failed"
39+
s.log.Progress(" Brew formulae scan failed: exit_code=%d stderr=%s", exitCode, stderr)
3740
}
3841

42+
lineCount := len(strings.Split(strings.TrimSpace(stdout), "\n"))
43+
if strings.TrimSpace(stdout) == "" {
44+
lineCount = 0
45+
}
46+
s.log.Progress(" Brew formulae scan complete: %d lines, exit_code=%d, duration=%dms", lineCount, exitCode, duration)
47+
3948
return model.BrewScanResult{
4049
ScanType: "formulae",
4150
RawStdoutBase64: base64.StdEncoding.EncodeToString([]byte(stdout)),
@@ -49,6 +58,7 @@ func (s *BrewScanner) ScanFormulae(ctx context.Context) (model.BrewScanResult, b
4958
// ScanCasks runs `brew list --cask --versions` and returns raw base64-encoded output.
5059
func (s *BrewScanner) ScanCasks(ctx context.Context) (model.BrewScanResult, bool) {
5160
if _, err := s.exec.LookPath("brew"); err != nil {
61+
s.log.Progress(" brew not found in PATH for casks scan")
5262
return model.BrewScanResult{}, false
5363
}
5464

@@ -60,7 +70,14 @@ func (s *BrewScanner) ScanCasks(ctx context.Context) (model.BrewScanResult, bool
6070
errMsg := ""
6171
if exitCode != 0 {
6272
errMsg = "brew list --cask --versions failed"
73+
s.log.Progress(" Brew casks scan failed: exit_code=%d stderr=%s", exitCode, stderr)
74+
}
75+
76+
lineCount := len(strings.Split(strings.TrimSpace(stdout), "\n"))
77+
if strings.TrimSpace(stdout) == "" {
78+
lineCount = 0
6379
}
80+
s.log.Progress(" Brew casks scan complete: %d lines, exit_code=%d, duration=%dms", lineCount, exitCode, duration)
6481

6582
return model.BrewScanResult{
6683
ScanType: "casks",

internal/detector/pythonproject.go

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,25 @@ func (d *PythonProjectDetector) listInDir(dir string) []model.ProjectInfo {
111111
(strings.HasPrefix(name, ".") && name != ".venv") {
112112
return filepath.SkipDir
113113
}
114+
115+
// Detect directories that contain a venv even without a marker file.
116+
// A venv/ or .venv/ subdirectory is itself evidence of a Python project.
117+
if !seen[path] {
118+
if pipPath := d.findVenvPip(path); pipPath != "" {
119+
seen[path] = true
120+
pm := d.detectPM(path)
121+
pkgs := d.listVenvPackages(ctx, pipPath)
122+
projects = append(projects, model.ProjectInfo{
123+
Path: path,
124+
PackageManager: pm,
125+
Packages: pkgs,
126+
})
127+
if len(projects) >= maxPythonProjects {
128+
return filepath.SkipAll
129+
}
130+
}
131+
}
132+
114133
return nil
115134
}
116135
if pythonMarkerFiles[entry.Name()] {
@@ -120,20 +139,13 @@ func (d *PythonProjectDetector) listInDir(dir string) []model.ProjectInfo {
120139
}
121140
seen[projectDir] = true
122141

123-
// Only include projects that have a virtual environment
142+
// Only include marker-based projects that have a virtual environment
124143
pipPath := d.findVenvPip(projectDir)
125144
if pipPath == "" {
126145
return nil
127146
}
128147

129-
pm := pythonPMFromMarker[entry.Name()]
130-
if d.exec.FileExists(filepath.Join(projectDir, "poetry.lock")) {
131-
pm = "poetry"
132-
} else if d.exec.FileExists(filepath.Join(projectDir, "Pipfile.lock")) {
133-
pm = "pipenv"
134-
} else if d.exec.FileExists(filepath.Join(projectDir, "uv.lock")) {
135-
pm = "uv"
136-
}
148+
pm := d.detectPM(projectDir)
137149

138150
pkgs := d.listVenvPackages(ctx, pipPath)
139151

@@ -150,3 +162,22 @@ func (d *PythonProjectDetector) listInDir(dir string) []model.ProjectInfo {
150162
})
151163
return projects
152164
}
165+
166+
// detectPM determines the package manager for a project directory based on lock/marker files.
167+
func (d *PythonProjectDetector) detectPM(projectDir string) string {
168+
if d.exec.FileExists(filepath.Join(projectDir, "poetry.lock")) {
169+
return "poetry"
170+
}
171+
if d.exec.FileExists(filepath.Join(projectDir, "Pipfile.lock")) {
172+
return "pipenv"
173+
}
174+
if d.exec.FileExists(filepath.Join(projectDir, "uv.lock")) {
175+
return "uv"
176+
}
177+
for marker, pm := range pythonPMFromMarker {
178+
if d.exec.FileExists(filepath.Join(projectDir, marker)) {
179+
return pm
180+
}
181+
}
182+
return "pip"
183+
}

internal/executor/user_aware.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package executor
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"os/user"
8+
"strings"
9+
"time"
10+
)
11+
12+
// UserAwareExecutor wraps an Executor and delegates LookPath and RunWithTimeout
13+
// to the logged-in user when the process is running as root. This ensures that
14+
// commands like "brew list", "pip3 list", "npm --version" etc. execute in the
15+
// correct user context, since many tools refuse to run as root or return
16+
// different results for different users.
17+
//
18+
// All other Executor methods are forwarded unchanged.
19+
type UserAwareExecutor struct {
20+
inner Executor
21+
username string // logged-in user to delegate to; empty = no delegation
22+
}
23+
24+
// NewUserAwareExecutor returns a wrapped executor that delegates command execution
25+
// to the given user when running as root on Unix. If username is empty or the
26+
// process is not root, all calls pass through to the inner executor unchanged.
27+
func NewUserAwareExecutor(inner Executor, username string) Executor {
28+
if username == "" || !inner.IsRoot() || inner.GOOS() == "windows" {
29+
return inner // no wrapping needed
30+
}
31+
return &UserAwareExecutor{inner: inner, username: username}
32+
}
33+
34+
func (e *UserAwareExecutor) Run(ctx context.Context, name string, args ...string) (string, string, int, error) {
35+
cmd := name
36+
for _, a := range args {
37+
cmd += " " + a
38+
}
39+
stdout, err := e.inner.RunAsUser(ctx, e.username, cmd)
40+
if err != nil {
41+
return stdout, err.Error(), 1, err
42+
}
43+
return stdout, "", 0, nil
44+
}
45+
46+
func (e *UserAwareExecutor) RunWithTimeout(ctx context.Context, timeout time.Duration, name string, args ...string) (string, string, int, error) {
47+
ctx, cancel := context.WithTimeout(ctx, timeout)
48+
defer cancel()
49+
stdout, stderr, code, err := e.Run(ctx, name, args...)
50+
if ctx.Err() == context.DeadlineExceeded {
51+
return stdout, stderr, 124, fmt.Errorf("command timed out after %s", timeout)
52+
}
53+
return stdout, stderr, code, err
54+
}
55+
56+
func (e *UserAwareExecutor) RunAsUser(ctx context.Context, username, command string) (string, error) {
57+
return e.inner.RunAsUser(ctx, username, command)
58+
}
59+
60+
func (e *UserAwareExecutor) LookPath(name string) (string, error) {
61+
stdout, err := e.inner.RunAsUser(context.Background(), e.username, "which "+name)
62+
if err != nil || strings.TrimSpace(stdout) == "" {
63+
return "", fmt.Errorf("%s not found in user PATH", name)
64+
}
65+
return strings.TrimSpace(stdout), nil
66+
}
67+
68+
// --- Pass-through methods ---
69+
70+
func (e *UserAwareExecutor) FileExists(path string) bool { return e.inner.FileExists(path) }
71+
func (e *UserAwareExecutor) DirExists(path string) bool { return e.inner.DirExists(path) }
72+
func (e *UserAwareExecutor) ReadFile(path string) ([]byte, error) { return e.inner.ReadFile(path) }
73+
func (e *UserAwareExecutor) ReadDir(path string) ([]os.DirEntry, error) {
74+
return e.inner.ReadDir(path)
75+
}
76+
func (e *UserAwareExecutor) Stat(path string) (os.FileInfo, error) { return e.inner.Stat(path) }
77+
func (e *UserAwareExecutor) Hostname() (string, error) { return e.inner.Hostname() }
78+
func (e *UserAwareExecutor) Getenv(key string) string { return e.inner.Getenv(key) }
79+
func (e *UserAwareExecutor) IsRoot() bool { return e.inner.IsRoot() }
80+
func (e *UserAwareExecutor) CurrentUser() (*user.User, error) { return e.inner.CurrentUser() }
81+
func (e *UserAwareExecutor) HomeDir(username string) (string, error) {
82+
return e.inner.HomeDir(username)
83+
}
84+
func (e *UserAwareExecutor) Glob(pattern string) ([]string, error) { return e.inner.Glob(pattern) }
85+
func (e *UserAwareExecutor) LoggedInUser() (*user.User, error) { return e.inner.LoggedInUser() }
86+
func (e *UserAwareExecutor) GOOS() string { return e.inner.GOOS() }

internal/telemetry/telemetry.go

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,13 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error {
120120
loggedInUsername = u.Username
121121
}
122122

123+
// Create a user-aware executor that delegates commands to the logged-in user
124+
// when running as root. This ensures tools like brew, pip3, npm etc. execute
125+
// in the correct user context (many refuse to run as root or return different
126+
// results). File-based detectors (IDE, extensions, MCP) use the original exec
127+
// since file operations don't need user delegation.
128+
userExec := executor.NewUserAwareExecutor(exec, loggedInUsername)
129+
123130
// Resolve search dirs
124131
searchDirs := resolveSearchDirs(exec, cfg.SearchDirs)
125132
fmt.Fprintln(os.Stderr)
@@ -148,7 +155,7 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error {
148155
fmt.Fprintln(os.Stderr)
149156

150157
log.Progress("Detecting AI CLI tools...")
151-
cliTools := detector.NewAICLIDetector(exec).Detect(ctx)
158+
cliTools := detector.NewAICLIDetector(userExec).Detect(ctx)
152159
for _, t := range cliTools {
153160
log.Progress(" Found: %s (%s) v%s at %s", t.Name, t.Vendor, t.Version, t.BinaryPath)
154161
}
@@ -158,7 +165,7 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error {
158165
fmt.Fprintln(os.Stderr)
159166

160167
log.Progress("Detecting general-purpose AI agents...")
161-
agents := detector.NewAgentDetector(exec).Detect(ctx, searchDirs)
168+
agents := detector.NewAgentDetector(userExec).Detect(ctx, searchDirs)
162169
for _, a := range agents {
163170
log.Progress(" Found: %s (%s) at %s", a.Name, a.Vendor, a.InstallPath)
164171
}
@@ -168,7 +175,7 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error {
168175
fmt.Fprintln(os.Stderr)
169176

170177
log.Progress("Detecting AI frameworks and runtimes...")
171-
frameworks := detector.NewFrameworkDetector(exec).Detect(ctx)
178+
frameworks := detector.NewFrameworkDetector(userExec).Detect(ctx)
172179
for _, f := range frameworks {
173180
running := "false"
174181
if f.IsRunning != nil && *f.IsRunning {
@@ -206,17 +213,24 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error {
206213

207214
if brewEnabled {
208215
log.Progress("Detecting Homebrew...")
209-
brewDetector := detector.NewBrewDetector(exec)
216+
brewDetector := detector.NewBrewDetector(userExec)
210217
brewPkgMgr = brewDetector.DetectBrew(ctx)
211218
if brewPkgMgr != nil {
212219
log.Progress(" Found: Homebrew v%s at %s", brewPkgMgr.Version, brewPkgMgr.Path)
213-
brewScanner := detector.NewBrewScanner(exec, log)
220+
brewScanner := detector.NewBrewScanner(userExec, log)
214221
if r, ok := brewScanner.ScanFormulae(ctx); ok {
215222
brewScans = append(brewScans, r)
223+
log.Progress(" Formulae scan: exit_code=%d, error=%q, raw_len=%d", r.ExitCode, r.Error, len(r.RawStdoutBase64))
224+
} else {
225+
log.Progress(" Formulae scan: skipped (brew not in PATH)")
216226
}
217227
if r, ok := brewScanner.ScanCasks(ctx); ok {
218228
brewScans = append(brewScans, r)
229+
log.Progress(" Casks scan: exit_code=%d, error=%q, raw_len=%d", r.ExitCode, r.Error, len(r.RawStdoutBase64))
230+
} else {
231+
log.Progress(" Casks scan: skipped (brew not in PATH)")
219232
}
233+
log.Progress(" Total brew scans: %d", len(brewScans))
220234
} else {
221235
log.Progress(" Homebrew not found")
222236
}
@@ -238,7 +252,7 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error {
238252

239253
if pythonEnabled {
240254
log.Progress("Detecting Python package managers...")
241-
pyDetector := detector.NewPythonPMDetector(exec)
255+
pyDetector := detector.NewPythonPMDetector(userExec)
242256
pythonPkgManagers = pyDetector.DetectManagers(ctx)
243257
for _, pm := range pythonPkgManagers {
244258
log.Progress(" Found: %s v%s at %s", pm.Name, pm.Version, pm.Path)
@@ -248,7 +262,7 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error {
248262
}
249263

250264
log.Progress("Scanning Python global packages...")
251-
pyScanner := detector.NewPythonScanner(exec, log)
265+
pyScanner := detector.NewPythonScanner(userExec, log)
252266
pythonGlobalPkgs = pyScanner.ScanGlobalPackages(ctx)
253267
log.Progress(" Found %d Python global package source(s)", len(pythonGlobalPkgs))
254268

@@ -277,7 +291,7 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error {
277291
log.Progress("Node.js package scanning is ENABLED")
278292

279293
log.Progress("Detecting Node.js package managers...")
280-
npmDetector := detector.NewNodePMDetector(exec)
294+
npmDetector := detector.NewNodePMDetector(userExec)
281295
pkgManagers = npmDetector.DetectManagers(ctx)
282296
for _, pm := range pkgManagers {
283297
log.Progress(" Found: %s v%s at %s", pm.Name, pm.Version, pm.Path)

0 commit comments

Comments
 (0)