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
40 changes: 28 additions & 12 deletions cmd/stepsecurity-dev-machine-guard/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,20 +56,32 @@ func main() {

exec := executor.NewReal()

// Quiet resolution: config is the base, CLI overrides.
quiet := false
if config.Quiet != nil {
quiet = *config.Quiet
// Log level resolution: default info → config file → CLI flag → --verbose → JSON override.
level := progress.LevelInfo
if config.LogLevel != "" {
if l, ok := progress.ParseLevel(config.LogLevel); ok {
level = l
}
}
if cfg.LogLevel != "" {
if l, ok := progress.ParseLevel(cfg.LogLevel); ok {
level = l
}
}
if cfg.Verbose {
quiet = false
level = progress.LevelDebug
}
if cfg.OutputFormat == "json" {
quiet = true
// Keep stdout clean for pipes: only errors on stderr.
level = progress.LevelError
}
// Note: send-telemetry and bare command (auto-detected enterprise) both
// respect the same quiet logic — config value wins, default is true.
log := progress.NewLogger(quiet)
log := progress.NewLogger(level)
log.Debug("resolved log level: %s (config=%q cli=%q verbose=%v output=%s)",
level, config.LogLevel, cfg.LogLevel, cfg.Verbose, cfg.OutputFormat)
log.Debug("config loaded: enterprise=%v api_endpoint=%q scan_freq=%q search_dirs=%v log_level=%q",
config.IsEnterpriseMode(), config.APIEndpoint, config.ScanFrequencyHours, config.SearchDirs, config.LogLevel)
log.Debug("cli parsed: command=%q output_format=%q output_format_set=%v color=%s include_bundled=%v",
cfg.Command, cfg.OutputFormat, cfg.OutputFormatSet, cfg.ColorMode, cfg.IncludeBundledPlugins)

switch cfg.Command {
case "configure":
Expand Down Expand Up @@ -149,18 +161,22 @@ func main() {

default:
// Community mode or auto-detect enterprise
if cfg.OutputFormatSet || cfg.HTMLOutputFile != "" {
switch {
case cfg.OutputFormatSet || cfg.HTMLOutputFile != "":
// Output format flag was explicitly set — community mode
log.Debug("dispatch: community scan (output format flag set)")
if err := scan.Run(exec, log, cfg); err != nil {
log.Error("%v", err)
os.Exit(1)
}
} else if config.IsEnterpriseMode() {
case config.IsEnterpriseMode():
log.Debug("dispatch: enterprise telemetry (auto-detected)")
if err := telemetry.Run(exec, log, cfg); err != nil {
log.Error("%v", err)
os.Exit(1)
}
} else {
default:
log.Debug("dispatch: community scan (default)")
if err := scan.Run(exec, log, cfg); err != nil {
log.Error("%v", err)
os.Exit(1)
Expand Down
17 changes: 15 additions & 2 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ type Config struct {
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
Verbose bool // --verbose (shortcut for --log-level=debug)
LogLevel string // "" = unset; one of "error", "warn", "info", "debug"
EnableNPMScan *bool // nil=auto, true/false=explicit
EnableBrewScan *bool // nil=auto, true/false=explicit
EnablePythonScan *bool // nil=auto, true/false=explicit
Expand Down Expand Up @@ -109,6 +110,17 @@ func Parse(args []string) (*Config, error) {
continue // skip the i++ at the bottom
case arg == "--verbose":
cfg.Verbose = true
case strings.HasPrefix(arg, "--log-level="):
level := strings.ToLower(strings.TrimPrefix(arg, "--log-level="))
switch level {
case "error", "warn", "warning", "info", "debug":
if level == "warning" {
level = "warn"
}
cfg.LogLevel = level
default:
return nil, fmt.Errorf("invalid log level: %s (must be error, warn, info, or debug)", level)
}
case arg == "-v" || arg == "--version" || arg == "version":
_, _ = fmt.Fprintf(os.Stdout, "StepSecurity Dev Machine Guard v%s\n", buildinfo.VersionString())
os.Exit(0)
Expand Down Expand Up @@ -151,7 +163,8 @@ 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)
--verbose Show progress messages (suppressed by default)
--log-level=LEVEL Log level: error | warn | info | debug (default: info)
--verbose Shortcut for --log-level=debug
--color=WHEN Color mode: auto | always | never (default: auto)
-v, --version Show version
-h, --help Show this help
Expand Down
46 changes: 26 additions & 20 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ var (
ColorMode string // "" means auto
OutputFormat string // "" means default (pretty)
HTMLOutputFile string // "" means not set
Quiet *bool // nil=default behavior
LogLevel string // "" means default (info); one of error/warn/info/debug
)

// ConfigFile is the JSON structure persisted to ~/.stepsecurity/config.json.
Expand All @@ -38,7 +38,7 @@ type ConfigFile struct {
ColorMode string `json:"color_mode,omitempty"`
OutputFormat string `json:"output_format,omitempty"`
HTMLOutputFile string `json:"html_output_file,omitempty"`
Quiet *bool `json:"quiet,omitempty"`
LogLevel string `json:"log_level,omitempty"`
}

// configDir returns ~/.stepsecurity.
Expand Down Expand Up @@ -98,8 +98,8 @@ func Load() {
if cfg.HTMLOutputFile != "" && HTMLOutputFile == "" {
HTMLOutputFile = cfg.HTMLOutputFile
}
if cfg.Quiet != nil && Quiet == nil {
Quiet = cfg.Quiet
if cfg.LogLevel != "" && LogLevel == "" {
LogLevel = cfg.LogLevel
}
}

Expand Down Expand Up @@ -239,19 +239,20 @@ func RunConfigure() error {
existing.HTMLOutputFile = promptValue(reader, "HTML Output File", existing.HTMLOutputFile)
}

// Quiet mode
currentQuiet := "false"
if existing.Quiet != nil && *existing.Quiet {
currentQuiet = "true"
// Log level
currentLevel := existing.LogLevel
if currentLevel == "" {
currentLevel = "info"
}
quietInput := promptValue(reader, "Quiet Mode (true/false)", currentQuiet)
switch strings.ToLower(quietInput) {
case "true":
v := true
existing.Quiet = &v
levelInput := promptValue(reader, "Log Level (error/warn/info/debug)", currentLevel)
switch strings.ToLower(strings.TrimSpace(levelInput)) {
case "error", "warn", "warning", "info", "debug":
existing.LogLevel = strings.ToLower(strings.TrimSpace(levelInput))
if existing.LogLevel == "warning" {
existing.LogLevel = "warn"
}
default:
v := false
existing.Quiet = &v
existing.LogLevel = "info"
}

// Save
Expand Down Expand Up @@ -348,7 +349,7 @@ func ShowConfigure() {
if cfg.OutputFormat == "html" {
fmt.Printf(" %-24s %s\n", "HTML Output File:", displayValue(cfg.HTMLOutputFile))
}
fmt.Printf(" %-24s %s\n", "Quiet Mode:", displayQuiet(cfg.Quiet))
fmt.Printf(" %-24s %s\n", "Log Level:", displayLogLevel(cfg.LogLevel))
}

func displayValue(v string) string {
Expand Down Expand Up @@ -409,11 +410,16 @@ func displayOutputFormat(v string) string {
return v
}

func displayQuiet(v *bool) string {
if v != nil && *v {
return "true"
func displayLogLevel(level string) string {
if level == "" {
return "info (default)"
}
switch strings.ToLower(strings.TrimSpace(level)) {
case "error", "warn", "warning", "info", "debug":
return level
default:
return fmt.Sprintf("%s (invalid — using info)", level)
}
return "false"
}

func isPlaceholder(v string) bool {
Expand Down
6 changes: 4 additions & 2 deletions internal/detector/brewscan.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,15 @@ func (s *BrewScanner) ScanFormulae(ctx context.Context) (model.BrewScanResult, b
errMsg := ""
if exitCode != 0 {
errMsg = "brew list --formula --versions failed"
s.log.Progress(" Brew formulae scan failed: exit_code=%d stderr=%s", exitCode, stderr)
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",
Expand Down Expand Up @@ -71,14 +72,15 @@ func (s *BrewScanner) ScanCasks(ctx context.Context) (model.BrewScanResult, bool
errMsg := ""
if exitCode != 0 {
errMsg = "brew list --cask --versions failed"
s.log.Progress(" Brew casks scan failed: exit_code=%d stderr=%s", exitCode, stderr)
s.log.Warn("brew casks 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 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))

return model.BrewScanResult{
ScanType: "casks",
Expand Down
10 changes: 10 additions & 0 deletions internal/detector/nodescan.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ func (s *NodeScanner) scanNPMGlobal(ctx context.Context) (model.NodeScanResult,
version := s.getVersion(ctx, "npm", "--version")
prefix := s.getOutput(ctx, "npm", "config", "get", "prefix")
if prefix == "" {
s.log.Warn("npm found but `npm config get prefix` returned empty — skipping npm global scan")
return model.NodeScanResult{}, false
}

Expand All @@ -143,7 +144,9 @@ func (s *NodeScanner) scanNPMGlobal(ctx context.Context) (model.NodeScanResult,
errMsg := ""
if exitCode != 0 {
errMsg = "npm list -g command failed with exit code"
s.log.Warn("npm list -g failed (exit_code=%d, %dms) — results may be incomplete", exitCode, duration)
}
s.log.Debug("npm global scan: version=%s prefix=%s exit_code=%d stdout_bytes=%d duration=%dms", version, prefix, exitCode, len(stdout), duration)

return model.NodeScanResult{
ProjectPath: prefix,
Expand Down Expand Up @@ -177,7 +180,9 @@ func (s *NodeScanner) scanYarnGlobal(ctx context.Context) (model.NodeScanResult,
errMsg := ""
if exitCode != 0 {
errMsg = "yarn global list command failed"
s.log.Warn("yarn global list failed (exit_code=%d, %dms) — results may be incomplete", exitCode, duration)
}
s.log.Debug("yarn global scan: version=%s global_dir=%s exit_code=%d stdout_bytes=%d duration=%dms", version, globalDir, exitCode, len(stdout), duration)

return model.NodeScanResult{
ProjectPath: globalDir,
Expand Down Expand Up @@ -211,7 +216,9 @@ func (s *NodeScanner) scanPnpmGlobal(ctx context.Context) (model.NodeScanResult,
errMsg := ""
if exitCode != 0 {
errMsg = "pnpm list -g command failed"
s.log.Warn("pnpm list -g failed (exit_code=%d, %dms) — results may be incomplete", exitCode, duration)
}
s.log.Debug("pnpm global scan: version=%s global_dir=%s exit_code=%d stdout_bytes=%d duration=%dms", version, globalDir, exitCode, len(stdout), duration)

return model.NodeScanResult{
ProjectPath: globalDir,
Expand Down Expand Up @@ -268,6 +275,8 @@ func (s *NodeScanner) ScanProjects(ctx context.Context, searchDirs []string) []m
})
}

s.log.Debug("node project discovery: found %d package.json files across %d search dir(s)", len(projects), len(searchDirs))

// Phase 2: Sort by modification time descending (most recent first)
sort.Slice(projects, func(i, j int) bool {
return projects[i].modTime > projects[j].modTime
Expand All @@ -281,6 +290,7 @@ func (s *NodeScanner) ScanProjects(ctx context.Context, searchDirs []string) []m
for i, p := range projects {
if i >= maxNodeProjects {
s.log.Progress(" Reached maximum of %d projects, stopping search", maxNodeProjects)
s.log.Warn("Node project scan truncated at %d projects (total discovered: %d) — oldest projects were skipped", maxNodeProjects, len(projects))
break
}
if totalSize > maxBytes {
Expand Down
2 changes: 1 addition & 1 deletion internal/detector/nodescan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (
)

func newTestScanner(exec *executor.Mock) *NodeScanner {
log := progress.NewLogger(false)
log := progress.NewLogger(progress.LevelInfo)
return NewNodeScanner(exec, log, "")
}

Expand Down
2 changes: 2 additions & 0 deletions internal/detector/pythonscan.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ func (s *PythonScanner) ScanGlobalPackages(ctx context.Context) []model.PythonSc
errMsg := ""
if exitCode != 0 {
errMsg = spec.binary + " list command failed"
s.log.Warn("%s list failed (exit_code=%d, %dms) — results may be incomplete", spec.binary, exitCode, duration)
}
s.log.Debug("%s global scan: version=%s binary=%s exit_code=%d stdout_bytes=%d duration=%dms", spec.name, version, binPath, exitCode, len(stdout), duration)

results = append(results, model.PythonScanResult{
PackageManager: spec.name,
Expand Down
8 changes: 6 additions & 2 deletions internal/launchd/launchd.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ func Install(exec executor.Executor, log *progress.Logger) error {
if isConfigured(ctx, exec) {
log.Progress("Existing agent installation detected. Upgrading...")
if err := doUninstall(ctx, exec, log); err != nil {
log.Progress("Warning: failed to remove previous installation: %v", err)
log.Warn("failed to remove previous launchd installation: %v — continuing install anyway", err)
}
log.Progress("Previous installation removed. Installing new version...")
}
Expand Down Expand Up @@ -106,8 +106,11 @@ func Install(exec executor.Executor, log *progress.Logger) error {
_ = os.Chmod(plistPath, 0o644)
}

log.Debug("launchd install: plist=%q log_dir=%q interval=%ds user_home=%q is_root=%v", plistPath, logDir, intervalSeconds, userHome, exec.IsRoot())

// Load plist
_, _, exitCode, err := exec.Run(ctx, "launchctl", "load", plistPath)
log.Debug("launchctl load %q: exit_code=%d err=%v", plistPath, exitCode, err)
if err != nil || exitCode != 0 {
return fmt.Errorf("failed to load launchd configuration")
}
Expand Down Expand Up @@ -142,7 +145,8 @@ func doUninstall(ctx context.Context, exec executor.Executor, log *progress.Logg
// Unload
stdout, _, _, _ := exec.Run(ctx, "launchctl", "list")
if strings.Contains(stdout, label) {
_, _, _, _ = exec.Run(ctx, "launchctl", "unload", plistPath)
_, _, exitCode, err := exec.Run(ctx, "launchctl", "unload", plistPath)
log.Debug("launchctl unload %q: exit_code=%d err=%v", plistPath, exitCode, err)
log.Progress("Unloaded launchd agent")
}

Expand Down
Loading
Loading