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
34 changes: 23 additions & 11 deletions cmd/stepsecurity-dev-machine-guard/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/step-security/dev-machine-guard/internal/featuregate"
"github.com/step-security/dev-machine-guard/internal/heartbeat"
"github.com/step-security/dev-machine-guard/internal/launchd"
"github.com/step-security/dev-machine-guard/internal/model"
"github.com/step-security/dev-machine-guard/internal/output"
"github.com/step-security/dev-machine-guard/internal/paths"
"github.com/step-security/dev-machine-guard/internal/progress"
Expand Down Expand Up @@ -244,7 +245,7 @@ func main() {
// Stamp the local heartbeat first — before the enterprise gate and
// the singleton lock inside telemetry.Run — so even runs that bail at
// the gate or die during startup leave an on-disk "I started" record.
writeHeartbeat("send-telemetry", log)
writeHeartbeat(exec, "send-telemetry", log)
if !config.IsEnterpriseMode() {
log.Error("Enterprise configuration not found. Run '%s configure' or download the script from your StepSecurity dashboard.", os.Args[0])
os.Exit(1)
Expand All @@ -263,17 +264,17 @@ func main() {
os.Exit(1)
}
switch runtime.GOOS {
case "windows":
case model.PlatformWindows:
if err := schtasks.Install(exec, log); err != nil {
log.Error("%v", err)
os.Exit(1)
}
case "darwin":
case model.PlatformDarwin:
if err := launchd.Install(exec, log); err != nil {
log.Error("%v", err)
os.Exit(1)
}
case "linux":
case model.PlatformLinux:
if err := systemd.Install(exec, log); err != nil {
log.Error("%v", err)
os.Exit(1)
Expand Down Expand Up @@ -302,14 +303,25 @@ func main() {
// If no one is logged in (unattended SCCM deploys), the trigger
// silently no-ops and the task fires on its next hourly tick;
// either way, no SYSTEM-context telemetry ever ships.
if runtime.GOOS == "windows" && winproc.IsLocalSystem() {
if runtime.GOOS == model.PlatformWindows && winproc.IsLocalSystem() {
if err := schtasks.RunNow(exec, log); err != nil {
log.Warn("could not trigger initial scan (%v) — the scheduled task will fire on its next interval", err)
}
runHookStateReconcile(exec, log)
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 == model.PlatformDarwin {
runHookStateReconcile(exec, log)
return
}

log.Progress("Sending initial telemetry...")
fmt.Println()
armExecutionWatchdog(telemetry.ExecutionDeadline(config.MaxExecutionDuration), log)
Expand All @@ -321,7 +333,7 @@ func main() {
// not race with that scan (issue #62). Run regardless of the
// telemetry result — the install itself succeeded and the schedule
// should activate; a failed initial telemetry run does not undo it.
if runtime.GOOS == "linux" {
if runtime.GOOS == model.PlatformLinux {
if err := systemd.StartTimer(exec, log); err != nil {
log.Warn("timer start failed (%v) — scheduled scans will resume after the next user-systemd reload", err)
}
Expand All @@ -346,17 +358,17 @@ func main() {
case "uninstall":
_, _ = fmt.Fprintf(os.Stdout, "StepSecurity Dev Machine Guard v%s\n\n", buildinfo.Version)
switch runtime.GOOS {
case "windows":
case model.PlatformWindows:
if err := schtasks.Uninstall(exec, log); err != nil {
log.Error("%v", err)
os.Exit(1)
}
case "darwin":
case model.PlatformDarwin:
if err := launchd.Uninstall(exec, log); err != nil {
log.Error("%v", err)
os.Exit(1)
}
case "linux":
case model.PlatformLinux:
if err := systemd.Uninstall(exec, log); err != nil {
log.Error("%v", err)
os.Exit(1)
Expand Down Expand Up @@ -607,8 +619,8 @@ func findLegacyLeftovers(legacy string) []string {
// logged at debug and never affects the run. The invocation method reuses the
// scheduler-footprint detection telemetry already does, so the heartbeat
// distinguishes a scheduled fire from a manual run.
func writeHeartbeat(command string, log *progress.Logger) {
if err := heartbeat.Write(paths.HeartbeatFile(), command, telemetry.DetectInvocationMethod()); err != nil {
func writeHeartbeat(exec executor.Executor, command string, log *progress.Logger) {
if err := heartbeat.Write(paths.HeartbeatFile(), command, telemetry.DetectInvocationMethod(exec, log)); err != nil {
log.Debug("heartbeat: failed to write %s: %v", paths.HeartbeatFile(), err)
}
}
Expand Down
47 changes: 47 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,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 := ""
Expand Down Expand Up @@ -306,6 +310,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 == "" {
Expand Down Expand Up @@ -489,10 +510,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" {
Expand Down Expand Up @@ -579,6 +602,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, "{{")
}
Expand Down
45 changes: 32 additions & 13 deletions internal/launchd/launchd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<uid> 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 {
Expand All @@ -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()

Expand Down Expand Up @@ -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/<uid>`. 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/<uid>` — 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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -274,7 +293,7 @@ const plistTmpl = `<?xml version="1.0" encoding="UTF-8"?>
<key>StartInterval</key>
<integer>{{.IntervalSeconds}}</integer>
<key>RunAtLoad</key>
<false/>{{if or .UserHome .StepSecurityHome}}
<true/>{{if or .UserHome .StepSecurityHome}}
<key>EnvironmentVariables</key>
<dict>{{if .UserHome}}
<key>HOME</key>
Expand Down
55 changes: 55 additions & 0 deletions internal/launchd/launchd_test.go
Original file line number Diff line number Diff line change
@@ -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, "<key>RunAtLoad</key>\n <true/>") {
t.Errorf("plist must set RunAtLoad=true:\n%s", out)
}
if strings.Contains(out, "<false/>") {
t.Errorf("plist must not contain RunAtLoad=false:\n%s", out)
}
if !strings.Contains(out, "<string>send-telemetry</string>") {
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/<uid>", domain)
}
if target != domain+"/"+label {
t.Errorf("target = %q, want %q", target, domain+"/"+label)
}
}
Loading
Loading