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
11 changes: 11 additions & 0 deletions cmd/stepsecurity-dev-machine-guard/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +314 to +315

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can use constants that we have defined for Darwin (same for linux and windows) to keep the codebase consistent.

return
}

log.Progress("Sending initial telemetry...")
fmt.Println()
armExecutionWatchdog(telemetry.ExecutionDeadline(config.MaxExecutionDuration), log)
Expand Down
47 changes: 47 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 := ""
Expand Down Expand Up @@ -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 == "" {
Expand Down Expand Up @@ -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" {
Expand Down Expand Up @@ -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, "{{")
}
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)
}
}
104 changes: 104 additions & 0 deletions internal/schedinfo/info_darwin.go
Original file line number Diff line number Diff line change
@@ -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 <domain>/<label>` — parsed for
// state / pid / last-exit-code. We deliberately do NOT dump its full output:
// it's ~100 lines of internal launchd detail (endpoints, sandbox, inherited
// env) that's noise for troubleshooting. The concise plist config below is
// the illustrative dump instead.
domain, target := launchd.DomainTarget(exec)
stdout, stderr, code, err := exec.RunWithTimeout(ctx, queryTimeout, "launchctl", "print", target)
switch {
case err != nil:
info.Warnings = append(info.Warnings, fmt.Sprintf("launchctl print %s: %v", target, err))
case code != 0:
// Expected on no-GUI / SSH sessions ("Could not find service ...",
// "Bootstrap failed: 5") — see docs/launchd-troubleshooting.md.
info.Warnings = append(info.Warnings, fmt.Sprintf("launchctl print %s exited %d: %s", target, code, firstLine(stderr)))
default:
info.Loaded = true
applyLaunchctlPrint(&info, stdout)
}

// Fallback: `launchctl list <label>` for last exit + pid when print failed.
if !info.Loaded {
if out, _, c, e := exec.RunWithTimeout(ctx, queryTimeout, "launchctl", "list", launchd.Label); e == nil && c == 0 {
info.Loaded = strings.Contains(out, "LastExitStatus") || strings.Contains(out, launchd.Label)
applyLaunchctlList(&info, out)
}
}
_ = domain // domain currently informational; target carries it

// Illustrative config dump: `plutil -p` of the plist — the focused, readable
// schedule config (Label, ProgramArguments, StartInterval, RunAtLoad, env),
// the macOS analog of `schtasks /query /v`, far less noisy than launchctl print.
if out, _, c, e := exec.RunWithTimeout(ctx, queryTimeout, "plutil", "-p", plistPath); e == nil && c == 0 {
info.Raw = strings.TrimSpace(out)
}

// launchd exposes no "next fire" for a StartInterval job, so estimate it from
// the last run (agent.log mtime) + the interval. Labeled an estimate so it's
// not mistaken for a value launchd reported.
if !info.LogMtime.IsZero() && info.IntervalSeconds > 0 {
next := info.LogMtime.Add(time.Duration(info.IntervalSeconds) * time.Second)
info.NextRunTime = next.Format(time.RFC3339) + " (estimated: last run + interval)"
}

// Drift note: plist interval vs configured hours is a real misconfig signal.
if info.IntervalSeconds > 0 && info.ConfiguredHours > 0 && info.IntervalSeconds != info.ConfiguredHours*3600 {
info.Warnings = append(info.Warnings, fmt.Sprintf(
"plist StartInterval=%ds disagrees with config scan_frequency_hours=%d (%ds)",
info.IntervalSeconds, info.ConfiguredHours, info.ConfiguredHours*3600))
}

return info
}
50 changes: 50 additions & 0 deletions internal/schedinfo/info_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//go:build linux

package schedinfo

import (
"context"
"fmt"
"strings"

"github.com/step-security/dev-machine-guard/internal/executor"
"github.com/step-security/dev-machine-guard/internal/systemd"
)

// gather is best-effort on Linux: it confirms the systemd user timer footprint
// and captures `systemctl --user list-timers` output (logged at Debug) for the
// NEXT/LAST columns. Detailed per-field parsing is intentionally skipped — the
// table is locale/width-dependent; the agent.log mtime serves as the last-run
// proxy. macOS and Windows are the richly-parsed targets.
func gather(ctx context.Context, exec executor.Executor) Info {
info := Info{
Platform: "linux",
Manager: "systemd",
Label: "stepsecurity-dev-machine-guard.timer",
ConfiguredHours: configuredHours(),
Management: ManagementUnknown,
LogMtime: logMtime(),
}
if info.ConfiguredHours > 0 {
info.IntervalSeconds = info.ConfiguredHours * 3600
}

unitPath := systemd.TimerUnitPath()
info.UnitPath = unitPath
info.Scheduled = exec.FileExists(unitPath)

out, stderr, code, err := exec.RunWithTimeout(ctx, queryTimeout,
"systemctl", "--user", "list-timers", "--all", "--no-pager")
switch {
case err != nil:
info.Warnings = append(info.Warnings, fmt.Sprintf("systemctl list-timers: %v", err))
case code != 0:
info.Warnings = append(info.Warnings, fmt.Sprintf("systemctl list-timers exited %d: %s", code, firstLine(stderr)))
default:
info.Raw = out
if strings.Contains(out, "stepsecurity-dev-machine-guard") {
info.Loaded = true
}
}
return info
}
Loading
Loading