diff --git a/cmd/stepsecurity-dev-machine-guard/main.go b/cmd/stepsecurity-dev-machine-guard/main.go index 4782e69..7227023 100644 --- a/cmd/stepsecurity-dev-machine-guard/main.go +++ b/cmd/stepsecurity-dev-machine-guard/main.go @@ -305,6 +305,17 @@ func main() { return } + // On macOS, launchd.Install already loaded the plist, and RunAtLoad=true + // runs the initial scan immediately under the user's GUI session. Don't + // also scan inline here — that would double-scan at install (two TCC + // rounds + two uploads), with the second run blocked on the singleton + // lock. Mirrors the Windows-SYSTEM path above; the launchd-triggered + // scan's output lands in agent.log. + if runtime.GOOS == "darwin" { + runHookStateReconcile(exec, log) + return + } + log.Progress("Sending initial telemetry...") fmt.Println() armExecutionWatchdog(telemetry.ExecutionDeadline(config.MaxExecutionDuration), log) diff --git a/internal/config/config.go b/internal/config/config.go index ee88714..1e24ff8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -219,6 +219,10 @@ func RunConfigure() error { existing.APIEndpoint = promptValue(reader, "API Endpoint", existing.APIEndpoint) existing.APIKey = promptSecret(reader, "API Key", existing.APIKey) existing.ScanFrequencyHours = promptValue(reader, "Scan Frequency (hours)", existing.ScanFrequencyHours) + // Whole-process execution watchdog. A Go duration string ("2h", "30m"), + // "off"/"0" to disable, or blank for the built-in 4h default. See + // telemetry.ExecutionDeadline. + existing.MaxExecutionDuration = promptValue(reader, "Max Execution Duration (e.g. 2h, 30m; 'off' to disable; blank = default 4h)", existing.MaxExecutionDuration) // Search dirs currentDirs := "" @@ -302,6 +306,23 @@ func RunConfigure() error { existing.EnablePythonScan = nil } + // Include macOS TCC-protected dirs (Documents, Downloads, ~/Library/Mail, + // …). Default is to skip them so the agent never triggers permission + // prompts; opt in only after granting Full Disk Access (PPPC profile or + // System Settings). See docs/macos-tcc-permissions.md. Stored only when + // enabled — anything but "true" clears it back to the default skip. + currentTCC := "false" + if existing.IncludeTCCProtected != nil && *existing.IncludeTCCProtected { + currentTCC = "true" + } + tccInput := promptValue(reader, "Scan macOS TCC-protected dirs — Documents, Downloads, … (true/false)", currentTCC) + if strings.EqualFold(strings.TrimSpace(tccInput), "true") { + v := true + existing.IncludeTCCProtected = &v + } else { + existing.IncludeTCCProtected = nil + } + // Color mode currentColor := existing.ColorMode if currentColor == "" { @@ -485,10 +506,12 @@ func ShowConfigure() { fmt.Printf(" %-24s %s\n", "API Endpoint:", displayValue(cfg.APIEndpoint)) fmt.Printf(" %-24s %s\n", "API Key:", maskSecret(cfg.APIKey)) fmt.Printf(" %-24s %s\n", "Scan Frequency:", displayFrequency(cfg.ScanFrequencyHours)) + fmt.Printf(" %-24s %s\n", "Max Execution Duration:", displayMaxExecution(cfg.MaxExecutionDuration)) fmt.Printf(" %-24s %s\n", "Search Directories:", displayDirs(cfg.SearchDirs)) fmt.Printf(" %-24s %s\n", "Enable NPM Scan:", displayBoolScan(cfg.EnableNPMScan)) fmt.Printf(" %-24s %s\n", "Enable Brew Scan:", displayBoolScan(cfg.EnableBrewScan)) fmt.Printf(" %-24s %s\n", "Enable Python Scan:", displayBoolScan(cfg.EnablePythonScan)) + fmt.Printf(" %-24s %s\n", "Scan TCC-Protected Dirs:", displayTCC(cfg.IncludeTCCProtected)) fmt.Printf(" %-24s %s\n", "Color Mode:", displayColorMode(cfg.ColorMode)) fmt.Printf(" %-24s %s\n", "Output Format:", displayOutputFormat(cfg.OutputFormat)) if cfg.OutputFormat == "html" { @@ -575,6 +598,30 @@ func displayInstallDir(v string) string { return v } +// displayMaxExecution renders the execution-watchdog setting. Empty falls back +// to the built-in 4h default; "0"/"off" disables the watchdog; any other value +// is a Go duration string echoed as-is (validated at run time by +// telemetry.ExecutionDeadline). +func displayMaxExecution(v string) string { + switch v { + case "": + return "4h (default)" + case "0", "off": + return "off (watchdog disabled)" + default: + return v + } +} + +// displayTCC renders the macOS TCC opt-in. nil/false both mean the default +// (skip TCC-protected dirs to avoid permission prompts); true means scan them. +func displayTCC(v *bool) string { + if v != nil && *v { + return "true (scanning TCC-protected dirs)" + } + return "false (default — TCC-protected dirs skipped)" +} + func isPlaceholder(v string) bool { return strings.Contains(v, "{{") } diff --git a/internal/launchd/launchd.go b/internal/launchd/launchd.go index 36da136..64f8cf2 100644 --- a/internal/launchd/launchd.go +++ b/internal/launchd/launchd.go @@ -38,6 +38,22 @@ const ( // detector) can check for an installed footprint without re-deriving the path. const DaemonPlistPath = daemonPlistPath +// Label is the launchd job label for the agent. Exported so other packages +// (e.g. schedinfo) can address the job without re-deriving the constant. +const Label = label + +// DomainTarget returns the launchd domain and the domain/label service target +// for the current privilege level: the system domain for a root LaunchDaemon, +// gui/ for a per-user LaunchAgent. Single source of truth for the domain +// math reused by Install/Uninstall and the scheduler-info collector. +func DomainTarget(exec executor.Executor) (domain, target string) { + domain = "system" + if !exec.IsRoot() { + domain = fmt.Sprintf("gui/%d", os.Getuid()) + } + return domain, domain + "/" + label +} + // UserPlistPath returns the per-user launchd plist path installed when the // agent runs without root. Empty when the home directory cannot be resolved. func UserPlistPath() string { @@ -52,7 +68,12 @@ func agentPlistPath() string { return homeDir + "/Library/LaunchAgents/com.stepsecurity.agent.plist" } -// Install configures launchd for periodic scanning. If already installed, upgrades. +// Install configures launchd for periodic scanning and loads the job +// (upgrading in place if already installed). With RunAtLoad=true, loading the +// job triggers the run immediately — so this load IS the initial scan and the +// install command deliberately does NOT scan inline on macOS (see main.go). +// Doing both would double-scan at install: once inline, once at load, with the +// second blocked on the singleton lock. func Install(exec executor.Executor, log *progress.Logger) error { ctx := context.Background() @@ -160,15 +181,14 @@ func Install(exec executor.Executor, log *progress.Logger) error { log.Debug("launchd install: plist=%q log_dir=%q interval=%ds user_home=%q is_root=%v", plistPath, logDir, intervalSeconds, userHome, exec.IsRoot()) - // Bootstrap plist into its launchd domain. Apple actively recommends - // `bootstrap`/`bootout` over the older `load`/`unload` verbs, which - // are on the path to deprecation. Root daemons live in the `system` - // domain; user LaunchAgents in `gui/`. Available since macOS - // 10.11, so every machine we target supports it. - domain := "system" - if !exec.IsRoot() { - domain = fmt.Sprintf("gui/%d", os.Getuid()) - } + // Bootstrap the plist into its launchd domain. With RunAtLoad=true this + // runs the job immediately, so this load IS the initial scan — the install + // command does NOT scan inline on macOS (that would double-scan: once + // inline, once at load, the second blocked on the singleton lock). The scan + // runs under the user's GUI session and logs to agent.log. Apple recommends + // bootstrap/bootout over the deprecated load/unload verbs; root daemons live + // in the `system` domain, user LaunchAgents in `gui/` — see DomainTarget. + domain, _ := DomainTarget(exec) _, stderr, exitCode, err := exec.Run(ctx, "launchctl", "bootstrap", domain, plistPath) log.Debug("launchctl bootstrap %q %q: exit_code=%d err=%v stderr=%q", domain, plistPath, exitCode, err, stderr) if err != nil { @@ -181,8 +201,7 @@ func Install(exec executor.Executor, log *progress.Logger) error { log.Progress("launchd configuration completed successfully") log.Progress(" Plist: %s", plistPath) log.Progress(" Logs: %s/agent.log", logDir) - log.Progress("Installation complete!") - log.Progress("The agent will now run automatically every %d hours", hours) + log.Progress("The agent will run now (via launchd) and then every %d hours, plus at login", hours) return nil } @@ -274,7 +293,7 @@ const plistTmpl = ` StartInterval {{.IntervalSeconds}} RunAtLoad - {{if or .UserHome .StepSecurityHome}} + {{if or .UserHome .StepSecurityHome}} EnvironmentVariables {{if .UserHome}} HOME diff --git a/internal/launchd/launchd_test.go b/internal/launchd/launchd_test.go new file mode 100644 index 0000000..f57e7d1 --- /dev/null +++ b/internal/launchd/launchd_test.go @@ -0,0 +1,55 @@ +package launchd + +import ( + "strings" + "testing" + "text/template" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +func TestPlistTemplate_RunAtLoadAndScheduled(t *testing.T) { + tmpl, err := template.New("plist").Parse(plistTmpl) + if err != nil { + t.Fatalf("parse template: %v", err) + } + var sb strings.Builder + if err := tmpl.Execute(&sb, plistTemplateData{ + Label: label, + BinaryPath: "/usr/local/bin/stepsecurity-dev-machine-guard", + IntervalSeconds: 14400, + LogDir: "/Users/dev/.stepsecurity", + }); err != nil { + t.Fatalf("execute template: %v", err) + } + out := sb.String() + + // RunAtLoad must be true so login/boot is a (gated) catch-up trigger. + if !strings.Contains(out, "RunAtLoad\n ") { + t.Errorf("plist must set RunAtLoad=true:\n%s", out) + } + if strings.Contains(out, "") { + t.Errorf("plist must not contain RunAtLoad=false:\n%s", out) + } + if !strings.Contains(out, "send-telemetry") { + t.Errorf("plist must invoke send-telemetry:\n%s", out) + } +} + +func TestDomainTarget(t *testing.T) { + root := executor.NewMock() + root.SetIsRoot(true) + if domain, target := DomainTarget(root); domain != "system" || target != "system/"+label { + t.Errorf("root DomainTarget = %q,%q; want system, system/%s", domain, target, label) + } + + user := executor.NewMock() + user.SetIsRoot(false) + domain, target := DomainTarget(user) + if !strings.HasPrefix(domain, "gui/") { + t.Errorf("non-root domain = %q, want gui/", domain) + } + if target != domain+"/"+label { + t.Errorf("target = %q, want %q", target, domain+"/"+label) + } +} diff --git a/internal/schedinfo/info_darwin.go b/internal/schedinfo/info_darwin.go new file mode 100644 index 0000000..84d8981 --- /dev/null +++ b/internal/schedinfo/info_darwin.go @@ -0,0 +1,104 @@ +//go:build darwin + +package schedinfo + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/launchd" +) + +func gather(ctx context.Context, exec executor.Executor) Info { + info := Info{ + Platform: "darwin", + Manager: "launchd", + Label: launchd.Label, + ConfiguredHours: configuredHours(), + Management: ManagementUnknown, + LogMtime: logMtime(), + } + + // Resolve the plist for this run's privilege level (root LaunchDaemon vs + // per-user LaunchAgent), mirroring the install/uninstall paths. + plistPath := launchd.DaemonPlistPath + if !exec.IsRoot() { + plistPath = launchd.UserPlistPath() + } + info.UnitPath = plistPath + info.Scheduled = exec.FileExists(plistPath) + + // Parse the plist directly — it's our own well-formed XML, so reading + // StartInterval/RunAtLoad/ProgramArguments from disk is more robust than + // scraping plutil and needs no subprocess. + if data, err := os.ReadFile(plistPath); err == nil { + if pl, perr := parsePlist(data); perr == nil { + if pl.StartInterval > 0 { + info.IntervalSeconds = pl.StartInterval + } + ral := pl.RunAtLoad + info.RunAtLoad = &ral + info.Management = managementFromCmd(strings.Join(pl.ProgramArguments, " ")) + } else { + info.Warnings = append(info.Warnings, fmt.Sprintf("parse plist %s: %v", plistPath, perr)) + } + } else if info.Scheduled { + info.Warnings = append(info.Warnings, fmt.Sprintf("read plist %s: %v", plistPath, err)) + } + + // Live runtime state via `launchctl print /