Skip to content
Merged
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
3 changes: 3 additions & 0 deletions cmd/stepsecurity-dev-machine-guard/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ func main() {
if cfg.EnablePythonScan == nil && config.EnablePythonScan != nil {
cfg.EnablePythonScan = config.EnablePythonScan
}
if cfg.IncludeTCCProtected == nil && config.IncludeTCCProtected != nil {
cfg.IncludeTCCProtected = config.IncludeTCCProtected
}
if cfg.ColorMode == "auto" && config.ColorMode != "" {
cfg.ColorMode = config.ColorMode
}
Expand Down
51 changes: 35 additions & 16 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,29 @@ import (
// failure, so the hot path bypasses cli.Parse entirely — see main.go's
// early-return and internal/aiagents/cli.RunHook.
type Config struct {
Command string // "", "install", "uninstall", "send-telemetry", "configure", "configure show", "hooks install", "hooks uninstall"
OutputFormat string // "pretty", "json", "html"
OutputFormatSet bool // true if --pretty/--json/--html was explicitly passed (not persisted)
HTMLOutputFile string // set by --html (not persisted)
ColorMode string // "auto", "always", "never"
Verbose bool // --verbose (shortcut for --log-level=debug)
LogLevel string // "" = unset; one of "error", "warn", "info", "debug"
InstallDir string // --install-dir=DIR base install directory; all non-bootstrap files (logs, hook errors, binary placement) live under this dir. "" w/ InstallDirSet=true means "explicitly disabled" (no file logging).
InstallDirSet bool // true if --install-dir was passed (empty value = disable file logging for this run)
EnableNPMScan *bool // nil=auto, true/false=explicit
EnableBrewScan *bool // nil=auto, true/false=explicit
EnablePythonScan *bool // nil=auto, true/false=explicit
IncludeBundledPlugins bool // --include-bundled-plugins: include bundled/platform plugins in output
NPMRCOnly bool // --npmrc: run only the npmrc audit and render verbose pretty output
PipConfigOnly bool // --pipconfig: run only the pip config audit and render verbose pretty output
SearchDirs []string // defaults to ["$HOME"]
Command string // "", "install", "uninstall", "send-telemetry", "configure", "configure show", "hooks install", "hooks uninstall"
OutputFormat string // "pretty", "json", "html"
OutputFormatSet bool // true if --pretty/--json/--html was explicitly passed (not persisted)
HTMLOutputFile string // set by --html (not persisted)
ColorMode string // "auto", "always", "never"
Verbose bool // --verbose (shortcut for --log-level=debug)
LogLevel string // "" = unset; one of "error", "warn", "info", "debug"
InstallDir string // --install-dir=DIR base install directory; all non-bootstrap files (logs, hook errors, binary placement) live under this dir. "" w/ InstallDirSet=true means "explicitly disabled" (no file logging).
InstallDirSet bool // true if --install-dir was passed (empty value = disable file logging for this run)
EnableNPMScan *bool // nil=auto, true/false=explicit
EnableBrewScan *bool // nil=auto, true/false=explicit
EnablePythonScan *bool // nil=auto, true/false=explicit
IncludeBundledPlugins bool // --include-bundled-plugins: include bundled/platform plugins in output
// IncludeTCCProtected is tristate: nil = use the runtime default
// (skip only when running under launchd, where TCC prompts actually
// fire), true = always include the protected dirs (skipper off),
// false = always exclude them (skipper on). Wired via
// --include-tcc-protected / --no-include-tcc-protected and the
// matching field in config.ConfigFile.
IncludeTCCProtected *bool
NPMRCOnly bool // --npmrc: run only the npmrc audit and render verbose pretty output
PipConfigOnly bool // --pipconfig: run only the pip config audit and render verbose pretty output
SearchDirs []string // defaults to ["$HOME"]

// HooksAgent is the --agent value on `hooks install` / `hooks uninstall`;
// "" means "every detected agent".
Expand Down Expand Up @@ -146,6 +153,12 @@ func Parse(args []string) (*Config, error) {
cfg.EnablePythonScan = &v
case arg == "--include-bundled-plugins":
cfg.IncludeBundledPlugins = true
case arg == "--include-tcc-protected":
v := true
cfg.IncludeTCCProtected = &v
case arg == "--no-include-tcc-protected":
v := false
cfg.IncludeTCCProtected = &v
case arg == "--npmrc":
cfg.NPMRCOnly = true
case arg == "--pipconfig":
Expand Down Expand Up @@ -404,6 +417,12 @@ Options:
--enable-python-scan Enable Python package scanning
--disable-python-scan Disable Python package scanning
--include-bundled-plugins Include bundled/platform plugins in output (Windows)
--include-tcc-protected Scan macOS TCC-protected dirs (Documents, Downloads,
~/Library/Mail, etc.). Default: skipped only when
running under launchd (where permission prompts
fire); direct CLI runs scan them.
--no-include-tcc-protected Force-skip macOS TCC-protected dirs even on direct
CLI runs.
--npmrc Run ONLY the npm config audit (verbose pretty view; --json supported)
--pipconfig Run ONLY the pip config audit (verbose pretty view; --json supported)
--log-level=LEVEL Log level: error | warn | info | debug (default: info)
Expand Down
57 changes: 31 additions & 26 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,36 +11,38 @@ import (

// Default placeholders (replaced by backend for enterprise installation scripts).
var (
CustomerID = "{{CUSTOMER_ID}}"
APIEndpoint = "{{API_ENDPOINT}}"
APIKey = "{{API_KEY}}"
ScanFrequencyHours = "{{SCAN_FREQUENCY_HOURS}}"
SearchDirs []string
EnableNPMScan *bool // nil=auto
EnableBrewScan *bool // nil=auto
EnablePythonScan *bool // nil=auto
ColorMode string // "" means auto
OutputFormat string // "" means default (pretty)
HTMLOutputFile string // "" means not set
LogLevel string // "" means default (info); one of error/warn/info/debug
InstallDir string // "" means default (~/.stepsecurity); non-empty makes the agent put all its files (logs, hook errors, future state) under this directory. Bootstrap config.json itself stays at the legacy location. Per-run opt-out is the CLI flag --install-dir=. Resolution: --install-dir flag > STEPSECURITY_HOME env > this field > default — see internal/paths.
CustomerID = "{{CUSTOMER_ID}}"
APIEndpoint = "{{API_ENDPOINT}}"
APIKey = "{{API_KEY}}" //#nosec G101 -- build-time placeholder substituted by the backend installer; the literal is not a real credential.
ScanFrequencyHours = "{{SCAN_FREQUENCY_HOURS}}"
SearchDirs []string
EnableNPMScan *bool // nil=auto
EnableBrewScan *bool // nil=auto
EnablePythonScan *bool // nil=auto
IncludeTCCProtected *bool // nil=auto (skip when running under macOS launchd, scan otherwise)
ColorMode string // "" means auto
OutputFormat string // "" means default (pretty)
HTMLOutputFile string // "" means not set
LogLevel string // "" means default (info); one of error/warn/info/debug
InstallDir string // "" means default (~/.stepsecurity); non-empty makes the agent put all its files (logs, hook errors, future state) under this directory. Bootstrap config.json itself stays at the legacy location. Per-run opt-out is the CLI flag --install-dir=. Resolution: --install-dir flag > STEPSECURITY_HOME env > this field > default — see internal/paths.
)

// ConfigFile is the JSON structure persisted to ~/.stepsecurity/config.json.
type ConfigFile struct {
CustomerID string `json:"customer_id,omitempty"`
APIEndpoint string `json:"api_endpoint,omitempty"`
APIKey string `json:"api_key,omitempty"`
ScanFrequencyHours string `json:"scan_frequency_hours,omitempty"`
SearchDirs []string `json:"search_dirs,omitempty"`
EnableNPMScan *bool `json:"enable_npm_scan,omitempty"`
EnableBrewScan *bool `json:"enable_brew_scan,omitempty"`
EnablePythonScan *bool `json:"enable_python_scan,omitempty"`
ColorMode string `json:"color_mode,omitempty"`
OutputFormat string `json:"output_format,omitempty"`
HTMLOutputFile string `json:"html_output_file,omitempty"`
LogLevel string `json:"log_level,omitempty"`
InstallDir string `json:"install_dir,omitempty"`
CustomerID string `json:"customer_id,omitempty"`
APIEndpoint string `json:"api_endpoint,omitempty"`
APIKey string `json:"api_key,omitempty"`
ScanFrequencyHours string `json:"scan_frequency_hours,omitempty"`
SearchDirs []string `json:"search_dirs,omitempty"`
EnableNPMScan *bool `json:"enable_npm_scan,omitempty"`
EnableBrewScan *bool `json:"enable_brew_scan,omitempty"`
EnablePythonScan *bool `json:"enable_python_scan,omitempty"`
IncludeTCCProtected *bool `json:"include_tcc_protected,omitempty"`
ColorMode string `json:"color_mode,omitempty"`
OutputFormat string `json:"output_format,omitempty"`
HTMLOutputFile string `json:"html_output_file,omitempty"`
LogLevel string `json:"log_level,omitempty"`
InstallDir string `json:"install_dir,omitempty"`
}

// userConfigDir returns ~/.stepsecurity — the per-user config location.
Expand Down Expand Up @@ -152,6 +154,9 @@ func Load() {
if cfg.EnablePythonScan != nil && EnablePythonScan == nil {
EnablePythonScan = cfg.EnablePythonScan
}
if cfg.IncludeTCCProtected != nil && IncludeTCCProtected == nil {
IncludeTCCProtected = cfg.IncludeTCCProtected
}
if cfg.ColorMode != "" && ColorMode == "" {
ColorMode = cfg.ColorMode
}
Expand Down
14 changes: 13 additions & 1 deletion internal/detector/configaudit/npmrc.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,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/tcc"
)

// maxNPMRCFiles caps the number of .npmrc files we report. Even on big
Expand Down Expand Up @@ -53,7 +54,8 @@ var secretEnvNamePattern = regexp.MustCompile(`(?i)(token|password|secret|_auth|
// hashes) and git-tracking checks pluggable so unit tests don't need real
// syscalls or a git binary.
type NPMRCDetector struct {
exec executor.Executor
exec executor.Executor
skipper *tcc.Skipper

// ownerLookup returns owner info for a path. Defaults to the real
// platform-specific impl in npmrc_stat_*.go; tests can override.
Expand Down Expand Up @@ -84,6 +86,13 @@ func NewNPMRCDetector(exec executor.Executor) *NPMRCDetector {
return d
}

// WithSkipper attaches a TCC skipper so .npmrc discovery skips macOS-protected
// directories. A nil skipper is a no-op. Returns the detector for chaining.
func (d *NPMRCDetector) WithSkipper(s *tcc.Skipper) *NPMRCDetector {
d.skipper = s
return d
}

// Detect runs the full audit. searchDirs are the dirs to walk for project-
// level .npmrc files (typically the user's $HOME plus any extra dirs
// configured by the operator). loggedInUser is the username whose ~/.npmrc
Expand Down Expand Up @@ -174,6 +183,9 @@ func (d *NPMRCDetector) findProjectNPMRCs(dir string) []string {
return nil
}
if entry.IsDir() {
if d.skipper.ShouldSkip(path, dir) {
return filepath.SkipDir
}
if shouldSkipNPMRCDir(path, entry.Name(), dir) {
return filepath.SkipDir
}
Expand Down
14 changes: 13 additions & 1 deletion internal/detector/nodeproject.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,28 @@ 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/tcc"
)

const maxNodeProjects = 1000

// NodeProjectDetector scans for Node.js projects.
type NodeProjectDetector struct {
exec executor.Executor
exec executor.Executor
skipper *tcc.Skipper
}

func NewNodeProjectDetector(exec executor.Executor) *NodeProjectDetector {
return &NodeProjectDetector{exec: exec}
}

// WithSkipper attaches a TCC skipper so the walk skips macOS-protected
// directories. A nil skipper is a no-op. Returns the detector for chaining.
func (d *NodeProjectDetector) WithSkipper(s *tcc.Skipper) *NodeProjectDetector {
d.skipper = s
return d
}

// CountProjects counts the number of Node.js projects found under the given directories.
func (d *NodeProjectDetector) CountProjects(_ context.Context, searchDirs []string) int {
return len(d.ListProjects(searchDirs))
Expand All @@ -47,6 +56,9 @@ func (d *NodeProjectDetector) listInDir(dir string) []model.ProjectInfo {
return nil
}
if entry.IsDir() {
if d.skipper.ShouldSkip(path, dir) {
return filepath.SkipDir
}
name := entry.Name()
if name == "node_modules" || name == ".git" || name == ".cache" ||
strings.HasPrefix(name, ".") {
Expand Down
12 changes: 12 additions & 0 deletions internal/detector/nodescan.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,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"
)

const defaultMaxProjectScanBytes = 500 * 1024 * 1024 // 500MB total limit
Expand All @@ -34,6 +35,7 @@ type NodeScanner struct {
exec executor.Executor
log *progress.Logger
loggedInUser string // when non-empty and running as root, commands run as this user
skipper *tcc.Skipper
// ProgressHook, when non-nil, is invoked from inside ScanProjects /
// ScanGlobalPackages with a short human-readable detail string ("project
// 12 of 47", "scanning yarn", ...). Telemetry plumbs this into
Expand All @@ -45,6 +47,13 @@ func NewNodeScanner(exec executor.Executor, log *progress.Logger, loggedInUser s
return &NodeScanner{exec: exec, log: log, loggedInUser: loggedInUser}
}

// WithSkipper attaches a TCC skipper so the discovery walk skips
// macOS-protected directories. A nil skipper is a no-op.
func (s *NodeScanner) WithSkipper(skipper *tcc.Skipper) *NodeScanner {
s.skipper = skipper
return s
}

func (s *NodeScanner) emitProgress(detail string) {
if s.ProgressHook != nil {
s.ProgressHook(detail)
Expand Down Expand Up @@ -332,6 +341,9 @@ func (s *NodeScanner) ScanProjects(ctx context.Context, searchDirs []string) []m
return nil
}
if entry.IsDir() {
if s.skipper.ShouldSkip(path, dir) {
return filepath.SkipDir
}
name := entry.Name()
if name == "node_modules" || name == ".git" || name == ".cache" ||
strings.HasPrefix(name, ".") {
Expand Down
14 changes: 13 additions & 1 deletion internal/detector/pythonproject.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,28 @@ 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/tcc"
)

const maxPythonProjects = 1000

// PythonProjectDetector scans for Python projects with virtual environments.
type PythonProjectDetector struct {
exec executor.Executor
exec executor.Executor
skipper *tcc.Skipper
}

func NewPythonProjectDetector(exec executor.Executor) *PythonProjectDetector {
return &PythonProjectDetector{exec: exec}
}

// WithSkipper attaches a TCC skipper so the walk skips macOS-protected
// directories. A nil skipper is a no-op. Returns the detector for chaining.
func (d *PythonProjectDetector) WithSkipper(s *tcc.Skipper) *PythonProjectDetector {
d.skipper = s
return d
}

// CountProjects counts Python projects with virtual environments.
func (d *PythonProjectDetector) CountProjects(_ context.Context, searchDirs []string) int {
return len(d.ListProjects(searchDirs))
Expand Down Expand Up @@ -129,6 +138,9 @@ func (d *PythonProjectDetector) listInDir(dir string) []model.ProjectInfo {
if !entry.IsDir() {
return nil
}
if d.skipper.ShouldSkip(path, dir) {
return filepath.SkipDir
}
name := entry.Name()
if name == "node_modules" || name == ".git" || name == ".cache" ||
name == "__pycache__" || name == ".tox" || name == "site-packages" ||
Expand Down
20 changes: 20 additions & 0 deletions internal/executor/home.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package executor

// ResolveHome returns the home directory of the console (GUI) user when
// present, falling back to the process's current user. Returns an empty
// string when neither resolves — callers degrade gracefully.
//
// On macOS the LoggedInUser path uses `stat /dev/console` to find the
// real GUI user even when the agent runs as root via launchd (issue #63),
// so this is the correct anchor for per-user paths like the TCC skip
// list. Shared by both community-mode scan and enterprise telemetry to
// keep them in lock-step.
func ResolveHome(exec Executor) string {
if u, err := exec.LoggedInUser(); err == nil {
return u.HomeDir
}
if u, err := exec.CurrentUser(); err == nil {
return u.HomeDir
}
return ""
}
Loading
Loading