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
40 changes: 35 additions & 5 deletions internal/detector/pythonproject.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"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"
"github.com/step-security/dev-machine-guard/internal/tcc"
)

Expand All @@ -19,11 +20,12 @@ const maxPythonProjects = 1000
// PythonProjectDetector scans for Python projects with virtual environments.
type PythonProjectDetector struct {
exec executor.Executor
log *progress.Logger
skipper *tcc.Skipper
}

func NewPythonProjectDetector(exec executor.Executor) *PythonProjectDetector {
return &PythonProjectDetector{exec: exec}
return &PythonProjectDetector{exec: exec, log: progress.NewNoop()}
}

// WithSkipper attaches a TCC skipper so the walk skips macOS-protected
Expand All @@ -33,6 +35,16 @@ func (d *PythonProjectDetector) WithSkipper(s *tcc.Skipper) *PythonProjectDetect
return d
}

// WithLogger attaches a progress logger so venv discovery and per-venv scans
// surface in the agent log, on par with the Node project scanner. A nil logger
// falls back to the no-op default. Returns the detector for chaining.
func (d *PythonProjectDetector) WithLogger(log *progress.Logger) *PythonProjectDetector {
if log != nil {
d.log = log
}
return d
}

// CountProjects counts Python projects with virtual environments.
func (d *PythonProjectDetector) CountProjects(_ context.Context, searchDirs []string) int {
projects, _ := d.ListProjects(searchDirs, nil)
Expand Down Expand Up @@ -65,9 +77,12 @@ type venvCandidate struct {
func (d *PythonProjectDetector) ListProjects(searchDirs []string, knownLastVerified map[string]time.Time) (projects []model.ProjectInfo, discovered []string) {
var candidates []venvCandidate
for _, dir := range searchDirs {
d.log.Progress(" Searching in: %s", dir)
candidates = append(candidates, d.discoverInDir(dir)...)
}

d.log.Debug("python venv discovery: found %d venv(s) across %d search dir(s)", len(candidates), len(searchDirs))

discovered = make([]string, 0, len(candidates))
for _, c := range candidates {
discovered = append(discovered, c.path)
Expand All @@ -76,6 +91,7 @@ func (d *PythonProjectDetector) ListProjects(searchDirs []string, knownLastVerif
candidates = orderVenvs(candidates, knownLastVerified)

if len(candidates) > maxPythonProjects {
d.log.Warn("Python project scan truncated at %d venvs (total discovered: %d) — lowest-priority venvs were skipped", maxPythonProjects, len(candidates))
candidates = candidates[:maxPythonProjects]
}

Expand All @@ -84,14 +100,22 @@ func (d *PythonProjectDetector) ListProjects(searchDirs []string, knownLastVerif
for _, c := range candidates {
var pkgs []model.PackageDetail
if c.pipPath != "" {
pkgs = d.listVenvPackages(ctx, c.pipPath)
d.log.Progress(" Scanning: %s (%s)", c.path, c.pm)
pkgs = d.listVenvPackages(ctx, c.path, c.pipPath)
} else {
// A valid venv (pyvenv.cfg present) created with --without-pip:
// there's nothing to list, but record that we saw it so the
// absence of packages is explained rather than silent.
d.log.Debug("python venv has no pip — skipping package list: %s (%s)", c.path, c.pm)
}
projects = append(projects, model.ProjectInfo{
Path: c.path,
PackageManager: c.pm,
Packages: pkgs,
})
}

d.log.Progress(" Scanned %d venvs", len(candidates))
return projects, discovered
}

Expand Down Expand Up @@ -162,11 +186,16 @@ func (d *PythonProjectDetector) isVenvDir(path string) (pipPath string, isVenv b
}

// listVenvPackages runs pip list inside the venv and returns the packages.
func (d *PythonProjectDetector) listVenvPackages(ctx context.Context, pipPath string) []model.PackageDetail {
stdout, _, _, err := d.exec.RunWithTimeout(ctx, 15*time.Second, pipPath, "list", "--format", "json")
if err != nil {
// venvPath is used only for log context; pipPath is the binary actually run.
func (d *PythonProjectDetector) listVenvPackages(ctx context.Context, venvPath, pipPath string) []model.PackageDetail {
start := time.Now()
stdout, _, exitCode, err := d.exec.RunWithTimeout(ctx, 15*time.Second, pipPath, "list", "--format", "json")
duration := time.Since(start).Milliseconds()
if errMsg := pmRunError("pip list", exitCode, err); errMsg != "" {
d.log.Warn("python venv scan failed: %s (venv=%s, exit=%d, %dms) — results may be incomplete", errMsg, venvPath, exitCode, duration)
return nil
Comment on lines +191 to 196
}
d.log.Debug("python venv scan: venv=%s exit_code=%d stdout_bytes=%d duration=%dms", venvPath, exitCode, len(stdout), duration)
stdout = strings.TrimSpace(stdout)
if stdout == "" {
return nil
Expand All @@ -177,6 +206,7 @@ func (d *PythonProjectDetector) listVenvPackages(ctx context.Context, pipPath st
}
var entries []pipEntry
if err := json.Unmarshal([]byte(stdout), &entries); err != nil {
d.log.Warn("python venv scan: failed to parse pip list JSON (venv=%s): %v", venvPath, err)
return nil
}
pkgs := make([]model.PackageDetail, 0, len(entries))
Expand Down
2 changes: 1 addition & 1 deletion internal/scan/scanner.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error {

log.StepStart("Scanning Python projects")
start = time.Now()
pyProjectDetector := detector.NewPythonProjectDetector(exec).WithSkipper(tccSkipper)
pyProjectDetector := detector.NewPythonProjectDetector(exec).WithSkipper(tccSkipper).WithLogger(log)
pythonProjects, _ = pyProjectDetector.ListProjects(searchDirs, nil)
log.StepDone(time.Since(start))
} else {
Expand Down
2 changes: 1 addition & 1 deletion internal/telemetry/telemetry.go
Original file line number Diff line number Diff line change
Expand Up @@ -708,7 +708,7 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err
log.Progress(" Found %d Python global package source(s)", len(pythonGlobalPkgs))

log.Progress("Searching for Python projects...")
pyProjectDetector := detector.NewPythonProjectDetector(exec).WithSkipper(tccSkipper)
pyProjectDetector := detector.NewPythonProjectDetector(exec).WithSkipper(tccSkipper).WithLogger(log)
var knownPython map[string]time.Time
if scanState != nil && !scanStateFullSync {
knownPython = make(map[string]time.Time, len(scanState.PythonProjects))
Expand Down
Loading