From eba9dfa7d8d8e9d30cfd43186dfd092004bd84ce Mon Sep 17 00:00:00 2001 From: Shubham Malik Date: Tue, 18 Aug 2026 09:36:59 +0530 Subject: [PATCH 1/9] feat(developer-mdm): run telemetry scans at OS background priority --- CHANGELOG.md | 7 ++- internal/bgpriority/apply_darwin.go | 28 +++++++++ internal/bgpriority/apply_linux.go | 44 ++++++++++++++ internal/bgpriority/apply_other.go | 9 +++ internal/bgpriority/apply_windows.go | 36 +++++++++++ internal/bgpriority/bgpriority.go | 44 ++++++++++++++ internal/bgpriority/bgpriority_test.go | 82 ++++++++++++++++++++++++++ internal/telemetry/telemetry.go | 7 +++ 8 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 internal/bgpriority/apply_darwin.go create mode 100644 internal/bgpriority/apply_linux.go create mode 100644 internal/bgpriority/apply_other.go create mode 100644 internal/bgpriority/apply_windows.go create mode 100644 internal/bgpriority/bgpriority.go create mode 100644 internal/bgpriority/bgpriority_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index db54010c..7f3fc756 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 See [VERSIONING.md](VERSIONING.md) for why the version starts at 1.8.1. +## [Unreleased] + +### Added + +- **Telemetry scans run at OS background priority**: at the start of every enterprise scan the agent drops itself — and, on macOS and Linux, every child process it spawns — into the platform's background CPU/IO band (macOS `PRIO_DARWIN_BG`, the Time Machine/Spotlight tier; Linux `nice 19` plus best-effort ionice priority 7; Windows below-normal priority class plus background IO/memory mode), so disk-heavy phases no longer compete with the interactive user. Every mechanism is throttled-but-guaranteed-progress, never an idle-only class that could starve under sustained load, and the existing per-phase budgets and scan deadline bound the worst case. Interactive community `scan` runs are unaffected. Per-device escape hatch: `STEPSEC_DISABLE_BACKGROUND_PRIORITY=1`. + ## [1.16.0] - 2026-08-20 ### Added @@ -21,7 +27,6 @@ See [VERSIONING.md](VERSIONING.md) for why the version starts at 1.8.1. - **The macOS TCC skipper is wired into AI CLI detection.** The new resolution ladders stat candidates directly instead of descending a walk, so the walk-level skip could not protect them; consent is now checked before every stat and again on every resolved symlink. The pnpm and fnm trees under `~/Library` are exempted, since the coarse `~/Library` skip that is correct for a walk would otherwise drop both macOS channels silently. - **CI: release publishing is gated on verification.** A new `publish-release.yml` runs the verification suite as a reusable workflow and publishes the draft release, marking it latest, only if every check passes — signed checksums, Windows Authenticode, macOS notarization — replacing the manual `gh release edit --draft=false --latest` step. Verification now also requires a valid out-of-band Ed25519 `.sha256.sig` for the `x64` and `arm64` `.intunewin` packages, so every distributable artifact is covered. Because those checksums are created outside the repository, a compromised repository alone cannot ship a release that customers' loaders will accept. - ## [1.15.0] - 2026-08-03 ### Added diff --git a/internal/bgpriority/apply_darwin.go b/internal/bgpriority/apply_darwin.go new file mode 100644 index 00000000..26249f9d --- /dev/null +++ b/internal/bgpriority/apply_darwin.go @@ -0,0 +1,28 @@ +//go:build darwin + +package bgpriority + +import ( + "fmt" + "syscall" +) + +// Darwin-specific setpriority(2) selectors, spelled out because +// golang.org/x/sys/unix doesn't define them (see sys/resource.h). +const ( + // prioDarwinProcess scopes the call to a whole process (PRIO_DARWIN_PROCESS). + prioDarwinProcess = 4 + // prioDarwinBG places the process in the Darwin background band + // (PRIO_DARWIN_BG): throttled CPU, disk IO, and network — the tier Time + // Machine and Spotlight indexing run at. Throttled means a reduced + // proportional share under contention, never starvation, and effectively + // full speed on an otherwise idle machine. + prioDarwinBG = 0x1000 +) + +func apply() (string, error) { + if err := syscall.Setpriority(prioDarwinProcess, 0, prioDarwinBG); err != nil { + return "", fmt.Errorf("setpriority(PRIO_DARWIN_PROCESS, PRIO_DARWIN_BG): %w", err) + } + return "macOS background task policy (throttled CPU/IO/network, inherited by child processes)", nil +} diff --git a/internal/bgpriority/apply_linux.go b/internal/bgpriority/apply_linux.go new file mode 100644 index 00000000..303b7634 --- /dev/null +++ b/internal/bgpriority/apply_linux.go @@ -0,0 +1,44 @@ +//go:build linux + +package bgpriority + +import ( + "fmt" + "strings" + "syscall" +) + +// ioprio_set(2) encoding (linux/ioprio.h), spelled out because neither the +// stdlib nor golang.org/x/sys/unix wraps the call itself. +const ( + ioprioWhoProcess = 1 // IOPRIO_WHO_PROCESS: target a single process + ioprioClassShift = 13 + // ioprioClassBE / ioprioBELowest: best-effort class, lowest slot. + // Deliberately NOT the idle class (3): idle IO is only serviced when the + // disk is otherwise quiet and can be starved indefinitely under a + // sustained workload (a running build), whereas best-effort prio 7 is the + // last slot in the round-robin and always makes progress. + ioprioClassBE = 2 + ioprioBELowest = 7 +) + +func apply() (string, error) { + var parts []string + + niceErr := syscall.Setpriority(syscall.PRIO_PROCESS, 0, 19) + if niceErr == nil { + parts = append(parts, "nice 19") + } + + ioprio := uintptr(ioprioClassBE< Date: Tue, 18 Aug 2026 09:37:33 +0530 Subject: [PATCH 2/9] feat(developer-mdm): detect and report sleep-spanning runs --- CHANGELOG.md | 1 + internal/telemetry/phase_deadline.go | 5 +- internal/telemetry/phase_tracker.go | 62 +++++++++++++--- .../telemetry/phase_tracker_sleep_test.go | 73 +++++++++++++++++++ internal/telemetry/telemetry.go | 6 ++ 5 files changed, 136 insertions(+), 11 deletions(-) create mode 100644 internal/telemetry/phase_tracker_sleep_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f3fc756..29777a3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ See [VERSIONING.md](VERSIONING.md) for why the version starts at 1.8.1. ### Added - **Telemetry scans run at OS background priority**: at the start of every enterprise scan the agent drops itself — and, on macOS and Linux, every child process it spawns — into the platform's background CPU/IO band (macOS `PRIO_DARWIN_BG`, the Time Machine/Spotlight tier; Linux `nice 19` plus best-effort ionice priority 7; Windows below-normal priority class plus background IO/memory mode), so disk-heavy phases no longer compete with the interactive user. Every mechanism is throttled-but-guaranteed-progress, never an idle-only class that could starve under sustained load, and the existing per-phase budgets and scan deadline bound the worst case. Interactive community `scan` runs are unaffected. Per-device escape hatch: `STEPSEC_DISABLE_BACKGROUND_PRIORITY=1`. +- **Sleep-spanning runs are detected and reported**: when the machine sleeps mid-scan, wall-clock and monotonic elapsed time diverge (the monotonic clock halts during sleep on macOS/Linux); the agent now reports that divergence as `slept_ms` per completed phase and per run in run-status heartbeats and the final telemetry payload, and logs "system slept ~Xm" warnings plus a run-summary note. Report-only — phase durations stay monotonic (actual work time) and the scan is never aborted. Divergence under 60 seconds is ignored to absorb NTP clock steps. ## [1.16.0] - 2026-08-20 diff --git a/internal/telemetry/phase_deadline.go b/internal/telemetry/phase_deadline.go index ad2accf5..dd269769 100644 --- a/internal/telemetry/phase_deadline.go +++ b/internal/telemetry/phase_deadline.go @@ -109,5 +109,8 @@ func endPhase(phaseCtx context.Context, cancel context.CancelFunc, log.Warn("phase %s exceeded budget %s — continuing with partial results", name, budget) } cancel() - tracker.Finish() + if pc, finished := tracker.Finish(); finished && pc.SleptMs > 0 { + log.Warn("system slept ~%s during phase %s — reported durations exclude sleep", + (time.Duration(pc.SleptMs) * time.Millisecond).Round(time.Second), name) + } } diff --git a/internal/telemetry/phase_tracker.go b/internal/telemetry/phase_tracker.go index 9776a467..e47f5d55 100644 --- a/internal/telemetry/phase_tracker.go +++ b/internal/telemetry/phase_tracker.go @@ -11,6 +11,12 @@ type PhaseCompletion struct { Name string `json:"name"` FinishedAt int64 `json:"finished_at"` DurationMs int64 `json:"duration_ms"` + // SleptMs is how long the system was suspended while this phase ran: + // the wall-clock elapsed minus the monotonic elapsed (macOS/Linux + // monotonic clocks halt during sleep; Windows QPC usually keeps ticking, + // so this under-reports there). DurationMs stays monotonic — actual work + // time — so a phase that spanned a laptop nap reports both honestly. + SleptMs int64 `json:"slept_ms,omitempty"` } // RunStatusInfo is the structured progress snapshot sent on each phase @@ -33,6 +39,29 @@ type RunStatusInfo struct { // rapidly. Backend handlers must tolerate this field being absent on // any given snapshot. LogTailGzipBase64 string `json:"log_tail_gzip_b64,omitempty"` + // SleptMs is the run-level counterpart of PhaseCompletion.SleptMs, + // measured from run start so it also covers sleep that lands between + // phases. Report-only: ElapsedMs stays monotonic. + SleptMs int64 `json:"slept_ms,omitempty"` +} + +// minReportedSleep is the smallest wall-vs-monotonic divergence reported as +// system sleep. NTP steps/slews and scheduler jitter can move the wall clock +// a few seconds relative to the monotonic clock; a genuine sleep is minutes. +const minReportedSleep = 60 * time.Second + +// sleptDuration returns how long the system was suspended during an +// interval, given the interval measured on the wall clock and on the +// monotonic clock. Divergence below minReportedSleep — including the +// degenerate equal-inputs case, which is what a clock source without a +// monotonic reading (time.Unix-constructed test clocks) produces — reports +// zero. Never negative. +func sleptDuration(wallElapsed, monoElapsed time.Duration) time.Duration { + d := wallElapsed - monoElapsed + if d < minReportedSleep { + return 0 + } + return d } // PhaseTracker accumulates phase lifecycle events for a single telemetry @@ -78,26 +107,35 @@ func (t *PhaseTracker) Start(phase string) { t.phaseStartedAt = t.now() } -// Finish records completion of the current phase. No-op when nothing is -// in flight — safe to defer. -func (t *PhaseTracker) Finish() { +// Finish records completion of the current phase and returns it. The bool +// is false when nothing was in flight — safe to defer and to call in +// statement position. +func (t *PhaseTracker) Finish() (PhaseCompletion, bool) { t.mu.Lock() defer t.mu.Unlock() - t.finishLocked() + return t.finishLocked() } -func (t *PhaseTracker) finishLocked() { +func (t *PhaseTracker) finishLocked() (PhaseCompletion, bool) { if t.currentPhase == "" { - return + return PhaseCompletion{}, false } finishedAt := t.now() - t.completed = append(t.completed, PhaseCompletion{ + // Sub prefers the monotonic readings when both stamps carry one (actual + // work time — halts during system sleep); Round(0) strips them, so the + // second Sub is pure wall clock. The difference is time spent asleep. + mono := finishedAt.Sub(t.phaseStartedAt) + wall := finishedAt.Round(0).Sub(t.phaseStartedAt.Round(0)) + pc := PhaseCompletion{ Name: t.currentPhase, FinishedAt: finishedAt.Unix(), - DurationMs: finishedAt.Sub(t.phaseStartedAt).Milliseconds(), - }) + DurationMs: mono.Milliseconds(), + SleptMs: sleptDuration(wall, mono).Milliseconds(), + } + t.completed = append(t.completed, pc) t.currentPhase = "" t.currentPhaseDetail = "" + return pc, true } // UpdateDetail sets a free-form sub-progress string for the current @@ -140,9 +178,13 @@ func (t *PhaseTracker) Snapshot() RunStatusInfo { current = current + " (" + t.currentPhaseDetail + ")" } + now := t.now() out := RunStatusInfo{ CurrentPhase: current, - ElapsedMs: t.now().Sub(t.startedAt).Milliseconds(), + ElapsedMs: now.Sub(t.startedAt).Milliseconds(), + // Run-level sleep, measured from run start so it also covers sleep + // landing between phases (per-phase SleptMs can't see those gaps). + SleptMs: sleptDuration(now.Round(0).Sub(t.startedAt.Round(0)), now.Sub(t.startedAt)).Milliseconds(), } if len(t.completed) > 0 { out.PhasesCompleted = make([]PhaseCompletion, len(t.completed)) diff --git a/internal/telemetry/phase_tracker_sleep_test.go b/internal/telemetry/phase_tracker_sleep_test.go new file mode 100644 index 00000000..7d15a6b8 --- /dev/null +++ b/internal/telemetry/phase_tracker_sleep_test.go @@ -0,0 +1,73 @@ +package telemetry + +import ( + "testing" + "time" +) + +func TestSleptDuration(t *testing.T) { + tests := []struct { + name string + wall time.Duration + mono time.Duration + want time.Duration + }{ + {name: "equal inputs (no monotonic reading)", wall: 5 * time.Minute, mono: 5 * time.Minute, want: 0}, + {name: "divergence below threshold (NTP step)", wall: 5*time.Minute + 59*time.Second, mono: 5 * time.Minute, want: 0}, + {name: "divergence exactly at threshold", wall: 6 * time.Minute, mono: 5 * time.Minute, want: 60 * time.Second}, + {name: "incident shape: 53m wall vs 7m work", wall: 53 * time.Minute, mono: 7 * time.Minute, want: 46 * time.Minute}, + {name: "negative divergence clamps to zero", wall: 4 * time.Minute, mono: 5 * time.Minute, want: 0}, + {name: "zero interval", wall: 0, mono: 0, want: 0}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := sleptDuration(tc.wall, tc.mono); got != tc.want { + t.Errorf("sleptDuration(%v, %v) = %v, want %v", tc.wall, tc.mono, got, tc.want) + } + }) + } +} + +// The fakeClock produces time.Unix-constructed stamps with no monotonic +// reading, so wall and monotonic elapsed are identical — the tracker must +// report zero sleep everywhere. This is the degradation contract for any +// environment where the two clocks cannot diverge. +func TestPhaseTracker_NoMonotonicReadingReportsZeroSleep(t *testing.T) { + clk := &fakeClock{cur: time.Unix(1_700_000_000, 0), step: 2 * time.Minute} + pt := newPhaseTrackerWithClock(clk.now) + + pt.Start("malicious_file_scan") // t=0 + pc, finished := pt.Finish() // t=2m + if !finished { + t.Fatal("Finish() finished = false, want true") + } + if pc.SleptMs != 0 { + t.Errorf("phase slept_ms = %d, want 0 without monotonic divergence", pc.SleptMs) + } + + snap := pt.Snapshot() // t=4m + if snap.SleptMs != 0 { + t.Errorf("run slept_ms = %d, want 0 without monotonic divergence", snap.SleptMs) + } + if len(snap.PhasesCompleted) != 1 || snap.PhasesCompleted[0].SleptMs != 0 { + t.Errorf("phases_completed = %+v, want one entry with slept_ms 0", snap.PhasesCompleted) + } +} + +func TestPhaseTracker_FinishReturnValues(t *testing.T) { + clk := &fakeClock{cur: time.Unix(1_700_000_000, 0), step: time.Second} + pt := newPhaseTrackerWithClock(clk.now) + + if pc, finished := pt.Finish(); finished || pc.Name != "" { + t.Errorf("Finish() with nothing in flight = (%+v, %v), want zero value and false", pc, finished) + } + + pt.Start("ide_scan") + pc, finished := pt.Finish() + if !finished { + t.Fatal("Finish() finished = false, want true") + } + if pc.Name != "ide_scan" || pc.DurationMs != 1000 { + t.Errorf("Finish() completion = %+v, want ide_scan with duration_ms 1000", pc) + } +} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 6a3098a1..a31289de 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -1268,6 +1268,12 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err fmt.Fprintln(os.Stderr) log.Progress("Telemetry collection completed successfully") + // Fresh snapshot (not finalStatusInfo, which predates the upload phase) + // so sleep during the upload is counted too. + if s := tracker.Snapshot(); s.SleptMs > 0 { + log.Progress("Note: system slept ~%s during this run — reported durations exclude sleep", + (time.Duration(s.SleptMs) * time.Millisecond).Round(time.Second)) + } tccSkipper.LogHits(log.Debug) // Final progress post — AFTER the upload and the completion lines above — From 1c6ea27bfd654871a243640bfe1455936146a7fa Mon Sep 17 00:00:00 2001 From: Shubham Malik Date: Fri, 21 Aug 2026 10:23:27 +0530 Subject: [PATCH 3/9] feat(developer-mdm): relocate config.json into the install dir --- CHANGELOG.md | 2 + cmd/stepsecurity-dev-machine-guard/main.go | 8 +- internal/config/config.go | 63 ++++++++-- internal/config/config_path_test.go | 131 +++++++++++++++++++++ internal/paths/paths.go | 9 +- 5 files changed, 197 insertions(+), 16 deletions(-) create mode 100644 internal/config/config_path_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 29777a3f..0e164c88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ See [VERSIONING.md](VERSIONING.md) for why the version starts at 1.8.1. ### Added - **Telemetry scans run at OS background priority**: at the start of every enterprise scan the agent drops itself — and, on macOS and Linux, every child process it spawns — into the platform's background CPU/IO band (macOS `PRIO_DARWIN_BG`, the Time Machine/Spotlight tier; Linux `nice 19` plus best-effort ionice priority 7; Windows below-normal priority class plus background IO/memory mode), so disk-heavy phases no longer compete with the interactive user. Every mechanism is throttled-but-guaranteed-progress, never an idle-only class that could starve under sustained load, and the existing per-phase budgets and scan deadline bound the worst case. Interactive community `scan` runs are unaffected. Per-device escape hatch: `STEPSEC_DISABLE_BACKGROUND_PRIORITY=1`. +- **config.json travels with the install dir**: the agent now resolves config.json binary-relative first — next to the binary, then one directory up (the loader layout is `/bin/` with `/config.json`) — before the legacy `~/.stepsecurity` fallback, and `configure`-style writes follow the file that was read. Combined with the loader change that writes config.json into the install directory (keeping a legacy compatibility copy refreshed for older binaries), a custom Install Directory now holds the binary, logs, state, and configuration in one tree instead of leaving config.json behind in the user home. Default installs are byte-identical (the binary already lives under `~/.stepsecurity/bin`). - **Sleep-spanning runs are detected and reported**: when the machine sleeps mid-scan, wall-clock and monotonic elapsed time diverge (the monotonic clock halts during sleep on macOS/Linux); the agent now reports that divergence as `slept_ms` per completed phase and per run in run-status heartbeats and the final telemetry payload, and logs "system slept ~Xm" warnings plus a run-summary note. Report-only — phase durations stay monotonic (actual work time) and the scan is never aborted. Divergence under 60 seconds is ignored to absorb NTP clock steps. ## [1.16.0] - 2026-08-20 @@ -28,6 +29,7 @@ See [VERSIONING.md](VERSIONING.md) for why the version starts at 1.8.1. - **The macOS TCC skipper is wired into AI CLI detection.** The new resolution ladders stat candidates directly instead of descending a walk, so the walk-level skip could not protect them; consent is now checked before every stat and again on every resolved symlink. The pnpm and fnm trees under `~/Library` are exempted, since the coarse `~/Library` skip that is correct for a walk would otherwise drop both macOS channels silently. - **CI: release publishing is gated on verification.** A new `publish-release.yml` runs the verification suite as a reusable workflow and publishes the draft release, marking it latest, only if every check passes — signed checksums, Windows Authenticode, macOS notarization — replacing the manual `gh release edit --draft=false --latest` step. Verification now also requires a valid out-of-band Ed25519 `.sha256.sig` for the `x64` and `arm64` `.intunewin` packages, so every distributable artifact is covered. Because those checksums are created outside the repository, a compromised repository alone cannot ship a release that customers' loaders will accept. + ## [1.15.0] - 2026-08-03 ### Added diff --git a/cmd/stepsecurity-dev-machine-guard/main.go b/cmd/stepsecurity-dev-machine-guard/main.go index 485108e7..4073de23 100644 --- a/cmd/stepsecurity-dev-machine-guard/main.go +++ b/cmd/stepsecurity-dev-machine-guard/main.go @@ -73,7 +73,8 @@ func main() { os.Exit(aiagentscli.RunHook(os.Stdin, os.Stdout, os.Stderr, os.Args[2:])) } - // Load persisted config (~/.stepsecurity/config.json) before parsing CLI + // Load persisted config (config.json from the install dir, falling back + // to ~/.stepsecurity — see internal/config.readConfigDir) before parsing CLI config.Load() cfg, err := cli.Parse(os.Args[1:]) @@ -659,8 +660,9 @@ func scanJSONEncoder(w io.Writer) *json.Encoder { // findLegacyLeftovers checks the legacy ~/.stepsecurity dir for agent // files the operator may have moved (intentionally) to a new install // dir. Returns basenames of present diagnostic files (config.json is -// excluded — it must stay at the legacy path as the bootstrap, so its -// presence there is expected and not a leftover to migrate). +// excluded — loaders keep a compatibility copy refreshed there for +// binaries that predate the binary-relative config lookup, so its +// presence is expected and not a leftover to migrate). func findLegacyLeftovers(legacy string) []string { candidates := []string{ "agent.error.log", diff --git a/internal/config/config.go b/internal/config/config.go index 9bdc0d01..53975e92 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,7 +24,7 @@ var ( 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. + InstallDir string // "" means default (~/.stepsecurity); non-empty makes the agent put all its files (logs, hook errors, future state) under this directory. config.json itself is resolved binary-relative first (the loader writes it into the install dir next to bin/), falling back to the legacy location — see readConfigDir. Per-run opt-out is the CLI flag --install-dir=. Resolution: --install-dir flag > STEPSECURITY_HOME env > this field > default — see internal/paths. // UseLegacyPackageScan, when true, disables the scan-state delta-upload // optimization for npm and Python project scans — every run re-uploads // the full snapshot as in pre-1.13 agents. @@ -102,10 +102,46 @@ func userConfigDir() string { // as the logged-in user — the two never share a $HOME, so config has to // live somewhere both can read. C:\ProgramData is that place. +// executablePath is a seam for tests; production value is os.Executable. +var executablePath = os.Executable + +// exeAdjacentConfigDir returns the directory of a config.json that lives in +// the running binary's install tree: the binary's own directory, then its +// parent (the loader layout is /bin/ with config.json at +// /config.json). This is what lets a custom Install Directory +// carry the configuration along with the binary instead of pinning it to +// ~/.stepsecurity — the binary always knows its own path, so there is no +// bootstrap chicken-and-egg. Empty when neither location holds a config.json +// or the executable path can't be resolved (then the legacy chain applies). +// Symlinks on the executable are resolved so a symlinked binary still finds +// its real install tree. +func exeAdjacentConfigDir() string { + exe, err := executablePath() + if err != nil || exe == "" { + return "" + } + if resolved, err := filepath.EvalSymlinks(exe); err == nil && resolved != "" { + exe = resolved + } + exeDir := filepath.Dir(exe) + for _, dir := range []string{exeDir, filepath.Dir(exeDir)} { + if _, err := os.Stat(filepath.Join(dir, "config.json")); err == nil { + return dir + } + } + return "" +} + // readConfigDir returns the directory we should READ config from. -// Prefers machine-wide if a config exists there (so an MSI-deployed install -// is visible even when the scanner runs as an unprivileged user). +// Binary-relative first (config travels with the install dir — for the +// default install this resolves to ~/.stepsecurity anyway, since the binary +// sits in ~/.stepsecurity/bin). Then machine-wide if a config exists there +// (so an MSI-deployed install is visible even when the scanner runs as an +// unprivileged user). Then the per-user legacy location. func readConfigDir() string { + if ead := exeAdjacentConfigDir(); ead != "" { + return ead + } if mcd := machineConfigDir(); mcd != "" { if _, err := os.Stat(filepath.Join(mcd, "config.json")); err == nil { return mcd @@ -115,10 +151,17 @@ func readConfigDir() string { } // writeConfigDir returns the directory we should WRITE config to. -// Elevated/admin/SYSTEM context → machine-wide (Windows only). Otherwise -// per-user. This is what makes `configure` invoked from an MSI custom -// action put the config where the scheduled task can later read it. +// Write where we read: once a config.json exists in the binary's install +// tree, configure/persist updates must target that same file — otherwise a +// divergent copy appears at the legacy path and the next read (binary- +// relative first) never sees the update. Absent that: elevated/admin/SYSTEM +// context → machine-wide (Windows only), else per-user. This is what makes +// `configure` invoked from an MSI custom action put the config where the +// scheduled task can later read it. func writeConfigDir() string { + if ead := exeAdjacentConfigDir(); ead != "" { + return ead + } if isElevated() { if mcd := machineConfigDir(); mcd != "" { return mcd @@ -140,9 +183,11 @@ func WriteConfigFilePath() string { } // LegacyDirName is the basename of the per-user agent directory under -// $HOME. config.json always lives here so the agent can bootstrap; -// other files (logs, hook errors, the binary) may be relocated via the -// resolved install dir — see internal/paths. +// $HOME. It is the config.json FALLBACK: the primary copy travels with the +// install dir (see exeAdjacentConfigDir), and loaders with a custom install +// dir keep a compatibility copy here refreshed on every tick for binaries +// that predate the binary-relative lookup. Other files (logs, hook errors, +// the binary) relocate via the resolved install dir — see internal/paths. const LegacyDirName = ".stepsecurity" // LegacyDir returns the per-user agent directory (~/.stepsecurity), used diff --git a/internal/config/config_path_test.go b/internal/config/config_path_test.go new file mode 100644 index 00000000..425ed2fa --- /dev/null +++ b/internal/config/config_path_test.go @@ -0,0 +1,131 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +// setExecutablePath points the resolution seam at a fake binary path and +// restores the real os.Executable on cleanup. +func setExecutablePath(t *testing.T, exe string) { + t.Helper() + orig := executablePath + executablePath = func() (string, error) { return exe, nil } + t.Cleanup(func() { executablePath = orig }) +} + +// stageInstallTree builds /bin/ with config.json at — +// the loader layout — and returns the root and the fake binary path. +func stageInstallTree(t *testing.T) (root, exe string) { + t.Helper() + root = t.TempDir() + binDir := filepath.Join(root, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "config.json"), []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + return root, filepath.Join(binDir, "stepsecurity-dev-machine-guard") +} + +func TestReadConfigDir_PrefersInstallTreeParentOfBin(t *testing.T) { + root, exe := stageInstallTree(t) + setExecutablePath(t, exe) + + got := readConfigDir() + // t.TempDir on macOS hands out /var/... which is a symlink to + // /private/var; EvalSymlinks in the resolver canonicalises, so compare + // canonical forms. + want, _ := filepath.EvalSymlinks(root) + if want == "" { + want = root + } + if got != want && got != root { + t.Errorf("readConfigDir() = %q, want install root %q", got, root) + } +} + +func TestReadConfigDir_PrefersConfigBesideBinary(t *testing.T) { + dir := t.TempDir() + exe := filepath.Join(dir, "stepsecurity-dev-machine-guard") + if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + setExecutablePath(t, exe) + + got := readConfigDir() + want, _ := filepath.EvalSymlinks(dir) + if want == "" { + want = dir + } + if got != want && got != dir { + t.Errorf("readConfigDir() = %q, want binary dir %q", got, dir) + } +} + +func TestReadConfigDir_FallsBackToUserDirWithoutInstallTreeConfig(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + // Fake binary in a tree with NO config.json anywhere near it. + setExecutablePath(t, filepath.Join(t.TempDir(), "bin", "stepsecurity-dev-machine-guard")) + + // Mirror the documented chain: a machine-wide config (Windows hosts + // with a real ProgramData install) may legitimately win before the + // legacy per-user fallback. + want := filepath.Join(home, ".stepsecurity") + if mcd := machineConfigDir(); mcd != "" { + if _, err := os.Stat(filepath.Join(mcd, "config.json")); err == nil { + want = mcd + } + } + if got := readConfigDir(); got != want { + t.Errorf("readConfigDir() = %q, want %q", got, want) + } +} + +func TestWriteConfigDir_FollowsInstallTreeConfig(t *testing.T) { + root, exe := stageInstallTree(t) + setExecutablePath(t, exe) + + got := writeConfigDir() + want, _ := filepath.EvalSymlinks(root) + if want == "" { + want = root + } + if got != want && got != root { + t.Errorf("writeConfigDir() = %q, want install root %q (write where we read)", got, root) + } +} + +func TestWriteConfigDir_FallsBackWithoutInstallTreeConfig(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + setExecutablePath(t, filepath.Join(t.TempDir(), "bin", "stepsecurity-dev-machine-guard")) + + // Mirror the documented chain: elevated runs (Windows CI runners run + // as admin) write machine-wide; everything else writes the legacy + // per-user dir. + want := filepath.Join(home, ".stepsecurity") + if isElevated() { + if mcd := machineConfigDir(); mcd != "" { + want = mcd + } + } + if got := writeConfigDir(); got != want { + t.Errorf("writeConfigDir() = %q, want %q", got, want) + } +} + +func TestExeAdjacentConfigDir_ExecutableErrorIsEmpty(t *testing.T) { + orig := executablePath + executablePath = func() (string, error) { return "", os.ErrNotExist } + t.Cleanup(func() { executablePath = orig }) + + if got := exeAdjacentConfigDir(); got != "" { + t.Errorf("exeAdjacentConfigDir() = %q, want empty on executable error", got) + } +} diff --git a/internal/paths/paths.go b/internal/paths/paths.go index b2a77be5..2381a7d4 100644 --- a/internal/paths/paths.go +++ b/internal/paths/paths.go @@ -18,10 +18,11 @@ // the operator to re-run `install`. The env var stays as a defensive // fallback for the rare case where config.json is unreadable. // -// config.json itself stays at the legacy location regardless — see -// internal/config.LegacyDir — so the agent can always bootstrap. All -// other files (logs, hook errors, the binary placed by the loader) live -// under Home(). +// config.json itself is resolved separately (binary-relative install +// dir first, legacy ~/.stepsecurity as the fallback — see +// internal/config's readConfigDir); this package governs every other +// file (logs, hook errors, the binary placed by the loader), which +// live under Home(). package paths import ( From bcfd33273e79b5fe802c7deb8d9ca3211f86897d Mon Sep 17 00:00:00 2001 From: Shubham Malik Date: Wed, 26 Aug 2026 03:43:28 +0530 Subject: [PATCH 4/9] fix(executor): native user-PATH lookups and TTY-less children to prevent compinit hangs --- CHANGELOG.md | 4 ++ internal/executor/executor_unix.go | 23 +++++++--- internal/executor/mock.go | 25 ++++++++++- internal/executor/user_aware.go | 44 +++++++++++++++++++ internal/executor/user_aware_test.go | 65 +++++++++++++++++++++++++++- 5 files changed, 153 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e164c88..32584f85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ See [VERSIONING.md](VERSIONING.md) for why the version starts at 1.8.1. - **config.json travels with the install dir**: the agent now resolves config.json binary-relative first — next to the binary, then one directory up (the loader layout is `/bin/` with `/config.json`) — before the legacy `~/.stepsecurity` fallback, and `configure`-style writes follow the file that was read. Combined with the loader change that writes config.json into the install directory (keeping a legacy compatibility copy refreshed for older binaries), a custom Install Directory now holds the binary, logs, state, and configuration in one tree instead of leaving config.json behind in the user home. Default installs are byte-identical (the binary already lives under `~/.stepsecurity/bin`). - **Sleep-spanning runs are detected and reported**: when the machine sleeps mid-scan, wall-clock and monotonic elapsed time diverge (the monotonic clock halts during sleep on macOS/Linux); the agent now reports that divergence as `slept_ms` per completed phase and per run in run-status heartbeats and the final telemetry payload, and logs "system slept ~Xm" warnings plus a run-summary note. Report-only — phase durations stay monotonic (actual work time) and the scan is never aborted. Divergence under 60 seconds is ignored to absorb NTP clock steps. +### Fixed + +- **Tool lookups no longer hang on interactive zsh prompts**: the per-tool `which` login shells (AI CLI detection and friends) could block forever when a user's rc files hit an interactive read — seen in the field as a scan stuck on `which claude` because zsh compinit's "insecure directories … continue [y] or abort [n]?" prompt read the terminal the agent inherited. Two-layer fix: every Unix child now runs in its own session with no controlling terminal (`Setsid`), so TTY prompts can't block; and user-PATH tool resolution is native Go — the login shell is spawned once per run to capture `$PATH`, then every lookup is a stat against those directories instead of its own rc-sourcing shell. + ## [1.16.0] - 2026-08-20 ### Added diff --git a/internal/executor/executor_unix.go b/internal/executor/executor_unix.go index 90776458..55175407 100644 --- a/internal/executor/executor_unix.go +++ b/internal/executor/executor_unix.go @@ -23,15 +23,28 @@ import ( // forever — the deadline is effectively ignored. Seen in production as // node_scan hangs averaging 3.6 min per project under a 30s per-call ceiling. // -// Setpgid: true makes cmd its own process group leader, so kill(-pid, SIGKILL) -// reaches the whole subtree. cmd.Cancel runs on ctx cancel/deadline. -// WaitDelay bounds the pipe-copy wait independently of the kill — if a child -// somehow survives the group kill (e.g. PID reused), Wait still returns. +// Setsid: true makes cmd a session leader of a NEW session with NO controlling +// terminal, which covers two failure classes at once: +// - it is its own process group leader, so kill(-pid, SIGKILL) reaches the +// whole subtree (the original reason this hook exists); +// - children can never read the agent's controlling TTY. Login shells that +// source user rc files can hit interactive prompts that read /dev/tty +// directly — seen in the field: zsh compinit's "insecure directories, +// run compaudit ... continue [y] or abort [n]?" blocked a `which claude` +// probe forever when send-telemetry was run from a terminal, because the +// child inherited the terminal's TTY. With no controlling terminal the +// /dev/tty open fails and zsh takes the non-interactive default instead +// of waiting. (Under launchd there is no TTY, which is why the hang only +// reproduced on manual runs.) +// +// cmd.Cancel runs on ctx cancel/deadline. WaitDelay bounds the pipe-copy wait +// independently of the kill — if a child somehow survives the group kill +// (e.g. PID reused), Wait still returns. func setupKillgroupOnCancel(cmd *exec.Cmd) { if cmd.SysProcAttr == nil { cmd.SysProcAttr = &syscall.SysProcAttr{} } - cmd.SysProcAttr.Setpgid = true + cmd.SysProcAttr.Setsid = true cmd.Cancel = func() error { if cmd.Process == nil { return nil diff --git a/internal/executor/mock.go b/internal/executor/mock.go index 42c4993f..82eb92a7 100644 --- a/internal/executor/mock.go +++ b/internal/executor/mock.go @@ -123,6 +123,21 @@ func (m *Mock) SetFileInfo(path string, info os.FileInfo) { m.fileInfos[path] = info } +// SetExecutable registers `path` as an existing executable file (mode 0755) +// so Stat-based lookups (UserAwareExecutor.LookPath's native PATH walk) can +// resolve it. +func (m *Mock) SetExecutable(path string) { + m.mu.Lock() + defer m.mu.Unlock() + m.fileInfos[path] = &mockFileInfo{ + name: filepath.Base(path), + mode: 0o755, + } + if _, ok := m.files[path]; !ok { + m.files[path] = []byte{} + } +} + // SetFileMtime registers a stat result for `path` with a custom mtime // (Unix seconds) AND marks the file as existing (FileExists returns true). // Useful for cache-invalidation tests that need to assert behavior across @@ -407,14 +422,20 @@ type mockFileInfo struct { size int64 dir bool modTime time.Time + mode os.FileMode // 0 = default 0o644 (back-compat for tests that never set it) } func (fi *mockFileInfo) Name() string { return fi.name } func (fi *mockFileInfo) Size() int64 { return fi.size } func (fi *mockFileInfo) IsDir() bool { return fi.dir } func (fi *mockFileInfo) ModTime() time.Time { return fi.modTime } -func (fi *mockFileInfo) Mode() os.FileMode { return 0o644 } -func (fi *mockFileInfo) Sys() any { return nil } +func (fi *mockFileInfo) Mode() os.FileMode { + if fi.mode != 0 { + return fi.mode + } + return 0o644 +} +func (fi *mockFileInfo) Sys() any { return nil } // MockDirEntry creates an os.DirEntry for use with SetDirEntries. func MockDirEntry(name string, isDir bool) os.DirEntry { diff --git a/internal/executor/user_aware.go b/internal/executor/user_aware.go index 73e9b509..c9f48d6d 100644 --- a/internal/executor/user_aware.go +++ b/internal/executor/user_aware.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/user" + "path/filepath" "strings" "sync" "time" @@ -26,6 +27,14 @@ type UserAwareExecutor struct { envOnce sync.Once env map[string]string envErr error + + // pathOnce/userPath cache the user's login-shell $PATH so LookPath can + // resolve binaries natively: ONE rc-sourcing shell spawn per process + // instead of one `which` login shell per tool. Guarded by pathOnce; + // empty after the fetch means the fetch failed and LookPath falls back + // to the per-call `which` probe. + pathOnce sync.Once + userPath string } var userEnvironmentKeys = []string{ @@ -129,11 +138,46 @@ func (e *UserAwareExecutor) RunAsUser(ctx context.Context, username, command str return e.inner.RunAsUser(ctx, username, command) } +// loginShellPATH returns the user's login-shell $PATH, fetched once per +// process. The single spawn keeps the user-env fidelity (nvm/fnm/homebrew +// prepends live in rc files) while every subsequent lookup is native. +func (e *UserAwareExecutor) loginShellPATH() string { + e.pathOnce.Do(func() { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + out, err := e.inner.RunAsUser(ctx, e.username, `printf '%s' "$PATH"`) + if err == nil { + e.userPath = strings.TrimSpace(out) + } + }) + return e.userPath +} + +// LookPath resolves name against the user's login-shell $PATH natively +// (stat + executable bit), instead of spawning a `which` login shell per +// tool. Each of those shells re-sourced the user's rc files, where zsh +// compinit can block on its interactive "insecure directories" prompt — +// the field incident behind this design (see setupKillgroupOnCancel). +// Aliases and shell functions that `which` used to report are deliberately +// not matched: the agent needs a real executable it can stat and run. +// Falls back to the legacy `which` probe when the PATH fetch itself failed. func (e *UserAwareExecutor) LookPath(name string) (string, error) { return e.lookPath(context.Background(), name) } func (e *UserAwareExecutor) lookPath(ctx context.Context, name string) (string, error) { + if p := e.loginShellPATH(); p != "" { + for _, dir := range strings.Split(p, ":") { + if dir == "" { + continue + } + cand := filepath.Join(dir, name) + if fi, err := e.inner.Stat(cand); err == nil && !fi.IsDir() && fi.Mode()&0o111 != 0 { + return cand, nil + } + } + return "", fmt.Errorf("%s not found in user PATH", name) + } ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() stdout, err := e.inner.RunAsUser(ctx, e.username, "which "+posixShellQuote(name)) diff --git a/internal/executor/user_aware_test.go b/internal/executor/user_aware_test.go index 88517d63..9af63404 100644 --- a/internal/executor/user_aware_test.go +++ b/internal/executor/user_aware_test.go @@ -2,6 +2,7 @@ package executor import ( "context" + "fmt" "strings" "testing" "time" @@ -206,10 +207,16 @@ func TestUserAwareExecutor_LookPathHasDeadline(t *testing.T) { service.SetGOOS("linux") inner := &userContextExecutor{ Executor: service, - runAsUser: func(ctx context.Context, _, _ string) (string, error) { + runAsUser: func(ctx context.Context, _, command string) (string, error) { if _, ok := ctx.Deadline(); !ok { t.Fatal("LookPath RunAsUser context has no deadline") } + // Fail the one-time $PATH fetch so LookPath exercises the + // legacy `which` fallback — both RunAsUser paths must carry + // a deadline. + if strings.Contains(command, "$PATH") { + return "", errMockPathFetch + } return "/usr/bin/uv", nil }, } @@ -218,3 +225,59 @@ func TestUserAwareExecutor_LookPathHasDeadline(t *testing.T) { t.Fatal(err) } } + +// TestUserAwareExecutor_LookPathNativePATH pins the native resolution design: +// ONE login-shell $PATH fetch per process, then stat-based candidate walks — +// no per-tool `which` login shell. Each of those shells re-sourced the user's +// rc files, where zsh compinit's interactive "insecure directories" prompt +// hung a customer's ai_tools_scan until they chmod'ed the offending dirs. +func TestUserAwareExecutor_LookPathNativePATH(t *testing.T) { + mock := NewMock() + mock.SetCommand("/fake/bin:/other/bin", "", 0, "bash", "-c", `printf '%s' "$PATH"`) + mock.SetExecutable("/other/bin/claude") + e := NewUserAwareExecutor(mock, "someuser") + + got, err := e.LookPath("claude") + if err != nil { + t.Fatalf("LookPath(claude) error: %v", err) + } + if got != "/other/bin/claude" { + t.Errorf("LookPath(claude) = %q, want /other/bin/claude", got) + } + + if _, err := e.LookPath("missing-tool"); err == nil { + t.Error("LookPath(missing-tool) = nil error, want not-found") + } +} + +// A file that exists on PATH but without the executable bit must not resolve — +// matching exec.LookPath semantics rather than `which`'s looser matching. +func TestUserAwareExecutor_LookPathSkipsNonExecutable(t *testing.T) { + mock := NewMock() + mock.SetCommand("/fake/bin", "", 0, "bash", "-c", `printf '%s' "$PATH"`) + mock.SetFileMtime("/fake/bin/readme", 100) // exists, default mode 0644 + e := NewUserAwareExecutor(mock, "someuser") + + if _, err := e.LookPath("readme"); err == nil { + t.Error("LookPath(readme) resolved a non-executable file") + } +} + +// When the one-time $PATH fetch fails, LookPath degrades to the legacy +// per-tool `which` probe instead of reporting every tool missing. +func TestUserAwareExecutor_LookPathFallsBackToWhich(t *testing.T) { + mock := NewMock() + mock.SetCommandError(errMockPathFetch, "bash", "-c", `printf '%s' "$PATH"`) + mock.SetCommand("/usr/local/bin/claude\n", "", 0, "bash", "-c", "which 'claude'") + e := NewUserAwareExecutor(mock, "someuser") + + got, err := e.LookPath("claude") + if err != nil { + t.Fatalf("LookPath fallback error: %v", err) + } + if got != "/usr/local/bin/claude" { + t.Errorf("LookPath fallback = %q, want /usr/local/bin/claude", got) + } +} + +var errMockPathFetch = fmt.Errorf("mock: PATH fetch failed") From 97b119c949ee7db1faed39c53410fff0d8c39236 Mon Sep 17 00:00:00 2001 From: Shubham Malik Date: Wed, 26 Aug 2026 03:57:13 +0530 Subject: [PATCH 5/9] feat(developer-mdm): self-updating binary with native SSHSIG release verification --- CHANGELOG.md | 1 + cmd/stepsecurity-dev-machine-guard/main.go | 7 + internal/config/config.go | 28 +++ internal/selfupdate/selfupdate.go | 261 +++++++++++++++++++++ internal/selfupdate/selfupdate_test.go | 176 ++++++++++++++ internal/selfupdate/sshsig.go | 169 +++++++++++++ internal/selfupdate/sshsig_test.go | 93 ++++++++ 7 files changed, 735 insertions(+) create mode 100644 internal/selfupdate/selfupdate.go create mode 100644 internal/selfupdate/selfupdate_test.go create mode 100644 internal/selfupdate/sshsig.go create mode 100644 internal/selfupdate/sshsig_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 32584f85..e79123cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ See [VERSIONING.md](VERSIONING.md) for why the version starts at 1.8.1. ### Added - **Telemetry scans run at OS background priority**: at the start of every enterprise scan the agent drops itself — and, on macOS and Linux, every child process it spawns — into the platform's background CPU/IO band (macOS `PRIO_DARWIN_BG`, the Time Machine/Spotlight tier; Linux `nice 19` plus best-effort ionice priority 7; Windows below-normal priority class plus background IO/memory mode), so disk-heavy phases no longer compete with the interactive user. Every mechanism is throttled-but-guaranteed-progress, never an idle-only class that could starve under sustained load, and the existing per-phase budgets and scan deadline bound the worst case. Interactive community `scan` runs are unaffected. Per-device escape hatch: `STEPSEC_DISABLE_BACKGROUND_PRIORITY=1`. +- **Self-updating binary for auto-loader installs**: scheduler-fired runs (launchd/systemd now launch the binary directly instead of the loader script) keep themselves current: on every tick the binary asks the latest-binary endpoint for the release its tenant should run, verifies the checksum's Ed25519 SSHSIG natively (stdlib crypto, same pinned release key and `stepsecurity-mdm-checksum` namespace the loaders verify with ssh-keygen), downloads the asset, verifies its sha256, and atomically swaps its own executable — the new version takes effect on the next scheduled fire. Strictly opt-in via the `auto_update` config key the auto-loader writes at install; version-pinned installs and manual runs never set it and can never drift off their pin. Script-baked update-policy overrides persist as `update_lag_behind`/`update_cooldown_hours` config keys so the binary sends the same query params the loader did. Update failures never block a scan. Kill switch: `STEPSEC_DISABLE_SELF_UPDATE=1`. Windows keeps its task.exe + loader architecture. - **config.json travels with the install dir**: the agent now resolves config.json binary-relative first — next to the binary, then one directory up (the loader layout is `/bin/` with `/config.json`) — before the legacy `~/.stepsecurity` fallback, and `configure`-style writes follow the file that was read. Combined with the loader change that writes config.json into the install directory (keeping a legacy compatibility copy refreshed for older binaries), a custom Install Directory now holds the binary, logs, state, and configuration in one tree instead of leaving config.json behind in the user home. Default installs are byte-identical (the binary already lives under `~/.stepsecurity/bin`). - **Sleep-spanning runs are detected and reported**: when the machine sleeps mid-scan, wall-clock and monotonic elapsed time diverge (the monotonic clock halts during sleep on macOS/Linux); the agent now reports that divergence as `slept_ms` per completed phase and per run in run-status heartbeats and the final telemetry payload, and logs "system slept ~Xm" warnings plus a run-summary note. Report-only — phase durations stay monotonic (actual work time) and the scan is never aborted. Divergence under 60 seconds is ignored to absorb NTP clock steps. diff --git a/cmd/stepsecurity-dev-machine-guard/main.go b/cmd/stepsecurity-dev-machine-guard/main.go index 4073de23..57aa07d3 100644 --- a/cmd/stepsecurity-dev-machine-guard/main.go +++ b/cmd/stepsecurity-dev-machine-guard/main.go @@ -32,6 +32,7 @@ import ( "github.com/step-security/dev-machine-guard/internal/rungate" "github.com/step-security/dev-machine-guard/internal/scan" "github.com/step-security/dev-machine-guard/internal/schtasks" + "github.com/step-security/dev-machine-guard/internal/selfupdate" "github.com/step-security/dev-machine-guard/internal/systemd" "github.com/step-security/dev-machine-guard/internal/tcc" "github.com/step-security/dev-machine-guard/internal/telemetry" @@ -263,6 +264,12 @@ func main() { log.Error("Enterprise configuration not found. Run '%s configure' or download the script from your StepSecurity dashboard.", os.Args[0]) os.Exit(1) } + // Self-update BEFORE the run gate so a gated-skip tick still keeps + // the binary current, exactly like the loader-periodic flow updated + // on every tick regardless of whether a scan ran. No-op unless the + // install opted in (config auto_update, written by the auto-loader); + // a swapped binary takes effect on the NEXT scheduled fire. + selfupdate.Run(context.Background(), exec, log) // Server-driven run gate: exit 0 quietly when the backend says this // invocation isn't due (or another instance is mid-scan). Sits before // the watchdog and telemetry.Run so a skipped wakeup posts no beacon, diff --git a/internal/config/config.go b/internal/config/config.go index 53975e92..b5e4ebe2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -65,6 +65,22 @@ var ( // telemetry.ExecutionDeadline. var MaxExecutionDuration string +// AutoUpdate opts the binary into self-updating on scheduler-fired runs +// (see internal/selfupdate). Written as `auto_update: true` by the +// auto-loader install flow when it registers the scheduler to launch the +// binary directly; version-pinned installs and manual runs never set it, so +// they can never drift off their pin. Default false. +var AutoUpdate bool + +// UpdateLagBehind / UpdateCooldownHours carry a script-baked update-policy +// override into the binary-periodic flow (the loader persists them at +// install; the loader-periodic flow sends them itself as query params). +// 0 means no override — the tenant-wide policy applies server-side. +var ( + UpdateLagBehind int + UpdateCooldownHours int +) + // ConfigFile is the JSON structure persisted to ~/.stepsecurity/config.json. type ConfigFile struct { CustomerID string `json:"customer_id,omitempty"` @@ -85,6 +101,9 @@ type ConfigFile struct { UseLegacyPackageScan *bool `json:"use_legacy_package_scan,omitempty"` UseLegacyNodeScan *bool `json:"use_legacy_node_scan,omitempty"` UseLegacyPythonScan *bool `json:"use_legacy_python_scan,omitempty"` + AutoUpdate *bool `json:"auto_update,omitempty"` + UpdateLagBehind int `json:"update_lag_behind,omitempty"` + UpdateCooldownHours int `json:"update_cooldown_hours,omitempty"` } // userConfigDir returns ~/.stepsecurity — the per-user config location. @@ -271,6 +290,15 @@ func Load() { if cfg.UseLegacyPythonScan != nil { UseLegacyPythonScan = *cfg.UseLegacyPythonScan } + if cfg.AutoUpdate != nil { + AutoUpdate = *cfg.AutoUpdate + } + if cfg.UpdateLagBehind > 0 && UpdateLagBehind == 0 { + UpdateLagBehind = cfg.UpdateLagBehind + } + if cfg.UpdateCooldownHours > 0 && UpdateCooldownHours == 0 { + UpdateCooldownHours = cfg.UpdateCooldownHours + } } // IsEnterpriseMode returns true if valid enterprise credentials are configured. diff --git a/internal/selfupdate/selfupdate.go b/internal/selfupdate/selfupdate.go new file mode 100644 index 00000000..10675ea9 --- /dev/null +++ b/internal/selfupdate/selfupdate.go @@ -0,0 +1,261 @@ +// Package selfupdate keeps a scheduler-launched binary current without the +// loader script: it asks the backend's latest-binary endpoint for the release +// the tenant should run, verifies the checksum's Ed25519 SSHSIG natively, +// downloads the asset, verifies its sha256, and atomically swaps its own +// executable. The running process keeps executing the old image; the NEW +// binary takes effect on the next scheduled fire (deliberate: no re-exec +// edge cases). +// +// Enabled only when config.AutoUpdate is true — the auto-loader install flow +// writes `auto_update: true` into config.json when it registers the scheduler +// to launch the binary directly. Version-pinned installs and manual runs +// never set it, so they can never drift off their pin. Best-effort by +// contract: every failure logs and returns; a scan is never blocked by an +// update problem. Kill switch: STEPSEC_DISABLE_SELF_UPDATE=1. +package selfupdate + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "runtime" + "time" + + "github.com/step-security/dev-machine-guard/internal/buildinfo" + "github.com/step-security/dev-machine-guard/internal/config" + "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/paths" + "github.com/step-security/dev-machine-guard/internal/progress" +) + +// EnvDisable is the per-device kill switch, mirroring the other STEPSEC_ +// escapes (run gate, background priority). +const EnvDisable = "STEPSEC_DISABLE_SELF_UPDATE" + +const ( + metaTimeout = 30 * time.Second + downloadTimeout = 5 * time.Minute + maxMetaBytes = 64 << 10 + binaryName = "stepsecurity-dev-machine-guard" +) + +// releaseBaseURL / executablePath / allowedReleaseKeyB64 are vars so tests +// can point downloads at an httptest server, swap a scratch file in for the +// real executable, and verify against a throwaway signing key. +var ( + releaseBaseURL = "https://github.com/step-security/dev-machine-guard/releases/download" + executablePath = os.Executable + allowedReleaseKeyB64 = releasePublicKeyB64 +) + +type latestBinaryResponse struct { + Version string `json:"version"` + Checksum string `json:"checksum"` + SignedChecksum string `json:"signed_checksum"` +} + +// assetName returns the release asset for this platform, matching the +// loaders' naming: darwin ships a single universal binary, linux is +// per-arch. Windows is never self-updated (its task.exe launcher + loader +// architecture owns updates there); callers gate on GOOS first. +func assetName(version string) string { + if runtime.GOOS == model.PlatformDarwin { + return fmt.Sprintf("%s-%s-darwin", binaryName, version) + } + return fmt.Sprintf("%s-%s-linux_%s", binaryName, version, runtime.GOARCH) +} + +// Run performs one self-update check. Returns true only when a new binary +// was installed (taking effect next run). Never returns an error: all +// failures are logged and swallowed so the scan proceeds regardless. +func Run(ctx context.Context, exec executor.Executor, log *progress.Logger) bool { + if !config.AutoUpdate { + return false + } + if runtime.GOOS == model.PlatformWindows { + return false + } + if exec.Getenv(EnvDisable) == "1" { + log.Debug("self-update: disabled via %s", EnvDisable) + return false + } + + exe, err := executablePath() + if err != nil { + log.Warn("self-update: cannot resolve own executable: %v", err) + return false + } + if resolved, err := filepath.EvalSymlinks(exe); err == nil && resolved != "" { + exe = resolved + } + + meta, err := fetchLatestBinary(ctx) + if err != nil { + log.Warn("self-update: check failed (%v) — continuing on v%s", err, buildinfo.Version) + return false + } + + // The signature covers the checksum string exactly as the release + // pipeline signed it (no trailing newline; the loaders verify the same + // bytes). A bad or missing signature aborts BEFORE any download. + if err := verifySSHSig(meta.SignedChecksum, []byte(meta.Checksum), allowedReleaseKeyB64, signatureNamespace); err != nil { + log.Warn("self-update: checksum signature verification failed for v%s: %v", meta.Version, err) + return false + } + + current, err := fileSHA256(exe) + if err != nil { + log.Warn("self-update: cannot hash current binary: %v", err) + return false + } + if current == meta.Checksum { + log.Debug("self-update: binary is current (v%s)", meta.Version) + return false + } + + log.Progress("Self-update: v%s available (checksum differs from installed binary), downloading...", meta.Version) + tmp, err := downloadAsset(ctx, meta.Version, exe) + if err != nil { + log.Warn("self-update: download failed: %v", err) + return false + } + defer os.Remove(tmp) // no-op after the successful rename + + got, err := fileSHA256(tmp) + if err != nil || got != meta.Checksum { + log.Warn("self-update: downloaded binary checksum mismatch (got %.12s, want %.12s) — discarding", got, meta.Checksum) + return false + } + if err := os.Chmod(tmp, 0o755); err != nil { + log.Warn("self-update: chmod failed: %v", err) + return false + } + // Atomic same-directory rename: the running process keeps its (now + // unlinked) old image; the next scheduled fire executes the new one. + if err := os.Rename(tmp, exe); err != nil { + log.Warn("self-update: install failed: %v", err) + return false + } + writeVersionMarker(meta.Version) + log.Progress("Self-update: installed v%s (replacing v%s); it takes effect on the next scheduled run", meta.Version, buildinfo.Version) + return true +} + +func fetchLatestBinary(ctx context.Context) (*latestBinaryResponse, error) { + q := url.Values{} + if runtime.GOOS == model.PlatformLinux { + q.Set("os", "linux") + q.Set("arch", runtime.GOARCH) + } + // Script-baked update-policy overrides ride config.json in the + // binary-periodic flow (the loader persists them at install); send them + // exactly like the loader's policy_query_string so the backend resolves + // the same version either way. + if config.UpdateLagBehind > 0 || config.UpdateCooldownHours > 0 { + q.Set("lag_behind", fmt.Sprintf("%d", config.UpdateLagBehind)) + q.Set("cooldown_hours", fmt.Sprintf("%d", config.UpdateCooldownHours)) + } + endpoint := fmt.Sprintf("%s/v1/%s/developer-mdm-agent/latest-binary", config.APIEndpoint, config.CustomerID) + if enc := q.Encode(); enc != "" { + endpoint += "?" + enc + } + + ctx, cancel := context.WithTimeout(ctx, metaTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+config.APIKey) + req.Header.Set("X-Agent-Version", buildinfo.Version) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("latest-binary returned HTTP %d", resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxMetaBytes)) + if err != nil { + return nil, err + } + var meta latestBinaryResponse + if err := json.Unmarshal(body, &meta); err != nil { + return nil, fmt.Errorf("parse latest-binary response: %w", err) + } + if meta.Version == "" || meta.Checksum == "" || meta.SignedChecksum == "" { + return nil, fmt.Errorf("latest-binary response missing version/checksum/signed_checksum") + } + return &meta, nil +} + +// downloadAsset streams the release asset to a temp file in the same +// directory as the target executable (same filesystem, so the final rename +// is atomic). Returns the temp path. +func downloadAsset(ctx context.Context, version, exe string) (string, error) { + assetURL := fmt.Sprintf("%s/v%s/%s", releaseBaseURL, version, assetName(version)) + + ctx, cancel := context.WithTimeout(ctx, downloadTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, assetURL, nil) + if err != nil { + return "", err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("download %s returned HTTP %d", assetURL, resp.StatusCode) + } + + f, err := os.CreateTemp(filepath.Dir(exe), "."+binaryName+".new-*") + if err != nil { + return "", err + } + if _, err := io.Copy(f, resp.Body); err != nil { + f.Close() + os.Remove(f.Name()) + return "", err + } + if err := f.Close(); err != nil { + os.Remove(f.Name()) + return "", err + } + return f.Name(), nil +} + +func fileSHA256(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +// writeVersionMarker refreshes the loader-compatible .current_version file in +// the install dir. Best-effort: the marker is diagnostic (scheduler_info and +// the loaders read it), never load-bearing for the update itself. +func writeVersionMarker(version string) { + home := paths.Home() + if home == "" { + return + } + _ = os.WriteFile(filepath.Join(home, ".current_version"), []byte(version+"\n"), 0o644) +} diff --git a/internal/selfupdate/selfupdate_test.go b/internal/selfupdate/selfupdate_test.go new file mode 100644 index 00000000..a2174d7f --- /dev/null +++ b/internal/selfupdate/selfupdate_test.go @@ -0,0 +1,176 @@ +package selfupdate + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "sync/atomic" + "testing" + + "github.com/step-security/dev-machine-guard/internal/config" + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/progress" +) + +// stageSeams wires every package seam at a fake install: a scratch "current +// binary", an httptest server serving both the latest-binary metadata and the +// release asset, the throwaway fixture signing key, and enterprise config. +// Returns the scratch exe path and a download-hit counter. +func stageSeams(t *testing.T, metaJSON, assetBody string) (string, *atomic.Int32) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("self-update is darwin/linux only (windows uses the task.exe + loader architecture)") + } + + dir := t.TempDir() + exe := filepath.Join(dir, binaryName) + if err := os.WriteFile(exe, []byte("old-binary-content\n"), 0o755); err != nil { + t.Fatal(err) + } + + var downloads atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/v1/testcust/developer-mdm-agent/latest-binary", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(metaJSON)) + }) + mux.HandleFunc("/v9.9.9/"+assetName("9.9.9"), func(w http.ResponseWriter, _ *http.Request) { + downloads.Add(1) + _, _ = w.Write([]byte(assetBody)) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + origBase, origExe, origKey := releaseBaseURL, executablePath, allowedReleaseKeyB64 + origEndpoint, origKeyCfg, origCust := config.APIEndpoint, config.APIKey, config.CustomerID + origAuto := config.AutoUpdate + releaseBaseURL = srv.URL + executablePath = func() (string, error) { return exe, nil } + allowedReleaseKeyB64 = fixtureKeyB64 + config.APIEndpoint = srv.URL + config.APIKey = "test-key" + config.CustomerID = "testcust" + config.AutoUpdate = true + t.Setenv("STEPSECURITY_HOME", dir) // version marker lands in the temp dir + t.Cleanup(func() { + releaseBaseURL, executablePath, allowedReleaseKeyB64 = origBase, origExe, origKey + config.APIEndpoint, config.APIKey, config.CustomerID = origEndpoint, origKeyCfg, origCust + config.AutoUpdate = origAuto + }) + return exe, &downloads +} + +func validMeta() string { + return `{"version":"9.9.9","checksum":"` + fixturePayloadChecksum + `","signed_checksum":` + jsonString(fixturePayloadSig) + `}` +} + +// jsonString encodes s as a JSON string literal (the signature is multi-line). +func jsonString(s string) string { + out := `"` + for _, r := range s { + switch r { + case '\n': + out += `\n` + case '"': + out += `\"` + case '\\': + out += `\\` + default: + out += string(r) + } + } + return out + `"` +} + +func TestRun_InstallsVerifiedUpdate(t *testing.T) { + exe, downloads := stageSeams(t, validMeta(), fixturePayload) + + updated := Run(context.Background(), executor.NewMock(), progress.NewLogger(progress.LevelInfo)) + if !updated { + t.Fatal("Run() = false, want an installed update") + } + got, err := os.ReadFile(exe) + if err != nil || string(got) != fixturePayload { + t.Errorf("binary content = %q err=%v, want the downloaded payload", got, err) + } + fi, _ := os.Stat(exe) + if fi.Mode()&0o111 == 0 { + t.Error("installed binary is not executable") + } + if downloads.Load() != 1 { + t.Errorf("downloads = %d, want 1", downloads.Load()) + } + marker, err := os.ReadFile(filepath.Join(filepath.Dir(exe), ".current_version")) + if err != nil || string(marker) != "9.9.9\n" { + t.Errorf("version marker = %q err=%v, want 9.9.9", marker, err) + } +} + +func TestRun_ChecksumMismatchDiscardsDownload(t *testing.T) { + exe, _ := stageSeams(t, validMeta(), "tampered-payload-not-matching-checksum\n") + + if Run(context.Background(), executor.NewMock(), progress.NewLogger(progress.LevelInfo)) { + t.Fatal("Run() = true despite checksum mismatch") + } + got, _ := os.ReadFile(exe) + if string(got) != "old-binary-content\n" { + t.Errorf("binary was replaced by a checksum-mismatched download: %q", got) + } + leftovers, _ := filepath.Glob(filepath.Join(filepath.Dir(exe), "."+binaryName+".new-*")) + if len(leftovers) != 0 { + t.Errorf("temp download not cleaned up: %v", leftovers) + } +} + +func TestRun_BadSignatureAbortsBeforeDownload(t *testing.T) { + // Signature is valid SSHSIG but over a DIFFERENT message than the + // advertised checksum — verification must fail and nothing downloads. + meta := `{"version":"9.9.9","checksum":"` + fixturePayloadChecksum + `","signed_checksum":` + jsonString(fixtureSig) + `}` + exe, downloads := stageSeams(t, meta, fixturePayload) + + if Run(context.Background(), executor.NewMock(), progress.NewLogger(progress.LevelInfo)) { + t.Fatal("Run() = true despite bad checksum signature") + } + if downloads.Load() != 0 { + t.Errorf("downloads = %d, want 0 (signature must gate the download)", downloads.Load()) + } + got, _ := os.ReadFile(exe) + if string(got) != "old-binary-content\n" { + t.Error("binary was replaced despite bad signature") + } +} + +func TestRun_UpToDateIsNoOp(t *testing.T) { + exe, downloads := stageSeams(t, validMeta(), fixturePayload) + // Make the "current" binary already match the advertised checksum. + if err := os.WriteFile(exe, []byte(fixturePayload), 0o755); err != nil { + t.Fatal(err) + } + if Run(context.Background(), executor.NewMock(), progress.NewLogger(progress.LevelInfo)) { + t.Fatal("Run() = true for an up-to-date binary") + } + if downloads.Load() != 0 { + t.Errorf("downloads = %d, want 0 for up-to-date", downloads.Load()) + } +} + +func TestRun_RequiresOptInAndHonorsKillSwitch(t *testing.T) { + _, downloads := stageSeams(t, validMeta(), fixturePayload) + + config.AutoUpdate = false + if Run(context.Background(), executor.NewMock(), progress.NewLogger(progress.LevelInfo)) { + t.Fatal("Run() = true without auto_update opt-in") + } + + config.AutoUpdate = true + mock := executor.NewMock() + mock.SetEnv(EnvDisable, "1") + if Run(context.Background(), mock, progress.NewLogger(progress.LevelInfo)) { + t.Fatal("Run() = true despite kill switch") + } + if downloads.Load() != 0 { + t.Errorf("downloads = %d, want 0 when disabled", downloads.Load()) + } +} diff --git a/internal/selfupdate/sshsig.go b/internal/selfupdate/sshsig.go new file mode 100644 index 00000000..b1560b24 --- /dev/null +++ b/internal/selfupdate/sshsig.go @@ -0,0 +1,169 @@ +package selfupdate + +import ( + "bytes" + "crypto/ed25519" + "crypto/sha256" + "crypto/sha512" + "encoding/base64" + "encoding/binary" + "fmt" + "io" + "strings" +) + +// Dep-free SSHSIG verification (the format `ssh-keygen -Y sign` produces; +// see OpenSSH PROTOCOL.sshsig). The loaders verify release checksums by +// shelling out to `ssh-keygen -Y verify`; the binary verifies the same +// signatures natively so the self-update path needs no external tools — +// stdlib crypto/ed25519 plus hand-rolled wire parsing (AGENTS.md: +// stdlib first). + +const ( + sigArmorBegin = "-----BEGIN SSH SIGNATURE-----" + sigArmorEnd = "-----END SSH SIGNATURE-----" + sshSigMagic = "SSHSIG" + sshSigVersion = 1 + keyTypeEd = "ssh-ed25519" + + // signatureNamespace pins `ssh-keygen -Y sign -n ` — a signature made + // by the same key for any other purpose does not validate here. Must + // match SIGNATURE_NAMESPACE in the loader scripts. + signatureNamespace = "stepsecurity-mdm-checksum" +) + +// releasePublicKeyB64 is the pinned Ed25519 release-signing key: the base64 +// SSH wire blob from the `ssh-ed25519 releases@stepsecurity.io` line +// the loader scripts embed as PUBLIC_KEY_SSH. Comparing wire blobs pins both +// the algorithm and the key in one equality check. +const releasePublicKeyB64 = "AAAAC3NzaC1lZDI1NTE5AAAAILN+WG4lOH/x6MysYOf1oY0PKXLLu9d3ZvQDcvq5Cboi" + +func readSSHString(r *bytes.Reader) ([]byte, error) { + var n uint32 + if err := binary.Read(r, binary.BigEndian, &n); err != nil { + return nil, err + } + if int64(n) > int64(r.Len()) { + return nil, fmt.Errorf("truncated ssh string (%d > %d remaining)", n, r.Len()) + } + b := make([]byte, n) + if _, err := io.ReadFull(r, b); err != nil { + return nil, err + } + return b, nil +} + +func appendSSHString(dst, s []byte) []byte { + var n [4]byte + binary.BigEndian.PutUint32(n[:], uint32(len(s))) + return append(append(dst, n[:]...), s...) +} + +// verifySSHSig checks that `armored` is a valid SSHSIG over `message`, made +// by the key whose SSH wire blob base64-encodes to allowedKeyB64, in the +// given namespace. Errors are diagnostic (safe to log; carry no secrets). +func verifySSHSig(armored string, message []byte, allowedKeyB64, namespace string) error { + beg := strings.Index(armored, sigArmorBegin) + end := strings.Index(armored, sigArmorEnd) + if beg == -1 || end == -1 || end < beg { + return fmt.Errorf("not an armored SSH signature block") + } + b64 := strings.Map(func(r rune) rune { + if r == '\n' || r == '\r' || r == ' ' || r == '\t' { + return -1 + } + return r + }, armored[beg+len(sigArmorBegin):end]) + blob, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return fmt.Errorf("decode signature body: %w", err) + } + + r := bytes.NewReader(blob) + magic := make([]byte, len(sshSigMagic)) + if _, err := io.ReadFull(r, magic); err != nil || string(magic) != sshSigMagic { + return fmt.Errorf("missing SSHSIG magic preamble") + } + var version uint32 + if err := binary.Read(r, binary.BigEndian, &version); err != nil || version != sshSigVersion { + return fmt.Errorf("unsupported SSHSIG version %d", version) + } + pubkeyBlob, err := readSSHString(r) + if err != nil { + return fmt.Errorf("read public key: %w", err) + } + ns, err := readSSHString(r) + if err != nil { + return fmt.Errorf("read namespace: %w", err) + } + reserved, err := readSSHString(r) + if err != nil { + return fmt.Errorf("read reserved: %w", err) + } + hashAlg, err := readSSHString(r) + if err != nil { + return fmt.Errorf("read hash algorithm: %w", err) + } + sigBlob, err := readSSHString(r) + if err != nil { + return fmt.Errorf("read signature: %w", err) + } + + if string(ns) != namespace { + return fmt.Errorf("signature namespace %q, want %q", ns, namespace) + } + allowed, err := base64.StdEncoding.DecodeString(allowedKeyB64) + if err != nil { + return fmt.Errorf("decode pinned key: %w", err) + } + if !bytes.Equal(pubkeyBlob, allowed) { + return fmt.Errorf("signing key is not the pinned release key") + } + + // Pubkey wire: string "ssh-ed25519" || string key(32). + pr := bytes.NewReader(pubkeyBlob) + keyType, err := readSSHString(pr) + if err != nil || string(keyType) != keyTypeEd { + return fmt.Errorf("unsupported key type %q", keyType) + } + key, err := readSSHString(pr) + if err != nil || len(key) != ed25519.PublicKeySize { + return fmt.Errorf("malformed ed25519 public key") + } + + // Signature wire: string "ssh-ed25519" || string sig(64). + sr := bytes.NewReader(sigBlob) + sigType, err := readSSHString(sr) + if err != nil || string(sigType) != keyTypeEd { + return fmt.Errorf("unsupported signature type %q", sigType) + } + sig, err := readSSHString(sr) + if err != nil || len(sig) != ed25519.SignatureSize { + return fmt.Errorf("malformed ed25519 signature") + } + + var hashed []byte + switch string(hashAlg) { + case "sha256": + h := sha256.Sum256(message) + hashed = h[:] + case "sha512": + h := sha512.Sum512(message) + hashed = h[:] + default: + return fmt.Errorf("unsupported hash algorithm %q", hashAlg) + } + + // Signed blob per PROTOCOL.sshsig: MAGIC || namespace || reserved || + // hash_algorithm || H(message), each as an ssh string except the magic. + signed := []byte(sshSigMagic) + signed = appendSSHString(signed, ns) + signed = appendSSHString(signed, reserved) + signed = appendSSHString(signed, hashAlg) + signed = appendSSHString(signed, hashed) + + if !ed25519.Verify(ed25519.PublicKey(key), signed, sig) { + return fmt.Errorf("ed25519 signature verification failed") + } + return nil +} diff --git a/internal/selfupdate/sshsig_test.go b/internal/selfupdate/sshsig_test.go new file mode 100644 index 00000000..dfd91e34 --- /dev/null +++ b/internal/selfupdate/sshsig_test.go @@ -0,0 +1,93 @@ +package selfupdate + +import ( + "strings" + "testing" +) + +// Fixtures generated with a throwaway ed25519 key: +// +// ssh-keygen -t ed25519 -f testkey -N "" -C test@fixture +// printf '%s' "" > msg && ssh-keygen -Y sign -f testkey -n stepsecurity-mdm-checksum msg +const ( + fixtureKeyB64 = "AAAAC3NzaC1lZDI1NTE5AAAAIPyrd8mRqV8sf32kNxdbqNcM7O6nnkpZBZuUkRzgV4Vi" + + fixtureMessage = "b9c566a0abef0c004c8550a4c06e7702c1ee4cedeb98a441b4362a8d38d8a215" + fixtureSig = `-----BEGIN SSH SIGNATURE----- +U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAg/Kt3yZGpXyx/faQ3F1uo1wzs7q +eeSlkFm5SRHOBXhWIAAAAZc3RlcHNlY3VyaXR5LW1kbS1jaGVja3N1bQAAAAAAAAAGc2hh +NTEyAAAAUwAAAAtzc2gtZWQyNTUxOQAAAEA54k4Rj3+JfaX1jh5KQF2RaWrtSxeQBDPCBt +1ONaiZ63fUUNmX2tH3KkkJo4Ul/v/GhTS+zCZI/tT8FWeGjG4D +-----END SSH SIGNATURE-----` + + // Payload fixtures for the update-flow tests (selfupdate_test.go). + fixturePayload = "poc-new-binary-payload\n" + fixturePayloadChecksum = "83aff4e3d909750027b906bfc78a11f22b11b82c045be1dcf8ee320f469621b4" + fixturePayloadSig = `-----BEGIN SSH SIGNATURE----- +U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAg/Kt3yZGpXyx/faQ3F1uo1wzs7q +eeSlkFm5SRHOBXhWIAAAAZc3RlcHNlY3VyaXR5LW1kbS1jaGVja3N1bQAAAAAAAAAGc2hh +NTEyAAAAUwAAAAtzc2gtZWQyNTUxOQAAAED/6oSFt0xYTqzYcwemQ4GQ8bZlHSOo4L1t0A +kmHXZoiG6RbdQlYusI4+laaYjXNr0gvXIViYBO0v3vwgdBK3wI +-----END SSH SIGNATURE-----` +) + +func TestVerifySSHSig(t *testing.T) { + tests := []struct { + name string + armored string + message string + key string + namespace string + wantErr string // substring; "" = must verify + }{ + { + name: "valid signature verifies", armored: fixtureSig, + message: fixtureMessage, key: fixtureKeyB64, namespace: signatureNamespace, + }, + { + name: "wrong namespace rejected", armored: fixtureSig, + message: fixtureMessage, key: fixtureKeyB64, namespace: "other-namespace", + wantErr: "namespace", + }, + { + name: "signature by non-pinned key rejected", armored: fixtureSig, + message: fixtureMessage, key: releasePublicKeyB64, namespace: signatureNamespace, + wantErr: "not the pinned release key", + }, + { + name: "tampered message rejected", armored: fixtureSig, + message: fixtureMessage[:len(fixtureMessage)-1] + "0", key: fixtureKeyB64, namespace: signatureNamespace, + wantErr: "verification failed", + }, + { + name: "garbage armor rejected", armored: "base64 -- Encode/decode file as base64. Call:", + message: fixtureMessage, key: fixtureKeyB64, namespace: signatureNamespace, + wantErr: "not an armored SSH signature", + }, + { + name: "truncated blob rejected", armored: fixtureSig[:120] + "\n-----END SSH SIGNATURE-----", + message: fixtureMessage, key: fixtureKeyB64, namespace: signatureNamespace, + wantErr: "", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := verifySSHSig(tc.armored, []byte(tc.message), tc.key, tc.namespace) + if tc.name == "truncated blob rejected" { + if err == nil { + t.Fatal("truncated blob verified, want any error") + } + return + } + if tc.wantErr == "" { + if err != nil { + t.Fatalf("verify error: %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("error = %v, want substring %q", err, tc.wantErr) + } + }) + } +} From 89ed4ad53f7715026e411a2aed09c1bdde6476d8 Mon Sep 17 00:00:00 2001 From: Shubham Malik Date: Wed, 26 Aug 2026 04:20:20 +0530 Subject: [PATCH 6/9] fix(developer-mdm): resolve gosec findings in the self-update path --- internal/selfupdate/selfupdate.go | 12 ++++++++---- internal/selfupdate/sshsig.go | 2 ++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/internal/selfupdate/selfupdate.go b/internal/selfupdate/selfupdate.go index 10675ea9..05c164d7 100644 --- a/internal/selfupdate/selfupdate.go +++ b/internal/selfupdate/selfupdate.go @@ -134,6 +134,8 @@ func Run(ctx context.Context, exec executor.Executor, log *progress.Logger) bool log.Warn("self-update: downloaded binary checksum mismatch (got %.12s, want %.12s) — discarding", got, meta.Checksum) return false } + // #nosec G302 -- this IS the agent executable being installed; it must + // carry the same 0755 the loaders have always set on the binary. if err := os.Chmod(tmp, 0o755); err != nil { log.Warn("self-update: chmod failed: %v", err) return false @@ -225,18 +227,20 @@ func downloadAsset(ctx context.Context, version, exe string) (string, error) { return "", err } if _, err := io.Copy(f, resp.Body); err != nil { - f.Close() - os.Remove(f.Name()) + _ = f.Close() + _ = os.Remove(f.Name()) return "", err } if err := f.Close(); err != nil { - os.Remove(f.Name()) + _ = os.Remove(f.Name()) return "", err } return f.Name(), nil } func fileSHA256(path string) (string, error) { + // #nosec G304 -- path is the agent's own resolved executable or the + // temp download it just created; never user or network input. f, err := os.Open(path) if err != nil { return "", err @@ -257,5 +261,5 @@ func writeVersionMarker(version string) { if home == "" { return } - _ = os.WriteFile(filepath.Join(home, ".current_version"), []byte(version+"\n"), 0o644) + _ = os.WriteFile(filepath.Join(home, ".current_version"), []byte(version+"\n"), 0o600) } diff --git a/internal/selfupdate/sshsig.go b/internal/selfupdate/sshsig.go index b1560b24..45503c88 100644 --- a/internal/selfupdate/sshsig.go +++ b/internal/selfupdate/sshsig.go @@ -55,6 +55,8 @@ func readSSHString(r *bytes.Reader) ([]byte, error) { func appendSSHString(dst, s []byte) []byte { var n [4]byte + // #nosec G115 -- inputs are protocol strings we compose ourselves + // (namespace, hash name, a 64-byte digest), always far below uint32. binary.BigEndian.PutUint32(n[:], uint32(len(s))) return append(append(dst, n[:]...), s...) } From 8d9ec2585944a641e99cef5a52ba99ca1dbfdff7 Mon Sep 17 00:00:00 2001 From: Shubham Malik Date: Wed, 26 Aug 2026 05:39:04 +0530 Subject: [PATCH 7/9] fix(developer-mdm): unwrap base64 transport around signed_checksum in self-update --- internal/selfupdate/selfupdate.go | 36 +++++++++++++++++++++++++- internal/selfupdate/selfupdate_test.go | 6 ++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/internal/selfupdate/selfupdate.go b/internal/selfupdate/selfupdate.go index 05c164d7..2278b3dc 100644 --- a/internal/selfupdate/selfupdate.go +++ b/internal/selfupdate/selfupdate.go @@ -17,6 +17,7 @@ package selfupdate import ( "context" "crypto/sha256" + "encoding/base64" "encoding/hex" "encoding/json" "fmt" @@ -26,6 +27,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "time" "github.com/step-security/dev-machine-guard/internal/buildinfo" @@ -106,7 +108,12 @@ func Run(ctx context.Context, exec executor.Executor, log *progress.Logger) bool // The signature covers the checksum string exactly as the release // pipeline signed it (no trailing newline; the loaders verify the same // bytes). A bad or missing signature aborts BEFORE any download. - if err := verifySSHSig(meta.SignedChecksum, []byte(meta.Checksum), allowedReleaseKeyB64, signatureNamespace); err != nil { + armored, err := decodeSignedChecksum(meta.SignedChecksum) + if err != nil { + log.Warn("self-update: signed_checksum for v%s is malformed: %v", meta.Version, err) + return false + } + if err := verifySSHSig(armored, []byte(meta.Checksum), allowedReleaseKeyB64, signatureNamespace); err != nil { log.Warn("self-update: checksum signature verification failed for v%s: %v", meta.Version, err) return false } @@ -238,6 +245,33 @@ func downloadAsset(ctx context.Context, version, exe string) (string, error) { return f.Name(), nil } +// decodeSignedChecksum recovers the multi-line armored SSHSIG block from the +// API's signed_checksum field, which is base64-wrapped so the armored block +// survives single-line JSON transport (the shell loaders undo the same layer +// with `base64 -D` before handing it to ssh-keygen). A value that already +// carries the armor header is accepted as-is, so a future backend that stops +// double-encoding keeps working. +func decodeSignedChecksum(s string) (string, error) { + if strings.Contains(s, sigArmorBegin) { + return s, nil + } + compact := strings.Map(func(r rune) rune { + if r == '\n' || r == '\r' || r == ' ' || r == '\t' { + return -1 + } + return r + }, s) + decoded, err := base64.StdEncoding.DecodeString(compact) + if err != nil { + return "", fmt.Errorf("base64-decode transport wrapper: %w", err) + } + armored := string(decoded) + if !strings.Contains(armored, sigArmorBegin) { + return "", fmt.Errorf("decoded signed_checksum is not an armored SSH signature block") + } + return armored, nil +} + func fileSHA256(path string) (string, error) { // #nosec G304 -- path is the agent's own resolved executable or the // temp download it just created; never user or network input. diff --git a/internal/selfupdate/selfupdate_test.go b/internal/selfupdate/selfupdate_test.go index a2174d7f..793a9faa 100644 --- a/internal/selfupdate/selfupdate_test.go +++ b/internal/selfupdate/selfupdate_test.go @@ -2,6 +2,7 @@ package selfupdate import ( "context" + "encoding/base64" "net/http" "net/http/httptest" "os" @@ -63,7 +64,10 @@ func stageSeams(t *testing.T, metaJSON, assetBody string) (string, *atomic.Int32 } func validMeta() string { - return `{"version":"9.9.9","checksum":"` + fixturePayloadChecksum + `","signed_checksum":` + jsonString(fixturePayloadSig) + `}` + // signed_checksum is base64-wrapped on the wire (single-line JSON + // transport of the multi-line armored block), matching the real API. + wrapped := base64.StdEncoding.EncodeToString([]byte(fixturePayloadSig)) + return `{"version":"9.9.9","checksum":"` + fixturePayloadChecksum + `","signed_checksum":"` + wrapped + `"}` } // jsonString encodes s as a JSON string literal (the signature is multi-line). From d5b40e20338818e3253cb6d585c543c070c96206 Mon Sep 17 00:00:00 2001 From: Shubham Malik Date: Thu, 3 Sep 2026 04:51:11 +0530 Subject: [PATCH 8/9] fix(developer-mdm): harden self-update floor and armor parsing, per-thread linux priority, validate exe-adjacent config --- internal/bgpriority/apply_linux.go | 59 ++++++++++++++++++++++--- internal/config/config.go | 28 +++++++++++- internal/config/config_path_test.go | 36 +++++++++++++++- internal/selfupdate/selfupdate.go | 54 +++++++++++++++++++++++ internal/selfupdate/selfupdate_test.go | 60 ++++++++++++++++++++++++++ internal/selfupdate/sshsig.go | 6 ++- 6 files changed, 232 insertions(+), 11 deletions(-) diff --git a/internal/bgpriority/apply_linux.go b/internal/bgpriority/apply_linux.go index 303b7634..a32b5263 100644 --- a/internal/bgpriority/apply_linux.go +++ b/internal/bgpriority/apply_linux.go @@ -4,6 +4,8 @@ package bgpriority import ( "fmt" + "os" + "strconv" "strings" "syscall" ) @@ -11,7 +13,7 @@ import ( // ioprio_set(2) encoding (linux/ioprio.h), spelled out because neither the // stdlib nor golang.org/x/sys/unix wraps the call itself. const ( - ioprioWhoProcess = 1 // IOPRIO_WHO_PROCESS: target a single process + ioprioWhoProcess = 1 // IOPRIO_WHO_PROCESS: with a tid, targets that thread ioprioClassShift = 13 // ioprioClassBE / ioprioBELowest: best-effort class, lowest slot. // Deliberately NOT the idle class (3): idle IO is only serviced when the @@ -22,23 +24,66 @@ const ( ioprioBELowest = 7 ) +// apply lowers CPU and IO priority for EVERY current OS thread. On Linux both +// setpriority(PRIO_PROCESS, 0, …) and ioprio_set(IOPRIO_WHO_PROCESS, 0, …) +// act on the CALLING THREAD only (nice is per-task under NPTL), and the Go +// runtime multiplexes goroutines across many threads — a single self-targeted +// call would leave most scan work, and any child forked from another thread, +// at normal priority. Walking /proc/self/task covers every live thread; +// threads and child processes created afterwards inherit from their (covered) +// creator, so the whole process stays in the background band. func apply() (string, error) { + tids, err := os.ReadDir("/proc/self/task") + if err != nil { + // Fall back to the calling thread — degraded but not useless. + return applyToThread(0) + } + + niceCovered, ioCovered := 0, 0 + for _, ent := range tids { + tid, convErr := strconv.Atoi(ent.Name()) + if convErr != nil { + continue + } + // A thread can exit between the ReadDir and the calls (ESRCH) — + // count only what actually applied. + if syscall.Setpriority(syscall.PRIO_PROCESS, tid, 19) == nil { + niceCovered++ + } + ioprio := uintptr(ioprioClassBE< 0 { + parts = append(parts, fmt.Sprintf("nice 19 on %d/%d threads", niceCovered, len(tids))) + } + if ioCovered > 0 { + parts = append(parts, fmt.Sprintf("ionice best-effort 7 on %d/%d threads", ioCovered, len(tids))) + } + // Partial success still helps; report only what actually applied. + if len(parts) == 0 { + return "", fmt.Errorf("setpriority/ioprio_set applied to no thread") + } + return strings.Join(parts, ", ") + " (inherited by new threads and child processes)", nil +} - niceErr := syscall.Setpriority(syscall.PRIO_PROCESS, 0, 19) +// applyToThread is the single-target fallback when /proc is unavailable. +func applyToThread(tid int) (string, error) { + var parts []string + niceErr := syscall.Setpriority(syscall.PRIO_PROCESS, tid, 19) if niceErr == nil { parts = append(parts, "nice 19") } - ioprio := uintptr(ioprioClassBE< '9' { + break + } + n = n*10 + int(r-'0') + } + out[i] = n + } + return out + } + va, vb := parse(a), parse(b) + for i := 0; i < 3; i++ { + if va[i] != vb[i] { + return va[i] < vb[i] + } + } + return false +} + // decodeSignedChecksum recovers the multi-line armored SSHSIG block from the // API's signed_checksum field, which is base64-wrapped so the armored block // survives single-line JSON transport (the shell loaders undo the same layer diff --git a/internal/selfupdate/selfupdate_test.go b/internal/selfupdate/selfupdate_test.go index 793a9faa..4ce35bd4 100644 --- a/internal/selfupdate/selfupdate_test.go +++ b/internal/selfupdate/selfupdate_test.go @@ -178,3 +178,63 @@ func TestRun_RequiresOptInAndHonorsKillSwitch(t *testing.T) { t.Errorf("downloads = %d, want 0 when disabled", downloads.Load()) } } + +func TestRun_RefusesDowngradeBelowSelfUpdateFloor(t *testing.T) { + // A release gate capping the tenant below minSelfUpdateVersion must not + // let a binary-periodic install downgrade itself into a binary with no + // self-update code (= no update path at all). The floor check runs + // before signature verification and before any download. + meta := `{"version":"1.16.0","checksum":"` + fixturePayloadChecksum + `","signed_checksum":"ZHVtbXk="}` + exe, downloads := stageSeams(t, meta, fixturePayload) + + if Run(context.Background(), executor.NewMock(), progress.NewLogger(progress.LevelInfo)) { + t.Fatal("Run() = true for a below-floor downgrade") + } + if downloads.Load() != 0 { + t.Errorf("downloads = %d, want 0 (floor must gate the download)", downloads.Load()) + } + got, _ := os.ReadFile(exe) + if string(got) != "old-binary-content\n" { + t.Error("binary was replaced despite the self-update floor") + } +} + +func TestVersionBelow(t *testing.T) { + cases := []struct { + a, b string + want bool + }{ + {"1.16.0", "1.17.0", true}, + {"1.17.0", "1.17.0", false}, + {"1.17.1", "1.17.0", false}, + {"1.18.0", "1.17.0", false}, + {"2.0.0", "1.17.0", false}, + {"1.9.9", "1.17.0", true}, + {"v1.16.0", "1.17.0", true}, + {"1.17.0-rc1", "1.17.0", false}, + {"1.17", "1.17.0", false}, + {"garbage", "1.17.0", true}, // unparseable = 0.0.0 = refuse (fail safe) + {"", "1.17.0", true}, + } + for _, tc := range cases { + if got := versionBelow(tc.a, tc.b); got != tc.want { + t.Errorf("versionBelow(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want) + } + } +} + +func TestVerifySSHSig_OverlappingArmorMarkersDoNotPanic(t *testing.T) { + // Regression: the END marker can match INSIDE the BEGIN marker's + // trailing dashes ("-----BEGIN SSH SIGNATURE-----END SSH SIGNATURE-----" + // finds END at offset 24 < len(BEGIN)); slicing with that index paniced. + crafted := []string{ + "-----BEGIN SSH SIGNATURE-----END SSH SIGNATURE-----", + "-----BEGIN SSH SIGNATUREEND SSH SIGNATURE-----", + "-----END SSH SIGNATURE---------BEGIN SSH SIGNATURE-----", + } + for _, s := range crafted { + if err := verifySSHSig(s, []byte("msg"), fixtureKeyB64, signatureNamespace); err == nil { + t.Errorf("verifySSHSig(%q) = nil error, want rejection", s) + } + } +} diff --git a/internal/selfupdate/sshsig.go b/internal/selfupdate/sshsig.go index 45503c88..d48d575b 100644 --- a/internal/selfupdate/sshsig.go +++ b/internal/selfupdate/sshsig.go @@ -67,7 +67,11 @@ func appendSSHString(dst, s []byte) []byte { func verifySSHSig(armored string, message []byte, allowedKeyB64, namespace string) error { beg := strings.Index(armored, sigArmorBegin) end := strings.Index(armored, sigArmorEnd) - if beg == -1 || end == -1 || end < beg { + // The END marker must start at or after the END of the BEGIN marker — + // the two markers share the "-----" run, so a crafted blob like + // "-----BEGIN SSH SIGNATUREEND SSH SIGNATURE-----" can make END match + // inside BEGIN's tail; slicing with that index would panic. + if beg == -1 || end == -1 || end < beg+len(sigArmorBegin) { return fmt.Errorf("not an armored SSH signature block") } b64 := strings.Map(func(r rune) rune { From 600c948a1443c97556f6027bc5d07bd1d132e7c9 Mon Sep 17 00:00:00 2001 From: Shubham Malik Date: Thu, 3 Sep 2026 04:56:20 +0530 Subject: [PATCH 9/9] fix(developer-mdm): annotate safe tid conversions for gosec --- internal/bgpriority/apply_linux.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/bgpriority/apply_linux.go b/internal/bgpriority/apply_linux.go index a32b5263..b888885c 100644 --- a/internal/bgpriority/apply_linux.go +++ b/internal/bgpriority/apply_linux.go @@ -51,6 +51,8 @@ func apply() (string, error) { niceCovered++ } ioprio := uintptr(ioprioClassBE<