From f3495080af9c9b16ef73774a15aedc5faacdca4d Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Mon, 19 Oct 2026 17:47:48 +0530 Subject: [PATCH 01/34] feat(faultinject): add build-tagged self-update fault checkpoints Point(name) is an inlinable no-op unless built with selfupdate_faults. Tagged builds act on DMG_FAULT (merged with the link-time Baked value): kill, err, hang, diskfull, exit, with optional @user/@system qualifiers. DMG_FAULT_TRACE records every checkpoint reached. --- .github/workflows/tests.yml | 4 + internal/faultinject/diskfull_test.go | 26 ++++ internal/faultinject/faultinject.go | 18 +++ internal/faultinject/os_other.go | 12 ++ internal/faultinject/os_windows.go | 15 +++ internal/faultinject/point_off.go | 9 ++ internal/faultinject/point_off_test.go | 16 +++ internal/faultinject/point_on.go | 118 +++++++++++++++++ internal/faultinject/point_on_test.go | 170 +++++++++++++++++++++++++ 9 files changed, 388 insertions(+) create mode 100644 internal/faultinject/diskfull_test.go create mode 100644 internal/faultinject/faultinject.go create mode 100644 internal/faultinject/os_other.go create mode 100644 internal/faultinject/os_windows.go create mode 100644 internal/faultinject/point_off.go create mode 100644 internal/faultinject/point_off_test.go create mode 100644 internal/faultinject/point_on.go create mode 100644 internal/faultinject/point_on_test.go diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 89016ff3..5c43568d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -112,6 +112,8 @@ jobs: with: go-version-file: go.mod - run: make test + - name: Test self-update fault injection (selfupdate_faults tag) + run: go test -race -count=1 -tags selfupdate_faults ./internal/faultinject/ # These packages have Windows-only code and Windows-specific behavior whose # tests the macOS test job can only cross-compile, not run: @@ -148,6 +150,8 @@ jobs: - run: go test -race -count=1 -run '^TestReal_' ./internal/detector/ - name: Test scheduled scan console suppression run: go test -race -count=1 -run 'Test(IsTaskRegistered|DetectInvocationMethod_Windows|HideWindow)' ./internal/schtasks/ ./internal/telemetry/ ./internal/winproc/ + - name: Test self-update fault injection (selfupdate_faults tag) + run: go test -race -count=1 -tags selfupdate_faults ./internal/faultinject/ smoke: name: Smoke Tests diff --git a/internal/faultinject/diskfull_test.go b/internal/faultinject/diskfull_test.go new file mode 100644 index 00000000..88d7a02f --- /dev/null +++ b/internal/faultinject/diskfull_test.go @@ -0,0 +1,26 @@ +//go:build selfupdate_faults + +package faultinject + +import ( + "errors" + "runtime" + "syscall" + "testing" +) + +func TestDiskFullErrno(t *testing.T) { + t.Setenv("DMG_FAULT", "S4.manifest:diskfull") + err := Point("S4.manifest") + var errno syscall.Errno + if !errors.As(err, &errno) { + t.Fatalf("diskfull = %v, want a wrapped errno", err) + } + want := syscall.ENOSPC + if runtime.GOOS == "windows" { + want = syscall.Errno(112) // ERROR_DISK_FULL + } + if errno != want { + t.Fatalf("errno = %d, want %d", errno, want) + } +} diff --git a/internal/faultinject/faultinject.go b/internal/faultinject/faultinject.go new file mode 100644 index 00000000..7e2fc971 --- /dev/null +++ b/internal/faultinject/faultinject.go @@ -0,0 +1,18 @@ +// Package faultinject provides named checkpoints for self-update fault tests. +// +// Release builds compile Point to an inlinable no-op. Builds tagged +// selfupdate_faults read DMG_FAULT=[@user|@system]:[,...] +// (merged with the link-time Baked value) and act at the matching checkpoint. +// Actions: kill (os.Exit(137), no cleanup), err (return ErrInjected), hang +// (block until killed), diskfull (return an error wrapping the OS disk-full +// errno), exit (os.Exit(N), e.g. exit2 mimics a Go panic). With +// DMG_FAULT_TRACE=, every reached checkpoint name is appended to file. +// +// Checkpoint names may contain ':' (S4.downloaded:); the action is the +// text after the last ':'. +package faultinject + +import "errors" + +// ErrInjected is wrapped by every error Point returns for an injected fault. +var ErrInjected = errors.New("faultinject: injected fault") diff --git a/internal/faultinject/os_other.go b/internal/faultinject/os_other.go new file mode 100644 index 00000000..9950a0b6 --- /dev/null +++ b/internal/faultinject/os_other.go @@ -0,0 +1,12 @@ +//go:build selfupdate_faults && !windows + +package faultinject + +import ( + "os" + "syscall" +) + +var errDiskFull error = syscall.ENOSPC + +func runningAsSystem() bool { return os.Geteuid() == 0 } diff --git a/internal/faultinject/os_windows.go b/internal/faultinject/os_windows.go new file mode 100644 index 00000000..d76b2083 --- /dev/null +++ b/internal/faultinject/os_windows.go @@ -0,0 +1,15 @@ +//go:build selfupdate_faults && windows + +package faultinject + +import "golang.org/x/sys/windows" + +var errDiskFull error = windows.ERROR_DISK_FULL + +func runningAsSystem() bool { + u, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return false + } + return u.User.Sid.IsWellKnown(windows.WinLocalSystemSid) +} diff --git a/internal/faultinject/point_off.go b/internal/faultinject/point_off.go new file mode 100644 index 00000000..7e8a6c6d --- /dev/null +++ b/internal/faultinject/point_off.go @@ -0,0 +1,9 @@ +//go:build !selfupdate_faults + +package faultinject + +// Enabled reports whether fault injection is compiled in. +const Enabled = false + +// Point is a no-op without the selfupdate_faults build tag. +func Point(string) error { return nil } diff --git a/internal/faultinject/point_off_test.go b/internal/faultinject/point_off_test.go new file mode 100644 index 00000000..60bdd6b4 --- /dev/null +++ b/internal/faultinject/point_off_test.go @@ -0,0 +1,16 @@ +//go:build !selfupdate_faults + +package faultinject + +import "testing" + +func TestPointIsNoOpWithoutTag(t *testing.T) { + t.Setenv("DMG_FAULT", "S0.locked:err,S0.locked:kill") + t.Setenv("DMG_FAULT_TRACE", t.TempDir()+"/trace") + if Enabled { + t.Fatal("Enabled = true without the selfupdate_faults tag") + } + if err := Point("S0.locked"); err != nil { + t.Fatalf("Point = %v, want nil", err) + } +} diff --git a/internal/faultinject/point_on.go b/internal/faultinject/point_on.go new file mode 100644 index 00000000..77f26ce3 --- /dev/null +++ b/internal/faultinject/point_on.go @@ -0,0 +1,118 @@ +//go:build selfupdate_faults + +package faultinject + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" +) + +// Enabled reports whether fault injection is compiled in. +const Enabled = true + +// Baked holds faults set at link time +// (-ldflags "-X github.com/step-security/dev-machine-guard/internal/faultinject.Baked="). +// It is merged with DMG_FAULT, Baked first. +var Baked string + +const ( + envFault = "DMG_FAULT" + envTrace = "DMG_FAULT_TRACE" +) + +// exit and isSystem are swapped by tests. +var ( + exit = os.Exit + isSystem = runningAsSystem +) + +type rule struct { + checkpoint string + principal string // "", "user" or "system" + action string +} + +// parse splits a spec list; malformed entries are returned as rules with an +// empty checkpoint so Point can report them instead of silently ignoring them. +func parse(spec string) []rule { + var rules []rule + for _, entry := range strings.Split(spec, ",") { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + i := strings.LastIndexByte(entry, ':') + if i <= 0 || i == len(entry)-1 { + rules = append(rules, rule{action: entry}) + continue + } + r := rule{checkpoint: entry[:i], action: entry[i+1:]} + if j := strings.LastIndexByte(r.checkpoint, '@'); j > 0 { + switch p := r.checkpoint[j+1:]; p { + case "user", "system": + r.principal, r.checkpoint = p, r.checkpoint[:j] + } + } + rules = append(rules, r) + } + return rules +} + +// Point acts on the first fault configured for name, if any. +func Point(name string) error { + trace(name) + for _, r := range parse(Baked + "," + os.Getenv(envFault)) { + if r.checkpoint == "" { + return fmt.Errorf("%w: malformed %s entry %q", ErrInjected, envFault, r.action) + } + if r.checkpoint != name { + continue + } + if r.principal != "" && (r.principal == "system") != isSystem() { + continue + } + return act(name, r.action) + } + return nil +} + +func act(name, action string) error { + switch action { + case "kill": + exit(137) + case "err": + return fmt.Errorf("%w: err at %s", ErrInjected, name) + case "diskfull": + return fmt.Errorf("%w: diskfull at %s: %w", ErrInjected, name, errDiskFull) + case "hang": + for { + time.Sleep(time.Hour) + } + default: + if n, ok := strings.CutPrefix(action, "exit"); ok { + if code, err := strconv.Atoi(n); err == nil && code >= 0 { + exit(code) + return nil // only reached when exit is stubbed + } + } + return fmt.Errorf("%w: unknown action %q at %s", ErrInjected, action, name) + } + return nil // only reached when exit is stubbed +} + +func trace(name string) { + path := os.Getenv(envTrace) + if path == "" { + return + } + // #nosec G304 -- test-only trace file chosen by the harness. + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return + } + _, _ = f.WriteString(name + "\n") + _ = f.Close() +} diff --git a/internal/faultinject/point_on_test.go b/internal/faultinject/point_on_test.go new file mode 100644 index 00000000..3ff4bb10 --- /dev/null +++ b/internal/faultinject/point_on_test.go @@ -0,0 +1,170 @@ +//go:build selfupdate_faults + +package faultinject + +import ( + "bufio" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func stubExit(t *testing.T) *int { + t.Helper() + code := -1 + old := exit + exit = func(c int) { code = c } + t.Cleanup(func() { exit = old }) + return &code +} + +func stubSystem(t *testing.T, system bool) { + t.Helper() + old := isSystem + isSystem = func() bool { return system } + t.Cleanup(func() { isSystem = old }) +} + +func TestPointActions(t *testing.T) { + t.Setenv("DMG_FAULT", "S0.locked:err, S4.downloaded:agent.exe:diskfull,S5.smoked:exit3,S1.swept:kill,S2.evaluated:exit2") + if !Enabled { + t.Fatal("Enabled = false with the tag") + } + if err := Point("S3.target"); err != nil { + t.Fatalf("unconfigured checkpoint: %v", err) + } + if err := Point("S0.locked"); !errors.Is(err, ErrInjected) || !strings.Contains(err.Error(), "S0.locked") { + t.Fatalf("err action = %v", err) + } + err := Point("S4.downloaded:agent.exe") + if !errors.Is(err, ErrInjected) || !errors.Is(err, errDiskFull) { + t.Fatalf("diskfull action = %v", err) + } + if err := Point("S4.downloaded:task.exe"); err != nil { + t.Fatalf("other asset matched: %v", err) + } + for name, want := range map[string]int{"S5.smoked": 3, "S1.swept": 137, "S2.evaluated": 2} { + code := stubExit(t) + if err := Point(name); err != nil || *code != want { + t.Fatalf("%s: code %d err %v, want %d", name, *code, err, want) + } + } +} + +func TestPointRejectsBadSpecs(t *testing.T) { + for _, spec := range []string{"S0.locked:explode", "S0.locked:exit", "S0.locked:exit-1", "nocolon", "S0.locked:", ":kill"} { + t.Setenv("DMG_FAULT", spec) + if err := Point("S0.locked"); !errors.Is(err, ErrInjected) { + t.Errorf("%q: Point = %v, want an injected error", spec, err) + } + } +} + +func TestPointPrincipalQualifier(t *testing.T) { + t.Setenv("DMG_FAULT", "boot.resolved@system:err,boot.launched@user:err") + for _, system := range []bool{false, true} { + stubSystem(t, system) + if got := Point("boot.resolved") != nil; got != system { + t.Errorf("system=%v: @system fired=%v", system, got) + } + if got := Point("boot.launched") != nil; got == system { + t.Errorf("system=%v: @user fired=%v", system, got) + } + } +} + +func TestPointMergesBaked(t *testing.T) { + old := Baked + t.Cleanup(func() { Baked = old }) + Baked = "agent.prestart:err" + t.Setenv("DMG_FAULT", "S7.reported:err") + if Point("agent.prestart") == nil || Point("S7.reported") == nil { + t.Fatal("Baked and DMG_FAULT must both apply") + } + t.Setenv("DMG_FAULT", "") + if Point("agent.prestart") == nil { + t.Fatal("Baked must apply without DMG_FAULT") + } +} + +func TestPointTrace(t *testing.T) { + trace := filepath.Join(t.TempDir(), "trace") + t.Setenv("DMG_FAULT_TRACE", trace) + t.Setenv("DMG_FAULT", "S6.mutex:err") + for _, n := range []string{"S0.locked", "S4.downloaded:agent.exe", "S6.mutex"} { + _ = Point(n) + } + got, err := os.ReadFile(trace) + if err != nil { + t.Fatal(err) + } + if want := "S0.locked\nS4.downloaded:agent.exe\nS6.mutex\n"; string(got) != want { + t.Fatalf("trace = %q, want %q", got, want) + } + t.Setenv("DMG_FAULT_TRACE", filepath.Join(t.TempDir(), "missing", "trace")) + if err := Point("S0.locked"); err != nil { + t.Fatalf("unwritable trace must not fail Point: %v", err) + } +} + +// TestHelperProcess is the subprocess body for the kill and hang tests. +func TestHelperProcess(t *testing.T) { + if os.Getenv("FAULTINJECT_HELPER") != "1" { + t.Skip("helper") + } + os.Stdout.WriteString("ready\n") + _ = Point("S6.state-temp") + // Reached only if Point returned: stands in for caller cleanup. + _ = os.WriteFile(os.Getenv("FAULTINJECT_MARKER"), []byte("returned"), 0o600) + os.Exit(0) +} + +func helper(t *testing.T, fault string) (*exec.Cmd, *bufio.Reader, string) { + t.Helper() + marker := filepath.Join(t.TempDir(), "marker") + cmd := exec.Command(os.Args[0], "-test.run=^TestHelperProcess$") + cmd.Env = append(os.Environ(), "FAULTINJECT_HELPER=1", "FAULTINJECT_MARKER="+marker, "DMG_FAULT="+fault) + out, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + return cmd, bufio.NewReader(out), marker +} + +func TestKillAndExitSkipCleanup(t *testing.T) { + for action, want := range map[string]int{"kill": 137, "exit2": 2, "err": 0} { + cmd, out, marker := helper(t, "S6.state-temp:"+action) + _, _ = out.ReadString('\n') + err := cmd.Wait() + if got := cmd.ProcessState.ExitCode(); got != want { + t.Fatalf("%s: wait = %v, exit %d, want %d", action, err, got, want) + } + _, statErr := os.Stat(marker) + if returned := statErr == nil; returned != (want == 0) { + t.Fatalf("%s: code after Point ran = %v", action, returned) + } + } +} + +func TestHangBlocksUntilKilled(t *testing.T) { + cmd, out, _ := helper(t, "S6.state-temp:hang") + if line, _ := out.ReadString('\n'); line != "ready\n" { + t.Fatalf("helper said %q", line) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case err := <-done: + t.Fatalf("hang returned: %v", err) + case <-time.After(500 * time.Millisecond): + } + _ = cmd.Process.Kill() + <-done +} From 2942701f2c0535f31f3b0376aacbb959efe33b3a Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Mon, 19 Oct 2026 17:53:33 +0530 Subject: [PATCH 02/34] feat(updatestate): add self-update layout, state.json, lock and run records Layout paths rooted at an install root and a data root; strict x.y.z versions; schema-1 state.json with corrupt-as-missing Load and atomic Save (temp + fsync + MoveFileEx REPLACE_EXISTING|WRITE_THROUGH, or rename + dir fsync) with .state-temp/-renamed fault checkpoints; non-blocking LockFileEx/flock update lock; run records capped at 10 with bad-run classification. Kill-at-checkpoint subprocess tests and fuzzers. --- .github/workflows/fuzz.yml | 1 + .github/workflows/tests.yml | 7 +- internal/updatestate/atomic.go | 67 +++++++ internal/updatestate/atomic_other.go | 23 +++ internal/updatestate/atomic_windows.go | 17 ++ internal/updatestate/crash_faults_test.go | 31 +++ internal/updatestate/crash_test.go | 88 +++++++++ internal/updatestate/fuzz_test.go | 76 ++++++++ internal/updatestate/layout.go | 72 +++++++ internal/updatestate/layout_test.go | 59 ++++++ internal/updatestate/lock.go | 40 ++++ internal/updatestate/lock_other.go | 22 +++ internal/updatestate/lock_test.go | 71 +++++++ internal/updatestate/lock_windows.go | 17 ++ internal/updatestate/open_other.go | 21 ++ internal/updatestate/open_windows.go | 27 +++ internal/updatestate/runs.go | 153 +++++++++++++++ internal/updatestate/runs_test.go | 127 +++++++++++++ internal/updatestate/state.go | 182 ++++++++++++++++++ internal/updatestate/state_test.go | 221 ++++++++++++++++++++++ internal/updatestate/version.go | 52 +++++ internal/updatestate/version_test.go | 45 +++++ tests/test_fuzz_go.sh | 2 +- 23 files changed, 1417 insertions(+), 4 deletions(-) create mode 100644 internal/updatestate/atomic.go create mode 100644 internal/updatestate/atomic_other.go create mode 100644 internal/updatestate/atomic_windows.go create mode 100644 internal/updatestate/crash_faults_test.go create mode 100644 internal/updatestate/crash_test.go create mode 100644 internal/updatestate/fuzz_test.go create mode 100644 internal/updatestate/layout.go create mode 100644 internal/updatestate/layout_test.go create mode 100644 internal/updatestate/lock.go create mode 100644 internal/updatestate/lock_other.go create mode 100644 internal/updatestate/lock_test.go create mode 100644 internal/updatestate/lock_windows.go create mode 100644 internal/updatestate/open_other.go create mode 100644 internal/updatestate/open_windows.go create mode 100644 internal/updatestate/runs.go create mode 100644 internal/updatestate/runs_test.go create mode 100644 internal/updatestate/state.go create mode 100644 internal/updatestate/state_test.go create mode 100644 internal/updatestate/version.go create mode 100644 internal/updatestate/version_test.go diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index d3bb2fe7..8d5fc11e 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -35,6 +35,7 @@ jobs: - ./internal/detector/configaudit - ./internal/state - ./internal/detector/credentials + - ./internal/updatestate env: CGO_ENABLED: '0' # Short campaigns on the PR and merge-queue paths so they don't hold up a diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5c43568d..dec13c8c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -113,7 +113,7 @@ jobs: go-version-file: go.mod - run: make test - name: Test self-update fault injection (selfupdate_faults tag) - run: go test -race -count=1 -tags selfupdate_faults ./internal/faultinject/ + run: go test -race -count=1 -tags selfupdate_faults ./internal/faultinject/ ./internal/updatestate/ # These packages have Windows-only code and Windows-specific behavior whose # tests the macOS test job can only cross-compile, not run: @@ -128,6 +128,7 @@ jobs: # - executor: Readlink on a real directory junction (ModeIrregular entry, # NT-namespace target), which the skills detector's junction folding # depends on + # - updatestate: MoveFileEx state replace, LockFileEx, protected DACLs # - detector TestReal_*: the Kiro CLI registry reader against a scratch # HKCU key, and skills junction folding over real junctions (the rest of # the detector suite assumes a POSIX host, hence the -run filter) @@ -146,12 +147,12 @@ jobs: - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version-file: go.mod - - run: go test -race -count=1 ./internal/devicepolicy/ ./internal/secureuserfile/ ./internal/detector/credentials/ ./internal/safepath/ ./internal/executor/ + - run: go test -race -count=1 ./internal/devicepolicy/ ./internal/secureuserfile/ ./internal/detector/credentials/ ./internal/safepath/ ./internal/executor/ ./internal/updatestate/ - run: go test -race -count=1 -run '^TestReal_' ./internal/detector/ - name: Test scheduled scan console suppression run: go test -race -count=1 -run 'Test(IsTaskRegistered|DetectInvocationMethod_Windows|HideWindow)' ./internal/schtasks/ ./internal/telemetry/ ./internal/winproc/ - name: Test self-update fault injection (selfupdate_faults tag) - run: go test -race -count=1 -tags selfupdate_faults ./internal/faultinject/ + run: go test -race -count=1 -tags selfupdate_faults ./internal/faultinject/ ./internal/updatestate/ smoke: name: Smoke Tests diff --git a/internal/updatestate/atomic.go b/internal/updatestate/atomic.go new file mode 100644 index 00000000..de686e43 --- /dev/null +++ b/internal/updatestate/atomic.go @@ -0,0 +1,67 @@ +package updatestate + +import ( + "os" + "path/filepath" + "strings" +) + +// writeAtomic writes data to a temp file beside path, fsyncs it, then +// replaces path (MoveFileEx REPLACE_EXISTING|WRITE_THROUGH on Windows, +// rename + directory fsync elsewhere). afterTemp runs once the temp file is +// durable and afterRename once path holds data; a non-nil error from +// afterTemp abandons the write and leaves path untouched. +func writeAtomic(path string, data []byte, afterTemp, afterRename func() error) error { + dir, base := filepath.Split(path) + f, err := os.CreateTemp(dir, tempPrefix(base)+"*") + if err != nil { + return err + } + tmp := f.Name() + committed := false + defer func() { + if !committed { + _ = os.Remove(tmp) + } + }() + _, err = f.Write(data) + if err == nil { + err = f.Sync() + } + if cerr := f.Close(); err == nil { + err = cerr + } + if err != nil { + return err + } + if afterTemp != nil { + if err := afterTemp(); err != nil { + return err + } + } + if err := replaceFile(tmp, path); err != nil { + return err + } + committed = true + if afterRename != nil { + return afterRename() + } + return nil +} + +func tempPrefix(base string) string { return "." + base + ".tmp-" } + +// SweepTemps removes temp files that a killed writer left beside path. Call +// it only while holding the update lock, so no live writer is affected. +func SweepTemps(path string) { + dir, base := filepath.Split(path) + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, e := range entries { + if e.Type().IsRegular() && strings.HasPrefix(e.Name(), tempPrefix(base)) { + _ = os.Remove(filepath.Join(dir, e.Name())) + } + } +} diff --git a/internal/updatestate/atomic_other.go b/internal/updatestate/atomic_other.go new file mode 100644 index 00000000..3857f4bc --- /dev/null +++ b/internal/updatestate/atomic_other.go @@ -0,0 +1,23 @@ +//go:build !windows + +package updatestate + +import ( + "os" + "path/filepath" +) + +func replaceFile(src, dst string) error { + if err := os.Rename(src, dst); err != nil { + return err + } + d, err := os.Open(filepath.Dir(dst)) + if err != nil { + return err + } + err = d.Sync() + if cerr := d.Close(); err == nil { + err = cerr + } + return err +} diff --git a/internal/updatestate/atomic_windows.go b/internal/updatestate/atomic_windows.go new file mode 100644 index 00000000..4a8a0ca8 --- /dev/null +++ b/internal/updatestate/atomic_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package updatestate + +import "golang.org/x/sys/windows" + +func replaceFile(src, dst string) error { + from, err := windows.UTF16PtrFromString(src) + if err != nil { + return err + } + to, err := windows.UTF16PtrFromString(dst) + if err != nil { + return err + } + return windows.MoveFileEx(from, to, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH) +} diff --git a/internal/updatestate/crash_faults_test.go b/internal/updatestate/crash_faults_test.go new file mode 100644 index 00000000..fd01c330 --- /dev/null +++ b/internal/updatestate/crash_faults_test.go @@ -0,0 +1,31 @@ +//go:build selfupdate_faults + +package updatestate + +import "testing" + +// TestSaveAtomicUnderInjectedFaults drives the real faultinject checkpoints +// through DMG_FAULT for every prefix the spec names. +func TestSaveAtomicUnderInjectedFaults(t *testing.T) { + for _, prefix := range []string{"S6", "rollback", "adopt"} { + for _, tc := range []struct { + action, at string + code int + current string + }{ + {"kill", "state-temp", 137, "1.18.2"}, + {"kill", "state-renamed", 137, "1.19.0"}, + {"err", "state-temp", 91, "1.18.2"}, + {"diskfull", "state-temp", 91, "1.18.2"}, + {"err", "state-renamed", 91, "1.19.0"}, + } { + name := prefix + "." + tc.at + t.Run(name+":"+tc.action, func(t *testing.T) { + code, st := runCrash(t, prefix, "DMG_FAULT="+name+":"+tc.action) + if code != tc.code || st.Current != tc.current { + t.Fatalf("exit %d current %s, want %d %s", code, st.Current, tc.code, tc.current) + } + }) + } + } +} diff --git a/internal/updatestate/crash_test.go b/internal/updatestate/crash_test.go new file mode 100644 index 00000000..23d2463d --- /dev/null +++ b/internal/updatestate/crash_test.go @@ -0,0 +1,88 @@ +package updatestate + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +// TestCrashHelper is the subprocess body: it saves the "new" state and is +// killed at the requested checkpoint, either by a stubbed checkpoint +// (UPDATESTATE_HOOK=1) or by a real faultinject DMG_FAULT (tagged builds). +func TestCrashHelper(t *testing.T) { + path := os.Getenv("UPDATESTATE_CRASH_PATH") + if path == "" { + t.Skip("helper") + } + if phase := os.Getenv("UPDATESTATE_HOOK_PHASE"); phase != "" { + checkpoint = func(name string) error { + if name == phase { + os.Exit(137) + } + return nil + } + } + if err := SaveCheckpointed(path, newCrashState(), os.Getenv("UPDATESTATE_PREFIX")); err != nil { + os.Exit(91) + } + os.Exit(0) +} + +func oldCrashState() *State { return &State{Current: "1.18.2", Previous: "1.17.0"} } + +func newCrashState() *State { + return &State{ + Current: "1.19.0", + Previous: "1.18.2", + Probation: &Probation{Version: "1.19.0", Since: time.Date(2026, 10, 19, 0, 0, 0, 0, time.UTC)}, + } +} + +// runCrash saves the old state, runs the helper with env, and returns the +// helper's exit code and the state Load then sees. +func runCrash(t *testing.T, prefix string, env ...string) (int, *State) { + t.Helper() + path := filepath.Join(t.TempDir(), "state.json") + if err := Save(path, oldCrashState()); err != nil { + t.Fatal(err) + } + cmd := exec.Command(os.Args[0], "-test.run=^TestCrashHelper$") + cmd.Env = append(os.Environ(), append(env, "UPDATESTATE_CRASH_PATH="+path, "UPDATESTATE_PREFIX="+prefix)...) + _ = cmd.Run() + st, corrupt, err := Load(path) + if err != nil || corrupt || st == nil { + t.Fatalf("Load after crash: %+v corrupt=%v err=%v (torn state)", st, corrupt, err) + } + SweepTemps(path) + if entries, _ := os.ReadDir(filepath.Dir(path)); len(entries) != 1 { + t.Fatalf("SweepTemps left %v", entries) + } + return cmd.ProcessState.ExitCode(), st +} + +func TestSaveAtomicUnderKill(t *testing.T) { + for _, tc := range []struct { + phase string + wantNew bool + }{ + {"S6.state-temp", false}, + {"S6.state-renamed", true}, + } { + t.Run(tc.phase, func(t *testing.T) { + code, st := runCrash(t, "S6", "UPDATESTATE_HOOK_PHASE="+tc.phase) + if code != 137 { + t.Fatalf("helper exit %d, want 137", code) + } + if want := map[bool]string{false: "1.18.2", true: "1.19.0"}[tc.wantNew]; st.Current != want { + t.Fatalf("current = %s, want %s", st.Current, want) + } + }) + } + t.Run("no crash", func(t *testing.T) { + if code, st := runCrash(t, "S6", "UPDATESTATE_HOOK_PHASE=none"); code != 0 || st.Current != "1.19.0" { + t.Fatalf("exit %d current %s", code, st.Current) + } + }) +} diff --git a/internal/updatestate/fuzz_test.go b/internal/updatestate/fuzz_test.go new file mode 100644 index 00000000..71ddf19f --- /dev/null +++ b/internal/updatestate/fuzz_test.go @@ -0,0 +1,76 @@ +package updatestate + +import ( + "encoding/json" + "path/filepath" + "testing" +) + +func FuzzDecodeState(f *testing.F) { + for _, s := range []string{ + `{"schema":1,"current":"1.19.0","previous":"1.18.2","probation":{"version":"1.19.0","since":"2026-10-19T00:00:00Z","ok":0,"bad":1,"smoke_ok":true},"quarantine":{"1.18.9":{"reason":"x","at":"2026-10-19T00:00:00Z"}},"updater":{"version":"1.18.2","consecutive_failures":2},"switch_deferrals":1,"dedupe":{"k":"2026-10-19T00:00:00Z"}}`, + `{"schema":1,"current":"1.2.3"}`, `{"schema":2}`, `null`, ``, `{"schema":1,"current":"01.2.3"}`, + } { + f.Add([]byte(s)) + } + f.Fuzz(func(t *testing.T, data []byte) { + st, err := decodeState(data) + if err != nil { + return + } + // Anything accepted must save and load back to the same state. + path := filepath.Join(t.TempDir(), "state.json") + if err := Save(path, st); err != nil { + t.Fatalf("accepted state does not save: %v", err) + } + got, corrupt, err := Load(path) + if err != nil || corrupt { + t.Fatalf("saved state reloads corrupt=%v err=%v", corrupt, err) + } + a, _ := json.Marshal(got) + st.Schema = SchemaVersion + if st.Quarantine == nil { + st.Quarantine = map[string]Quarantine{} + } + if st.Dedupe == nil { + st.Dedupe = got.Dedupe + } + b, _ := json.Marshal(st) + if string(a) != string(b) { + t.Fatalf("round trip changed state:\n%s\n%s", a, b) + } + }) +} + +func FuzzDecodeRuns(f *testing.F) { + for _, s := range []string{ + `{"records":[{"version":"1.19.0","execution_id":"e","started":true,"exit_code":0,"at":"2026-10-19T00:00:00Z"}]}`, + `{"records":[{"version":"1.19.0","watchdog_killed":true}]}`, `garbage`, `{"records":null}`, + } { + f.Add([]byte(s)) + } + f.Fuzz(func(t *testing.T, data []byte) { + recs := decodeRuns(data) + if len(recs) > MaxRunRecords { + t.Fatalf("%d records", len(recs)) + } + for _, r := range recs { + if !validVersion(r.Version) { + t.Fatalf("invalid version kept: %q", r.Version) + } + _ = ClassifyRun(r) + } + }) +} + +func FuzzParseVersion(f *testing.F) { + for _, s := range []string{"1.2.3", "0.0.0", "01.2.3", "1.2.3-rc.1", "4294967296.0.0"} { + f.Add(s) + } + f.Fuzz(func(t *testing.T, s string) { + v, err := ParseVersion(s) + if err == nil && v.String() != s { + t.Fatalf("ParseVersion(%q) = %v: not canonical", s, v) + } + }) +} diff --git a/internal/updatestate/layout.go b/internal/updatestate/layout.go new file mode 100644 index 00000000..526d09c5 --- /dev/null +++ b/internal/updatestate/layout.go @@ -0,0 +1,72 @@ +// Package updatestate owns the on-disk self-update layout: version +// directories, state.json, the updater lock and the bootstrap's run records. +// +// \versions\\ immutable once renamed in +// \versions\.staging--*\ never executed +// \seed\\ MSI-owned fallback payload +// \state\{state.json,update.lock,update.log} +// \runs\.json user-writable, advisory only +package updatestate + +import ( + "os" + "path/filepath" + "strings" +) + +// Production MSI roots. Everything takes the roots as parameters so tests +// and loader installs can use others. +const ( + DefaultInstallRoot = `C:\Program Files\StepSecurity` + DefaultDataRoot = `C:\ProgramData\StepSecurity` +) + +const ( + // ManifestName is the per-version manifest written before the rename in. + ManifestName = "manifest.json" + // StagingPrefix and TrashPrefix name entries of VersionsDir that S1 sweeps. + StagingPrefix = ".staging-" + TrashPrefix = ".trash-" +) + +// Layout resolves every self-update path from an install root (versions, +// seed, state) and a data root (runs). +type Layout struct { + InstallRoot string + DataRoot string +} + +// MSILayout is the production MSI layout. +func MSILayout() Layout { + return Layout{InstallRoot: DefaultInstallRoot, DataRoot: DefaultDataRoot} +} + +func (l Layout) VersionsDir() string { return filepath.Join(l.InstallRoot, "versions") } + +func (l Layout) VersionDir(v Version) string { return filepath.Join(l.VersionsDir(), v.String()) } + +func (l Layout) SeedDir(v Version) string { + return filepath.Join(l.InstallRoot, "seed", v.String()) +} + +func (l Layout) StateDir() string { return filepath.Join(l.InstallRoot, "state") } +func (l Layout) StateFile() string { return filepath.Join(l.StateDir(), "state.json") } +func (l Layout) LockFile() string { return filepath.Join(l.StateDir(), "update.lock") } +func (l Layout) LogFile() string { return filepath.Join(l.StateDir(), "update.log") } + +func (l Layout) RunsDir() string { return filepath.Join(l.DataRoot, "runs") } + +func (l Layout) RunFile(v Version) string { + return filepath.Join(l.RunsDir(), v.String()+".json") +} + +// StagingDir creates a fresh versions\.staging-- directory. It sits +// in VersionsDir (which must exist) so the final rename stays on one volume. +func (l Layout) StagingDir(v Version) (string, error) { + return os.MkdirTemp(l.VersionsDir(), StagingPrefix+v.String()+"-") +} + +// IsSweepable reports whether a VersionsDir entry is staging or trash. +func IsSweepable(name string) bool { + return strings.HasPrefix(name, StagingPrefix) || strings.HasPrefix(name, TrashPrefix) +} diff --git a/internal/updatestate/layout_test.go b/internal/updatestate/layout_test.go new file mode 100644 index 00000000..fceffeab --- /dev/null +++ b/internal/updatestate/layout_test.go @@ -0,0 +1,59 @@ +package updatestate + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLayoutPaths(t *testing.T) { + l := Layout{InstallRoot: filepath.Join("root", "SS"), DataRoot: filepath.Join("data", "SS")} + v := Version{1, 19, 0} + j := filepath.Join + for got, want := range map[string]string{ + l.VersionsDir(): j("root", "SS", "versions"), + l.VersionDir(v): j("root", "SS", "versions", "1.19.0"), + l.SeedDir(v): j("root", "SS", "seed", "1.19.0"), + l.StateDir(): j("root", "SS", "state"), + l.StateFile(): j("root", "SS", "state", "state.json"), + l.LockFile(): j("root", "SS", "state", "update.lock"), + l.LogFile(): j("root", "SS", "state", "update.log"), + l.RunsDir(): j("data", "SS", "runs"), + l.RunFile(v): j("data", "SS", "runs", "1.19.0.json"), + } { + if got != want { + t.Errorf("got %q, want %q", got, want) + } + } + if m := MSILayout(); m.InstallRoot != `C:\Program Files\StepSecurity` || m.DataRoot != `C:\ProgramData\StepSecurity` { + t.Errorf("MSILayout = %+v", m) + } +} + +func TestStagingDir(t *testing.T) { + l := Layout{InstallRoot: t.TempDir()} + v := Version{1, 2, 3} + if _, err := l.StagingDir(v); err == nil { + t.Fatal("StagingDir must fail when VersionsDir is missing") + } + if err := os.MkdirAll(l.VersionsDir(), 0o755); err != nil { + t.Fatal(err) + } + a, err := l.StagingDir(v) + if err != nil { + t.Fatal(err) + } + b, err := l.StagingDir(v) + if err != nil { + t.Fatal(err) + } + if a == b || filepath.Dir(a) != l.VersionsDir() || !strings.HasPrefix(filepath.Base(a), ".staging-1.2.3-") { + t.Fatalf("staging dirs %q, %q", a, b) + } + for name, want := range map[string]bool{filepath.Base(a): true, ".trash-1.2.3-x": true, "1.2.3": false, "staging-1": false} { + if IsSweepable(name) != want { + t.Errorf("IsSweepable(%q) != %v", name, want) + } + } +} diff --git a/internal/updatestate/lock.go b/internal/updatestate/lock.go new file mode 100644 index 00000000..cd4253fa --- /dev/null +++ b/internal/updatestate/lock.go @@ -0,0 +1,40 @@ +package updatestate + +import ( + "errors" + "os" +) + +// ErrLocked means another process holds the update lock. +var ErrLocked = errors.New("updatestate: update lock held by another process") + +// Lock is an OS-owned exclusive lock that the kernel releases when the +// holder dies. The lock file is never removed: every contender must lock the +// same file. +type Lock struct{ f *os.File } + +// AcquireLock takes the lock without blocking; it returns ErrLocked when +// another process holds it. +func AcquireLock(path string) (*Lock, error) { + // #nosec G304 -- fixed lock path inside the protected state directory. + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, err + } + if err := lockFile(f); err != nil { + _ = f.Close() + if isLockHeld(err) { + return nil, ErrLocked + } + return nil, err + } + return &Lock{f: f}, nil +} + +// Release drops the lock. It is safe to call more than once. +func (l *Lock) Release() { + if l != nil && l.f != nil { + _ = l.f.Close() + l.f = nil + } +} diff --git a/internal/updatestate/lock_other.go b/internal/updatestate/lock_other.go new file mode 100644 index 00000000..b386fa0f --- /dev/null +++ b/internal/updatestate/lock_other.go @@ -0,0 +1,22 @@ +//go:build !windows + +package updatestate + +import ( + "errors" + "os" + + "golang.org/x/sys/unix" +) + +func lockFile(f *os.File) error { + fd := f.Fd() + // POSIX descriptors fit in a signed 32-bit int; reject anything else + // before converting os.File's platform-neutral uintptr. + if fd > 1<<31-1 { + return os.ErrInvalid + } + return unix.Flock(int(fd), unix.LOCK_EX|unix.LOCK_NB) +} + +func isLockHeld(err error) bool { return errors.Is(err, unix.EWOULDBLOCK) } diff --git a/internal/updatestate/lock_test.go b/internal/updatestate/lock_test.go new file mode 100644 index 00000000..de67ffd3 --- /dev/null +++ b/internal/updatestate/lock_test.go @@ -0,0 +1,71 @@ +package updatestate + +import ( + "bufio" + "errors" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestLockExclusiveNonBlocking(t *testing.T) { + path := filepath.Join(t.TempDir(), "update.lock") + a, err := AcquireLock(path) + if err != nil { + t.Fatal(err) + } + if _, err := AcquireLock(path); !errors.Is(err, ErrLocked) { + t.Fatalf("second AcquireLock = %v, want ErrLocked", err) + } + a.Release() + a.Release() + b, err := AcquireLock(path) + if err != nil { + t.Fatalf("AcquireLock after Release: %v", err) + } + b.Release() + if _, err := os.Stat(path); err != nil { + t.Fatalf("lock file must persist: %v", err) + } + if _, err := AcquireLock(filepath.Join(t.TempDir(), "missing", "update.lock")); err == nil || errors.Is(err, ErrLocked) { + t.Fatalf("AcquireLock in a missing dir = %v", err) + } +} + +func TestLockHelper(t *testing.T) { + path := os.Getenv("UPDATESTATE_LOCK_PATH") + if path == "" { + t.Skip("helper") + } + if _, err := AcquireLock(path); err != nil { + os.Exit(90) + } + os.Stdout.WriteString("locked\n") + _, _ = bufio.NewReader(os.Stdin).ReadString('\n') + os.Exit(137) // die holding the lock, no Release +} + +func TestLockReleasedOnProcessDeath(t *testing.T) { + path := filepath.Join(t.TempDir(), "update.lock") + cmd := exec.Command(os.Args[0], "-test.run=^TestLockHelper$") + cmd.Env = append(os.Environ(), "UPDATESTATE_LOCK_PATH="+path) + in, _ := cmd.StdinPipe() + out, _ := cmd.StdoutPipe() + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + if line, _ := bufio.NewReader(out).ReadString('\n'); line != "locked\n" { + t.Fatalf("helper said %q", line) + } + if _, err := AcquireLock(path); !errors.Is(err, ErrLocked) { + t.Fatalf("lock held by another process: %v, want ErrLocked", err) + } + _ = in.Close() + _ = cmd.Wait() + l, err := AcquireLock(path) + if err != nil { + t.Fatalf("lock not released by process death: %v", err) + } + l.Release() +} diff --git a/internal/updatestate/lock_windows.go b/internal/updatestate/lock_windows.go new file mode 100644 index 00000000..9b0d43b0 --- /dev/null +++ b/internal/updatestate/lock_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package updatestate + +import ( + "errors" + "os" + + "golang.org/x/sys/windows" +) + +func lockFile(f *os.File) error { + return windows.LockFileEx(windows.Handle(f.Fd()), + windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, &windows.Overlapped{}) +} + +func isLockHeld(err error) bool { return errors.Is(err, windows.ERROR_LOCK_VIOLATION) } diff --git a/internal/updatestate/open_other.go b/internal/updatestate/open_other.go new file mode 100644 index 00000000..92ac79d9 --- /dev/null +++ b/internal/updatestate/open_other.go @@ -0,0 +1,21 @@ +//go:build !windows + +package updatestate + +import ( + "errors" + "fmt" + "os" + "syscall" +) + +// openShared opens path read-only without following a final symlink and +// without blocking on a FIFO. +func openShared(path string) (*os.File, error) { + // #nosec G304 -- callers pass layout-derived paths. + f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) + if errors.Is(err, syscall.ELOOP) { + return nil, fmt.Errorf("%w: %w", errUnreadable, err) + } + return f, err +} diff --git a/internal/updatestate/open_windows.go b/internal/updatestate/open_windows.go new file mode 100644 index 00000000..4154d441 --- /dev/null +++ b/internal/updatestate/open_windows.go @@ -0,0 +1,27 @@ +//go:build windows + +package updatestate + +import ( + "os" + + "golang.org/x/sys/windows" +) + +// openShared opens path for reading with FILE_SHARE_DELETE, so a reader +// never makes a concurrent MoveFileEx replace fail. Reparse points are +// opened as themselves and then rejected as non-regular by the callers that +// care (readRegular). +func openShared(path string) (*os.File, error) { + p, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + h, err := windows.CreateFile(p, windows.GENERIC_READ, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT, 0) + if err != nil { + return nil, &os.PathError{Op: "open", Path: path, Err: err} + } + return os.NewFile(uintptr(h), path), nil +} diff --git a/internal/updatestate/runs.go b/internal/updatestate/runs.go new file mode 100644 index 00000000..cdaf1758 --- /dev/null +++ b/internal/updatestate/runs.go @@ -0,0 +1,153 @@ +package updatestate + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "time" +) + +// MaxRunRecords is how many run records a runs\.json keeps. +const MaxRunRecords = 10 + +// maxRunsSize bounds what ReadRuns reads from the user-writable file. +const maxRunsSize = 64 << 10 + +// RunRecord is one bootstrap launch of a version. The bootstrap appends it, +// the agent sets Started (with its execution_id) right after a successful +// run-status "started", and the bootstrap finalizes ExitCode, +// WatchdogKilled and At. A nil ExitCode means not finalized yet. +type RunRecord struct { + Version string `json:"version"` + ExecutionID string `json:"execution_id,omitempty"` + Started bool `json:"started"` + ExitCode *int `json:"exit_code,omitempty"` + WatchdogKilled bool `json:"watchdog_killed,omitempty"` + At time.Time `json:"at,omitzero"` +} + +type runFile struct { + Records []RunRecord `json:"records"` +} + +// ErrNoOpenRun means the run file has no unfinalized record to update. +var ErrNoOpenRun = errors.New("updatestate: no open run record") + +// ReadRuns returns the newest MaxRunRecords records, oldest first. The file +// is user-writable, so anything unreadable or malformed reads as no records +// and records with an invalid version are dropped; it never fails. +func ReadRuns(path string) []RunRecord { + data, err := readBounded(path, maxRunsSize) + if err != nil { + return nil + } + return decodeRuns(data) +} + +func decodeRuns(data []byte) []RunRecord { + var f runFile + if json.Unmarshal(data, &f) != nil { + return nil + } + out := f.Records[:0] + for _, r := range f.Records { + if validVersion(r.Version) { + out = append(out, r) + } + } + if len(out) > MaxRunRecords { + out = out[len(out)-MaxRunRecords:] + } + if len(out) == 0 { + return nil + } + return out +} + +func writeRuns(path string, recs []RunRecord) error { + if len(recs) > MaxRunRecords { + recs = recs[len(recs)-MaxRunRecords:] + } + data, err := json.Marshal(runFile{Records: recs}) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return writeAtomic(path, data, nil, nil) +} + +// AppendRun appends r, keeping the newest MaxRunRecords. +func AppendRun(path string, r RunRecord) error { + if !validVersion(r.Version) { + return errors.New("updatestate: run record needs a strict x.y.z version") + } + return writeRuns(path, append(ReadRuns(path), r)) +} + +// MarkRunStarted sets the started marker on the newest open record. +func MarkRunStarted(path, executionID string) error { + return updateOpenRun(path, func(r *RunRecord) { + r.Started = true + r.ExecutionID = executionID + }) +} + +// FinalizeRun closes the newest open record. +func FinalizeRun(path string, exitCode int, watchdogKilled bool, at time.Time) error { + return updateOpenRun(path, func(r *RunRecord) { + r.ExitCode = &exitCode + r.WatchdogKilled = watchdogKilled + r.At = at.UTC() + }) +} + +func updateOpenRun(path string, fn func(*RunRecord)) error { + recs := ReadRuns(path) + if len(recs) == 0 || ClassifyRun(recs[len(recs)-1]) != RunOpen { + return ErrNoOpenRun + } + fn(&recs[len(recs)-1]) + return writeRuns(path, recs) +} + +// RunVerdict is the health reading of one run record (spec §4). +type RunVerdict int + +const ( + // RunOpen: not finalized (still running, or the bootstrap died). + RunOpen RunVerdict = iota + // RunHealthy: the started marker is set, no panic exit, no watchdog kill. + RunHealthy + // RunBad: a watchdog kill, exit code 2 (Go panic), or an exit before the + // started marker with a crash-like code (anything but 0 or 1). + RunBad + // RunNeutral: exited 0 or 1 without the started marker. That is how the + // agent ends when the backend is unreachable or another scan holds the + // instance lock; spec §4 says environmental failures are not bad runs. + RunNeutral +) + +// panicExitCode is the exit status of an unrecovered Go panic (and of the +// agent's watchdog hard-out). +const panicExitCode = 2 + +// ClassifyRun applies the bad-run rule to one record. +func ClassifyRun(r RunRecord) RunVerdict { + switch { + case r.WatchdogKilled: + return RunBad + case r.ExitCode == nil: + return RunOpen + case *r.ExitCode == panicExitCode: + return RunBad + case r.Started: + return RunHealthy + case *r.ExitCode == 0 || *r.ExitCode == 1: + return RunNeutral + default: + return RunBad + } +} diff --git a/internal/updatestate/runs_test.go b/internal/updatestate/runs_test.go new file mode 100644 index 00000000..8e493f03 --- /dev/null +++ b/internal/updatestate/runs_test.go @@ -0,0 +1,127 @@ +package updatestate + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" +) + +func TestRunRecordsLifecycle(t *testing.T) { + path := filepath.Join(t.TempDir(), "runs", "1.19.0.json") + if recs := ReadRuns(path); recs != nil { + t.Fatalf("missing file = %v", recs) + } + if err := MarkRunStarted(path, "x"); err != ErrNoOpenRun { + t.Fatalf("MarkRunStarted on empty = %v", err) + } + if err := AppendRun(path, RunRecord{Version: "bad"}); err == nil { + t.Fatal("AppendRun accepted a bad version") + } + at := time.Date(2026, 10, 19, 1, 2, 3, 0, time.UTC) + for i := 0; i < 13; i++ { + if err := AppendRun(path, RunRecord{Version: "1.19.0"}); err != nil { + t.Fatal(err) + } + if i%2 == 0 { + if err := MarkRunStarted(path, fmt.Sprintf("exec-%d", i)); err != nil { + t.Fatal(err) + } + } + if err := FinalizeRun(path, i%3, false, at.Add(time.Duration(i)*time.Minute)); err != nil { + t.Fatal(err) + } + } + if err := FinalizeRun(path, 0, false, at); err != ErrNoOpenRun { + t.Fatalf("FinalizeRun with no open record = %v", err) + } + recs := ReadRuns(path) + if len(recs) != MaxRunRecords { + t.Fatalf("kept %d records, want %d", len(recs), MaxRunRecords) + } + first, last := recs[0], recs[len(recs)-1] + if !first.At.Equal(at.Add(3*time.Minute)) || !last.At.Equal(at.Add(12*time.Minute)) { + t.Fatalf("kept the wrong window: %v .. %v", first.At, last.At) + } + if !last.Started || last.ExecutionID != "exec-12" || *last.ExitCode != 0 { + t.Fatalf("last record %+v", last) + } + if err := AppendRun(path, RunRecord{Version: "1.19.0"}); err != nil { + t.Fatal(err) + } + if recs := ReadRuns(path); ClassifyRun(recs[len(recs)-1]) != RunOpen { + t.Fatal("new record is not open") + } +} + +func TestReadRunsToleratesGarbage(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "1.19.0.json") + for _, body := range []string{"", "garbage", "[]", `{"records":"x"}`, `{"records":[{"version":1}]}`, + `{"records":[{"version":"1.19.0","exit_code":"x"}]}`, `{"records":[{"version":"../x"}]}`} { + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + if recs := ReadRuns(path); recs != nil { + t.Errorf("ReadRuns(%q) = %+v, want none", body, recs) + } + } + // Garbage is replaced, not appended to. + if err := AppendRun(path, RunRecord{Version: "1.19.0"}); err != nil { + t.Fatal(err) + } + if recs := ReadRuns(path); len(recs) != 1 { + t.Fatalf("after append over garbage: %+v", recs) + } + // Records with bad versions are dropped individually. + body := `{"records":[{"version":"1.19.0","started":true,"exit_code":0},{"version":"x"}]}` + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + if recs := ReadRuns(path); len(recs) != 1 || !recs[0].Started { + t.Fatalf("mixed file: %+v", recs) + } + big := make([]byte, maxRunsSize+1) + if err := os.WriteFile(path, big, 0o600); err != nil { + t.Fatal(err) + } + if recs := ReadRuns(path); recs != nil { + t.Fatalf("oversize file read as %d records", len(recs)) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatal(err) + } + if recs := ReadRuns(path); recs != nil { + t.Fatal("directory read as records") + } +} + +func TestClassifyRun(t *testing.T) { + code := func(c int) *int { return &c } + for _, tc := range []struct { + name string + r RunRecord + want RunVerdict + }{ + {"open", RunRecord{}, RunOpen}, + {"open started", RunRecord{Started: true}, RunOpen}, + {"watchdog while open", RunRecord{WatchdogKilled: true}, RunBad}, + {"healthy", RunRecord{Started: true, ExitCode: code(0)}, RunHealthy}, + {"healthy error exit", RunRecord{Started: true, ExitCode: code(1)}, RunHealthy}, + {"panic after start", RunRecord{Started: true, ExitCode: code(2)}, RunBad}, + {"watchdog after start", RunRecord{Started: true, ExitCode: code(0), WatchdogKilled: true}, RunBad}, + {"panic before start", RunRecord{ExitCode: code(2)}, RunBad}, + {"crash before start", RunRecord{ExitCode: code(-1073741819)}, RunBad}, + {"crash code before start", RunRecord{ExitCode: code(3221225477)}, RunBad}, + {"offline or lock held", RunRecord{ExitCode: code(1)}, RunNeutral}, + {"clean exit without marker", RunRecord{ExitCode: code(0)}, RunNeutral}, + } { + if got := ClassifyRun(tc.r); got != tc.want { + t.Errorf("%s: %v, want %v", tc.name, got, tc.want) + } + } +} diff --git a/internal/updatestate/state.go b/internal/updatestate/state.go new file mode 100644 index 00000000..85448200 --- /dev/null +++ b/internal/updatestate/state.go @@ -0,0 +1,182 @@ +package updatestate + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "time" + + "github.com/step-security/dev-machine-guard/internal/faultinject" +) + +// SchemaVersion is the only state.json schema this package reads or writes. +const SchemaVersion = 1 + +// maxStateSize bounds what Load reads; anything larger is corrupt. +const maxStateSize = 1 << 20 + +// State is state.json (schema 1, spec "Contracts"). Version fields hold +// strict x.y.z strings; Load guarantees they parse. +type State struct { + Schema int `json:"schema"` + Current string `json:"current"` + Previous string `json:"previous"` + Probation *Probation `json:"probation"` + Quarantine map[string]Quarantine `json:"quarantine"` + Updater Updater `json:"updater"` + SwitchDeferrals int `json:"switch_deferrals"` + Dedupe map[string]time.Time `json:"dedupe"` +} + +// Probation tracks a newly switched-to version until it is confirmed or +// rolled back. OK and Bad count runs (Bad counts consecutive bad runs). +type Probation struct { + Version string `json:"version"` + Since time.Time `json:"since"` + OK int `json:"ok"` + Bad int `json:"bad"` + SmokeOK bool `json:"smoke_ok"` +} + +type Quarantine struct { + Reason string `json:"reason"` + At time.Time `json:"at"` +} + +// Updater records which version last ran the updater and how often in a +// row it failed, for the bootstrap's updater selection. +type Updater struct { + Version string `json:"version"` + ConsecutiveFailures int `json:"consecutive_failures"` +} + +// Validate reports why s is not a well-formed schema-1 state. Current is +// required; the other version fields are optional but strict when set. +func (s *State) Validate() error { + switch { + case s.Schema != SchemaVersion: + return fmt.Errorf("unsupported schema %d", s.Schema) + case !validVersion(s.Current): + return fmt.Errorf("invalid current %q", s.Current) + case s.Previous != "" && !validVersion(s.Previous): + return fmt.Errorf("invalid previous %q", s.Previous) + case s.Updater.Version != "" && !validVersion(s.Updater.Version): + return fmt.Errorf("invalid updater version %q", s.Updater.Version) + case s.SwitchDeferrals < 0 || s.Updater.ConsecutiveFailures < 0: + return errors.New("negative counter") + } + if p := s.Probation; p != nil { + if !validVersion(p.Version) { + return fmt.Errorf("invalid probation version %q", p.Version) + } + if p.OK < 0 || p.Bad < 0 { + return errors.New("negative probation counter") + } + } + for v := range s.Quarantine { + if !validVersion(v) { + return fmt.Errorf("invalid quarantined version %q", v) + } + } + return nil +} + +// decodeState parses and validates state.json. Unknown fields are ignored +// so an older agent can still read a newer schema-1 file after a rollback. +func decodeState(data []byte) (*State, error) { + var s State + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + if err := s.Validate(); err != nil { + return nil, err + } + return &s, nil +} + +// Load reads state.json. A missing file returns (nil, false, nil). An +// unreadable-as-state file (bad JSON, unknown schema, invalid fields, +// oversize) returns (nil, true, nil): callers treat it as missing and report +// update_state_corrupt. err is set only for I/O failures other than absence. +func Load(path string) (st *State, corrupt bool, err error) { + data, err := readBounded(path, maxStateSize) + switch { + case errors.Is(err, os.ErrNotExist): + return nil, false, nil + case errors.Is(err, errUnreadable): + return nil, true, nil + case err != nil: + return nil, false, err + } + s, derr := decodeState(data) + if derr != nil { + return nil, true, nil + } + return s, false, nil +} + +// errUnreadable marks content that can never be valid: oversize or not a +// regular file. +var errUnreadable = errors.New("updatestate: not a bounded regular file") + +func readBounded(path string, limit int64) ([]byte, error) { + f, err := openShared(path) + if err != nil { + return nil, err + } + defer f.Close() + fi, err := f.Stat() + if err != nil { + return nil, err + } + if !fi.Mode().IsRegular() { + return nil, errUnreadable + } + data, err := io.ReadAll(io.LimitReader(f, limit+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > limit { + return nil, errUnreadable + } + return data, nil +} + +// checkpoint is swapped by tests that kill a subprocess mid-Save. +var checkpoint = faultinject.Point + +// Save atomically replaces state.json with st (schema set to 1). It refuses +// a state that Load would reject. After a crash at any point, Load returns +// either the old or the new state. +func Save(path string, st *State) error { return SaveCheckpointed(path, st, "") } + +// SaveCheckpointed is Save with fault checkpoints .state-temp (temp +// file durable, target untouched) and .state-renamed (target +// replaced), e.g. prefix "S6", "rollback" or "adopt". An empty prefix +// disables them. An injected error at state-renamed is returned although the +// new state is already in place, as a late write error would be. +func SaveCheckpointed(path string, st *State, prefix string) error { + out := *st + out.Schema = SchemaVersion + if out.Quarantine == nil { + out.Quarantine = map[string]Quarantine{} + } + if out.Dedupe == nil { + out.Dedupe = map[string]time.Time{} + } + if err := out.Validate(); err != nil { + return fmt.Errorf("updatestate: refusing to save invalid state: %w", err) + } + data, err := json.MarshalIndent(&out, "", " ") + if err != nil { + return err + } + var afterTemp, afterRename func() error + if prefix != "" { + afterTemp = func() error { return checkpoint(prefix + ".state-temp") } + afterRename = func() error { return checkpoint(prefix + ".state-renamed") } + } + return writeAtomic(path, append(data, '\n'), afterTemp, afterRename) +} diff --git a/internal/updatestate/state_test.go b/internal/updatestate/state_test.go new file mode 100644 index 00000000..08f7f358 --- /dev/null +++ b/internal/updatestate/state_test.go @@ -0,0 +1,221 @@ +package updatestate + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + "time" +) + +func sampleState() *State { + at := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC) + return &State{ + Current: "1.19.0", + Previous: "1.18.2", + Probation: &Probation{Version: "1.19.0", Since: at, OK: 1, SmokeOK: true}, + Quarantine: map[string]Quarantine{ + "1.18.9": {Reason: "health_failed", At: at}, + }, + Updater: Updater{Version: "1.18.2", ConsecutiveFailures: 1}, + SwitchDeferrals: 2, + Dedupe: map[string]time.Time{"update_check_failed:1.19.1": at}, + } +} + +func TestSaveLoadRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + want := sampleState() + if err := Save(path, want); err != nil { + t.Fatal(err) + } + got, corrupt, err := Load(path) + if err != nil || corrupt { + t.Fatalf("Load: corrupt=%v err=%v", corrupt, err) + } + want.Schema = SchemaVersion + if !reflect.DeepEqual(got, want) { + t.Fatalf("round trip:\n got %+v\nwant %+v", got, want) + } + // Minimal state: optional fields empty, maps normalized to {}. + if err := Save(path, &State{Current: "1.0.0"}); err != nil { + t.Fatal(err) + } + raw, _ := os.ReadFile(path) + for _, frag := range []string{`"schema": 1`, `"probation": null`, `"quarantine": {}`, `"dedupe": {}`} { + if !strings.Contains(string(raw), frag) { + t.Errorf("saved state lacks %s:\n%s", frag, raw) + } + } + SweepTemps(path) + entries, _ := os.ReadDir(filepath.Dir(path)) + if len(entries) != 1 { + t.Fatalf("leftover files after Save: %v", entries) + } +} + +func TestLoadMissing(t *testing.T) { + st, corrupt, err := Load(filepath.Join(t.TempDir(), "state.json")) + if st != nil || corrupt || err != nil { + t.Fatalf("Load(missing) = %v, %v, %v", st, corrupt, err) + } +} + +func TestLoadCorrupt(t *testing.T) { + cases := map[string]string{ + "empty": ``, + "garbage": "\x00\xffnot json", + "truncated": `{"schema":1,"current":"1.2.3"`, + "array": `[]`, + "null": `null`, + "schema 0": `{"current":"1.2.3"}`, + "schema 2": `{"schema":2,"current":"1.2.3"}`, + "no current": `{"schema":1}`, + "bad current": `{"schema":1,"current":"..\\..\\evil"}`, + "prerelease": `{"schema":1,"current":"1.2.3-rc.1"}`, + "bad previous": `{"schema":1,"current":"1.2.3","previous":"x"}`, + "bad probation": `{"schema":1,"current":"1.2.3","probation":{"version":""}}`, + "neg probation": `{"schema":1,"current":"1.2.3","probation":{"version":"1.2.3","bad":-1}}`, + "bad quarantine": `{"schema":1,"current":"1.2.3","quarantine":{"../x":{}}}`, + "bad updater": `{"schema":1,"current":"1.2.3","updater":{"version":"v1"}}`, + "neg deferrals": `{"schema":1,"current":"1.2.3","switch_deferrals":-1}`, + "bad time": `{"schema":1,"current":"1.2.3","dedupe":{"k":"yesterday"}}`, + "wrong type": `{"schema":"1","current":"1.2.3"}`, + "trailing garbage": `{"schema":1,"current":"1.2.3"} x`, + "oversize": `{"schema":1,"current":"1.2.3","pad":"` + strings.Repeat("a", maxStateSize) + `"}`, + } + for name, body := range cases { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + st, corrupt, err := Load(path) + if st != nil || !corrupt || err != nil { + t.Fatalf("Load = %+v, corrupt=%v, err=%v; want corrupt", st, corrupt, err) + } + }) + } + t.Run("directory", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatal(err) + } + if st, corrupt, err := Load(path); st != nil || (!corrupt && err == nil) { + t.Fatalf("Load(dir) = %v, %v, %v", st, corrupt, err) + } + }) + if runtime.GOOS != "windows" { + t.Run("symlink", func(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "real.json") + if err := Save(target, sampleState()); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "state.json") + if err := os.Symlink(target, path); err != nil { + t.Fatal(err) + } + if st, corrupt, err := Load(path); st != nil || !corrupt || err != nil { + t.Fatalf("Load(symlink) = %v, %v, %v", st, corrupt, err) + } + }) + } +} + +func TestLoadIgnoresUnknownFields(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + body := `{"schema":1,"current":"1.2.3","future_field":{"x":1},"probation":null}` + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + st, corrupt, err := Load(path) + if err != nil || corrupt || st.Current != "1.2.3" { + t.Fatalf("Load = %+v, %v, %v", st, corrupt, err) + } +} + +func TestSaveRefusesInvalidState(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + for _, st := range []*State{{}, {Current: "1.2"}, {Current: "1.2.3", Previous: "x"}} { + if err := Save(path, st); err == nil { + t.Errorf("Save(%+v) succeeded", st) + } + } + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatal("invalid Save wrote a file") + } +} + +func stubCheckpoint(t *testing.T, fn func(string) error) { + t.Helper() + old := checkpoint + checkpoint = fn + t.Cleanup(func() { checkpoint = old }) +} + +func TestSaveCheckpointErrors(t *testing.T) { + injected := errors.New("injected") + var seen []string + stubCheckpoint(t, func(name string) error { + seen = append(seen, name) + if name == os.Getenv("TEST_FAIL_AT") { + return injected + } + return nil + }) + path := filepath.Join(t.TempDir(), "state.json") + old := &State{Current: "1.18.2"} + if err := Save(path, old); err != nil { + t.Fatal(err) + } + if len(seen) != 0 { + t.Fatalf("Save without prefix hit checkpoints %v", seen) + } + newer := &State{Current: "1.19.0", Previous: "1.18.2"} + + t.Setenv("TEST_FAIL_AT", "S6.state-temp") + if err := SaveCheckpointed(path, newer, "S6"); !errors.Is(err, injected) { + t.Fatalf("err at state-temp = %v", err) + } + if st, _, _ := Load(path); st.Current != "1.18.2" { + t.Fatalf("state-temp failure changed state to %q", st.Current) + } + if entries, _ := os.ReadDir(filepath.Dir(path)); len(entries) != 1 { + t.Fatalf("temp file leaked: %v", entries) + } + + t.Setenv("TEST_FAIL_AT", "rollback.state-renamed") + if err := SaveCheckpointed(path, newer, "rollback"); !errors.Is(err, injected) { + t.Fatalf("err at state-renamed = %v", err) + } + if st, _, _ := Load(path); st.Current != "1.19.0" { + t.Fatalf("state-renamed: state is %q, want new", st.Current) + } + want := []string{"S6.state-temp", "rollback.state-temp", "rollback.state-renamed"} + if !reflect.DeepEqual(seen, want) { + t.Fatalf("checkpoints %v, want %v", seen, want) + } +} + +func TestSweepTemps(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "state.json") + for _, n := range []string{".state.json.tmp-123", ".state.json.tmp-abc", "state.json", "other.tmp-1", ".runs.json.tmp-1"} { + if err := os.WriteFile(filepath.Join(dir, n), nil, 0o600); err != nil { + t.Fatal(err) + } + } + SweepTemps(path) + entries, _ := os.ReadDir(dir) + var names []string + for _, e := range entries { + names = append(names, e.Name()) + } + if want := []string{".runs.json.tmp-1", "other.tmp-1", "state.json"}; !reflect.DeepEqual(names, want) { + t.Fatalf("after sweep %v, want %v", names, want) + } +} diff --git a/internal/updatestate/version.go b/internal/updatestate/version.go new file mode 100644 index 00000000..54224b80 --- /dev/null +++ b/internal/updatestate/version.go @@ -0,0 +1,52 @@ +package updatestate + +import ( + "fmt" + "strconv" + "strings" +) + +// Version is a strict x.y.z release version. Prereleases and build metadata +// are refused (spec §6). +type Version struct{ Major, Minor, Patch uint32 } + +// ParseVersion accepts only x.y.z with decimal components, no sign, no "v" +// prefix and no leading zeros. +func ParseVersion(s string) (Version, error) { + parts := strings.Split(s, ".") + if len(parts) != 3 { + return Version{}, fmt.Errorf("version %q: want x.y.z", s) + } + var n [3]uint32 + for i, p := range parts { + if p == "" || len(p) > 10 || (len(p) > 1 && p[0] == '0') || strings.TrimLeft(p, "0123456789") != "" { + return Version{}, fmt.Errorf("version %q: invalid component %q", s, p) + } + u, err := strconv.ParseUint(p, 10, 32) + if err != nil { + return Version{}, fmt.Errorf("version %q: %w", s, err) + } + n[i] = uint32(u) + } + return Version{n[0], n[1], n[2]}, nil +} + +func (v Version) String() string { return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) } + +// Compare returns -1, 0 or +1 as a is older than, equal to or newer than b. +func Compare(a, b Version) int { + for _, d := range [3][2]uint32{{a.Major, b.Major}, {a.Minor, b.Minor}, {a.Patch, b.Patch}} { + if d[0] != d[1] { + if d[0] < d[1] { + return -1 + } + return 1 + } + } + return 0 +} + +func validVersion(s string) bool { + _, err := ParseVersion(s) + return err == nil +} diff --git a/internal/updatestate/version_test.go b/internal/updatestate/version_test.go new file mode 100644 index 00000000..9d3e804f --- /dev/null +++ b/internal/updatestate/version_test.go @@ -0,0 +1,45 @@ +package updatestate + +import "testing" + +func TestParseVersion(t *testing.T) { + good := map[string]Version{ + "0.0.0": {}, + "1.19.0": {1, 19, 0}, + "10.200.3000": {10, 200, 3000}, + "4294967295.0.1": {4294967295, 0, 1}, + "1.0.4294967295": {1, 0, 4294967295}, + } + for s, want := range good { + got, err := ParseVersion(s) + if err != nil || got != want || got.String() != s { + t.Errorf("ParseVersion(%q) = %v, %v", s, got, err) + } + } + for _, s := range []string{"", "1", "1.2", "1.2.3.4", "v1.2.3", "1.2.3-rc.1", "1.2.3+b", "01.2.3", + "1.02.3", "1.2.03", "-1.2.3", "+1.2.3", "1..3", "1.2.", " 1.2.3", "1.2.3 ", "1.2.x", + "4294967296.0.0", "99999999999.0.0", "1.2.3\n", "1.2.3", "1/2.3"} { + if v, err := ParseVersion(s); err == nil { + t.Errorf("ParseVersion(%q) = %v, want error", s, v) + } + } +} + +func TestCompare(t *testing.T) { + order := []string{"0.0.0", "0.0.1", "0.1.0", "0.10.0", "1.0.0", "1.9.9", "1.10.0", "1.18.2", "1.19.0", "2.0.0"} + for i, a := range order { + for j, b := range order { + va, _ := ParseVersion(a) + vb, _ := ParseVersion(b) + want := 0 + if i < j { + want = -1 + } else if i > j { + want = 1 + } + if got := Compare(va, vb); got != want { + t.Errorf("Compare(%s, %s) = %d, want %d", a, b, got, want) + } + } + } +} diff --git a/tests/test_fuzz_go.sh b/tests/test_fuzz_go.sh index ab95d9ab..811f020d 100644 --- a/tests/test_fuzz_go.sh +++ b/tests/test_fuzz_go.sh @@ -10,7 +10,7 @@ mkdir -p "$FUZZ_LOG_DIR" if [ "$#" -eq 0 ]; then set -- ./internal/detector/rules ./internal/detector ./internal/detector/configaudit \ - ./internal/state ./internal/detector/credentials + ./internal/state ./internal/detector/credentials ./internal/updatestate fi status=0 From 0bdc787d11c06c39ff8f07ae48cb583ea16fb62b Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Mon, 19 Oct 2026 17:55:53 +0530 Subject: [PATCH 03/34] feat(updatestate): add EnsureProtectedDir for versions and state ACLs Windows: create the directory with, or re-assert, owner SYSTEM and a protected DACL (SYSTEM F, Administrators F, Users RX, inherited by children) via native security APIs, reporting whether drift was fixed. Rejects files and reparse points. Other platforms only create the dir. --- internal/updatestate/acl.go | 22 +++ internal/updatestate/acl_other.go | 18 +++ internal/updatestate/acl_other_test.go | 34 +++++ internal/updatestate/acl_windows.go | 175 +++++++++++++++++++++++ internal/updatestate/acl_windows_test.go | 131 +++++++++++++++++ 5 files changed, 380 insertions(+) create mode 100644 internal/updatestate/acl.go create mode 100644 internal/updatestate/acl_other.go create mode 100644 internal/updatestate/acl_other_test.go create mode 100644 internal/updatestate/acl_windows.go create mode 100644 internal/updatestate/acl_windows_test.go diff --git a/internal/updatestate/acl.go b/internal/updatestate/acl.go new file mode 100644 index 00000000..f12320a0 --- /dev/null +++ b/internal/updatestate/acl.go @@ -0,0 +1,22 @@ +package updatestate + +import ( + "fmt" + "os" +) + +// checkDirNotReparse rejects anything at path that is not a plain directory +// (files, symlinks, junctions), so ACL work never follows a redirect. +func checkDirNotReparse(path string) (exists bool, err error) { + fi, err := os.Lstat(path) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, err + } + if !fi.IsDir() || fi.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 { + return true, fmt.Errorf("updatestate: %s is not a plain directory", path) + } + return true, nil +} diff --git a/internal/updatestate/acl_other.go b/internal/updatestate/acl_other.go new file mode 100644 index 00000000..3c90de1f --- /dev/null +++ b/internal/updatestate/acl_other.go @@ -0,0 +1,18 @@ +//go:build !windows + +package updatestate + +import "os" + +// EnsureProtectedDir creates path if missing. Off Windows there is no +// protected-DACL model to enforce, so it never reports drift. +func EnsureProtectedDir(path string) (fixed bool, err error) { + exists, err := checkDirNotReparse(path) + if err != nil || exists { + return false, err + } + if err := os.Mkdir(path, 0o755); err != nil && !os.IsExist(err) { + return false, err + } + return false, nil +} diff --git a/internal/updatestate/acl_other_test.go b/internal/updatestate/acl_other_test.go new file mode 100644 index 00000000..57bfb0e7 --- /dev/null +++ b/internal/updatestate/acl_other_test.go @@ -0,0 +1,34 @@ +//go:build !windows + +package updatestate + +import ( + "os" + "path/filepath" + "testing" +) + +func TestEnsureProtectedDirStub(t *testing.T) { + dir := filepath.Join(t.TempDir(), "versions") + for i := 0; i < 2; i++ { + if fixed, err := EnsureProtectedDir(dir); fixed || err != nil { + t.Fatalf("EnsureProtectedDir = %v, %v", fixed, err) + } + } + if fi, err := os.Stat(dir); err != nil || !fi.IsDir() { + t.Fatalf("dir not created: %v", err) + } + file := filepath.Join(t.TempDir(), "f") + if err := os.WriteFile(file, nil, 0o600); err != nil { + t.Fatal(err) + } + link := filepath.Join(t.TempDir(), "l") + if err := os.Symlink(dir, link); err != nil { + t.Fatal(err) + } + for _, p := range []string{file, link} { + if _, err := EnsureProtectedDir(p); err == nil { + t.Errorf("EnsureProtectedDir(%s) accepted a non-directory", p) + } + } +} diff --git a/internal/updatestate/acl_windows.go b/internal/updatestate/acl_windows.go new file mode 100644 index 00000000..0a296b07 --- /dev/null +++ b/internal/updatestate/acl_windows.go @@ -0,0 +1,175 @@ +//go:build windows + +package updatestate + +import ( + "errors" + "fmt" + "runtime" + "unsafe" + + "golang.org/x/sys/windows" +) + +// protectedDirSDDL: owner SYSTEM; protected DACL (no inheritance from the +// parent) granting SYSTEM and Administrators full control and Users +// read+execute, each inherited by subdirectories and files. +const protectedDirSDDL = "O:SYD:PAI(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;0x1200a9;;;BU)" + +const ( + fileAllAccess windows.ACCESS_MASK = 0x001F01FF // FA + fileReadExecute windows.ACCESS_MASK = 0x001200A9 // RX + inheritOICI = windows.OBJECT_INHERIT_ACE | windows.CONTAINER_INHERIT_ACE +) + +// EnsureProtectedDir creates path if missing and makes its owner SYSTEM and +// its DACL protected: SYSTEM F, Administrators F, Users RX, inherited by +// children. It reports whether an existing directory had drifted and was +// fixed; creating the directory is not drift. Re-applying the DACL +// propagates it to children that inherit. Needs SYSTEM or an elevated +// administrator (setting another principal as owner takes +// SeRestorePrivilege, which is enabled for the call). The parent must exist +// and is trusted not to be writable by non-admins. +func EnsureProtectedDir(path string) (fixed bool, err error) { + err = withOwnerPrivileges(func() error { + exists, err := checkDirNotReparse(path) + if err != nil { + return err + } + if !exists { + err = createProtectedDir(path) + if !errors.Is(err, windows.ERROR_ALREADY_EXISTS) { + return err + } + if _, err = checkDirNotReparse(path); err != nil { + return err + } + } + ok, err := hasProtectedACL(path) + if err != nil || ok { + return err + } + fixed = true + return applyProtectedACL(path) + }) + return fixed, err +} + +func createProtectedDir(path string) error { + sd, err := windows.SecurityDescriptorFromString(protectedDirSDDL) + if err != nil { + return err + } + p, err := windows.UTF16PtrFromString(path) + if err != nil { + return err + } + sa := windows.SecurityAttributes{SecurityDescriptor: sd} + sa.Length = uint32(unsafe.Sizeof(sa)) + return windows.CreateDirectory(p, &sa) +} + +func applyProtectedACL(path string) error { + sd, err := windows.SecurityDescriptorFromString(protectedDirSDDL) + if err != nil { + return err + } + owner, _, err := sd.Owner() + if err != nil { + return err + } + dacl, _, err := sd.DACL() + if err != nil { + return err + } + if err := windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, + windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, + owner, nil, dacl, nil); err != nil { + return fmt.Errorf("updatestate: set ACL on %s: %w", path, err) + } + return nil +} + +// hasProtectedACL reports whether path has exactly the wanted owner and +// protected DACL. +func hasProtectedACL(path string) (bool, error) { + sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, + windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION) + if err != nil { + return false, fmt.Errorf("updatestate: read ACL of %s: %w", path, err) + } + owner, _, err := sd.Owner() + if err != nil || owner == nil || !owner.IsWellKnown(windows.WinLocalSystemSid) { + return false, nil + } + control, _, err := sd.Control() + if err != nil || control&windows.SE_DACL_PROTECTED == 0 { + return false, nil + } + acl, _, err := sd.DACL() + if err != nil || acl == nil || acl.AceCount != 3 { + return false, nil + } + want := map[windows.WELL_KNOWN_SID_TYPE]windows.ACCESS_MASK{ + windows.WinLocalSystemSid: fileAllAccess, + windows.WinBuiltinAdministratorsSid: fileAllAccess, + windows.WinBuiltinUsersSid: fileReadExecute, + } + for i := uint32(0); i < uint32(acl.AceCount); i++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(acl, i, &ace); err != nil { + return false, nil + } + if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE || ace.Header.AceFlags != inheritOICI { + return false, nil + } + sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + matched := false + for kind, mask := range want { + if sid.IsWellKnown(kind) && ace.Mask == mask { + delete(want, kind) + matched = true + break + } + } + if !matched { + return false, nil + } + } + return len(want) == 0, nil +} + +// withOwnerPrivileges runs fn on a dedicated OS thread impersonating the +// process token with SeRestorePrivilege and SeTakeOwnershipPrivilege enabled +// where held. The thread is never unlocked, so the runtime discards it (and +// any impersonation) when the goroutine exits. +func withOwnerPrivileges(fn func() error) error { + done := make(chan error, 1) + go func() { + runtime.LockOSThread() + if err := windows.ImpersonateSelf(windows.SecurityImpersonation); err == nil { + defer func() { _ = windows.RevertToSelf() }() + enableThreadPrivileges("SeRestorePrivilege", "SeTakeOwnershipPrivilege") + } + done <- fn() + }() + return <-done +} + +func enableThreadPrivileges(names ...string) { + var tok windows.Token + if windows.OpenThreadToken(windows.CurrentThread(), windows.TOKEN_ADJUST_PRIVILEGES|windows.TOKEN_QUERY, false, &tok) != nil { + return + } + defer tok.Close() + for _, name := range names { + var luid windows.LUID + p, err := windows.UTF16PtrFromString(name) + if err != nil || windows.LookupPrivilegeValue(nil, p, &luid) != nil { + continue + } + tp := windows.Tokenprivileges{PrivilegeCount: 1} + tp.Privileges[0] = windows.LUIDAndAttributes{Luid: luid, Attributes: windows.SE_PRIVILEGE_ENABLED} + _ = windows.AdjustTokenPrivileges(tok, false, &tp, 0, nil, nil) + } +} diff --git a/internal/updatestate/acl_windows_test.go b/internal/updatestate/acl_windows_test.go new file mode 100644 index 00000000..452430b6 --- /dev/null +++ b/internal/updatestate/acl_windows_test.go @@ -0,0 +1,131 @@ +//go:build windows + +package updatestate + +import ( + "os" + "path/filepath" + "testing" + + "golang.org/x/sys/windows" +) + +func requireElevated(t *testing.T) { + t.Helper() + if !windows.GetCurrentProcessToken().IsElevated() { + t.Skip("needs an elevated (administrator) process") + } +} + +func TestEnsureProtectedDir(t *testing.T) { + requireElevated(t) + dir := filepath.Join(t.TempDir(), "versions") + + fixed, err := EnsureProtectedDir(dir) + if err != nil || fixed { + t.Fatalf("create: fixed=%v err=%v", fixed, err) + } + if ok, err := hasProtectedACL(dir); !ok || err != nil { + t.Fatalf("new dir lacks the protected ACL: %v", err) + } + if fixed, err = EnsureProtectedDir(dir); err != nil || fixed { + t.Fatalf("no drift: fixed=%v err=%v", fixed, err) + } + + // Children inherit the three ACEs. + child := filepath.Join(dir, "1.19.0") + if err := os.Mkdir(child, 0o755); err != nil { + t.Fatal(err) + } + if n := inheritedAces(t, child); n != 3 { + t.Fatalf("child has %d inherited ACEs, want 3", n) + } + + everyone, _ := windows.CreateWellKnownSid(windows.WinWorldSid) + admins, _ := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + drifts := map[string]func(){ + "unprotected": func() { + setSecurity(t, dir, windows.DACL_SECURITY_INFORMATION|windows.UNPROTECTED_DACL_SECURITY_INFORMATION, nil, + "D:(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;0x1200a9;;;BU)") + }, + "extra ace": func() { + setSecurity(t, dir, windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, nil, + "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;0x1200a9;;;BU)(A;OICI;FA;;;"+everyone.String()+")") + }, + "users modify": func() { + setSecurity(t, dir, windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, nil, + "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;0x1301bf;;;BU)") + }, + "owner": func() { + setSecurity(t, dir, windows.OWNER_SECURITY_INFORMATION, admins, "") + }, + } + for name, drift := range drifts { + t.Run(name, func(t *testing.T) { + drift() + if ok, _ := hasProtectedACL(dir); ok { + t.Fatal("drift not detected") + } + if fixed, err := EnsureProtectedDir(dir); err != nil || !fixed { + t.Fatalf("fix: fixed=%v err=%v", fixed, err) + } + if ok, err := hasProtectedACL(dir); !ok || err != nil { + t.Fatalf("not fixed: %v", err) + } + if fixed, err := EnsureProtectedDir(dir); err != nil || fixed { + t.Fatalf("after fix: fixed=%v err=%v", fixed, err) + } + }) + } + + file := filepath.Join(t.TempDir(), "f") + if err := os.WriteFile(file, nil, 0o600); err != nil { + t.Fatal(err) + } + if _, err := EnsureProtectedDir(file); err == nil { + t.Fatal("accepted a file") + } +} + +func setSecurity(t *testing.T, path string, info windows.SECURITY_INFORMATION, owner *windows.SID, sddl string) { + t.Helper() + var dacl *windows.ACL + if sddl != "" { + sd, err := windows.SecurityDescriptorFromString(sddl) + if err != nil { + t.Fatal(err) + } + if dacl, _, err = sd.DACL(); err != nil { + t.Fatal(err) + } + } + err := withOwnerPrivileges(func() error { + return windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, info, owner, nil, dacl, nil) + }) + if err != nil { + t.Fatalf("set drift: %v", err) + } +} + +func inheritedAces(t *testing.T, path string) int { + t.Helper() + sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + t.Fatal(err) + } + acl, _, err := sd.DACL() + if err != nil { + t.Fatal(err) + } + n := 0 + for i := uint32(0); i < uint32(acl.AceCount); i++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(acl, i, &ace); err != nil { + t.Fatal(err) + } + if ace.Header.AceFlags&windows.INHERITED_ACE != 0 { + n++ + } + } + return n +} From 967163d0f68903574dd8cf7c2a7aabe73dd8e6ca Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Mon, 19 Oct 2026 18:08:14 +0530 Subject: [PATCH 04/34] feat(selfupdate): add Windows latest-binary client, verified downloads and version manifest Port the latest-binary client, GitHub-then-mirror download and SSHSIG verifier from #217 for the MSI updater: native arch via IsWow64Process2, env then WinHTTP machine proxy, strict x.y.z targets, signatures checked before download and sha256 after. Version dirs get a manifest so staged versions can be re-verified. --- .github/workflows/tests.yml | 7 +- internal/selfupdate/arch.go | 6 + internal/selfupdate/arch_other.go | 7 + internal/selfupdate/arch_windows.go | 34 +++ internal/selfupdate/arch_windows_test.go | 19 ++ internal/selfupdate/fakeapi_test.go | 186 +++++++++++++ internal/selfupdate/knobs.go | 29 ++ internal/selfupdate/proxy.go | 125 +++++++++ internal/selfupdate/proxy_other.go | 5 + internal/selfupdate/proxy_windows.go | 44 +++ internal/selfupdate/release.go | 333 +++++++++++++++++++++++ internal/selfupdate/release_test.go | 286 +++++++++++++++++++ internal/selfupdate/sshsig.go | 175 ++++++++++++ internal/selfupdate/sshsig_sign_test.go | 68 +++++ internal/selfupdate/sshsig_test.go | 93 +++++++ internal/selfupdate/testkeys_off.go | 13 + internal/selfupdate/testkeys_on.go | 49 ++++ internal/updatestate/manifest.go | 120 ++++++++ internal/updatestate/manifest_test.go | 58 ++++ 19 files changed, 1654 insertions(+), 3 deletions(-) create mode 100644 internal/selfupdate/arch.go create mode 100644 internal/selfupdate/arch_other.go create mode 100644 internal/selfupdate/arch_windows.go create mode 100644 internal/selfupdate/arch_windows_test.go create mode 100644 internal/selfupdate/fakeapi_test.go create mode 100644 internal/selfupdate/knobs.go create mode 100644 internal/selfupdate/proxy.go create mode 100644 internal/selfupdate/proxy_other.go create mode 100644 internal/selfupdate/proxy_windows.go create mode 100644 internal/selfupdate/release.go create mode 100644 internal/selfupdate/release_test.go create mode 100644 internal/selfupdate/sshsig.go create mode 100644 internal/selfupdate/sshsig_sign_test.go create mode 100644 internal/selfupdate/sshsig_test.go create mode 100644 internal/selfupdate/testkeys_off.go create mode 100644 internal/selfupdate/testkeys_on.go create mode 100644 internal/updatestate/manifest.go create mode 100644 internal/updatestate/manifest_test.go diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index dec13c8c..30d40429 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -113,7 +113,7 @@ jobs: go-version-file: go.mod - run: make test - name: Test self-update fault injection (selfupdate_faults tag) - run: go test -race -count=1 -tags selfupdate_faults ./internal/faultinject/ ./internal/updatestate/ + run: go test -race -count=1 -tags selfupdate_faults,selfupdate_testkeys ./internal/faultinject/ ./internal/updatestate/ ./internal/selfupdate/ # These packages have Windows-only code and Windows-specific behavior whose # tests the macOS test job can only cross-compile, not run: @@ -129,6 +129,7 @@ jobs: # NT-namespace target), which the skills detector's junction folding # depends on # - updatestate: MoveFileEx state replace, LockFileEx, protected DACLs + # - selfupdate: native arch (IsWow64Process2), WinHTTP machine proxy # - detector TestReal_*: the Kiro CLI registry reader against a scratch # HKCU key, and skills junction folding over real junctions (the rest of # the detector suite assumes a POSIX host, hence the -run filter) @@ -147,12 +148,12 @@ jobs: - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version-file: go.mod - - run: go test -race -count=1 ./internal/devicepolicy/ ./internal/secureuserfile/ ./internal/detector/credentials/ ./internal/safepath/ ./internal/executor/ ./internal/updatestate/ + - run: go test -race -count=1 ./internal/devicepolicy/ ./internal/secureuserfile/ ./internal/detector/credentials/ ./internal/safepath/ ./internal/executor/ ./internal/updatestate/ ./internal/selfupdate/ - run: go test -race -count=1 -run '^TestReal_' ./internal/detector/ - name: Test scheduled scan console suppression run: go test -race -count=1 -run 'Test(IsTaskRegistered|DetectInvocationMethod_Windows|HideWindow)' ./internal/schtasks/ ./internal/telemetry/ ./internal/winproc/ - name: Test self-update fault injection (selfupdate_faults tag) - run: go test -race -count=1 -tags selfupdate_faults ./internal/faultinject/ ./internal/updatestate/ + run: go test -race -count=1 -tags selfupdate_faults,selfupdate_testkeys ./internal/faultinject/ ./internal/updatestate/ ./internal/selfupdate/ smoke: name: Smoke Tests diff --git a/internal/selfupdate/arch.go b/internal/selfupdate/arch.go new file mode 100644 index 00000000..03a0f699 --- /dev/null +++ b/internal/selfupdate/arch.go @@ -0,0 +1,6 @@ +package selfupdate + +// NativeArch is the machine's architecture as a release-asset suffix +// (amd64 | arm64). On Windows it comes from IsWow64Process2, so an x64 build +// emulated on arm64 converges to the arm64 build. +func NativeArch() string { return nativeArch() } diff --git a/internal/selfupdate/arch_other.go b/internal/selfupdate/arch_other.go new file mode 100644 index 00000000..fb5943cc --- /dev/null +++ b/internal/selfupdate/arch_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package selfupdate + +import "runtime" + +func nativeArch() string { return runtime.GOARCH } diff --git a/internal/selfupdate/arch_windows.go b/internal/selfupdate/arch_windows.go new file mode 100644 index 00000000..1c0811d4 --- /dev/null +++ b/internal/selfupdate/arch_windows.go @@ -0,0 +1,34 @@ +//go:build windows + +package selfupdate + +import ( + "runtime" + + "golang.org/x/sys/windows" +) + +const ( + imageFileMachineAMD64 = 0x8664 + imageFileMachineARM64 = 0xAA64 +) + +func nativeArch() string { + var process, native uint16 + if err := windows.IsWow64Process2(windows.CurrentProcess(), &process, &native); err == nil { + if a := archForMachine(native); a != "" { + return a + } + } + return runtime.GOARCH +} + +func archForMachine(m uint16) string { + switch m { + case imageFileMachineAMD64: + return "amd64" + case imageFileMachineARM64: + return "arm64" + } + return "" +} diff --git a/internal/selfupdate/arch_windows_test.go b/internal/selfupdate/arch_windows_test.go new file mode 100644 index 00000000..48fa9b51 --- /dev/null +++ b/internal/selfupdate/arch_windows_test.go @@ -0,0 +1,19 @@ +//go:build windows + +package selfupdate + +import "testing" + +func TestArchForMachine(t *testing.T) { + if archForMachine(0x8664) != "amd64" || archForMachine(0xAA64) != "arm64" || archForMachine(0x14c) != "" { + t.Fatal("machine mapping wrong") + } + if a := nativeArch(); a != "amd64" && a != "arm64" { + t.Fatalf("nativeArch = %q on a Windows runner", a) + } +} + +func TestLoadMachineProxyDoesNotFail(t *testing.T) { + // Runners usually have no WinHTTP proxy; this only proves the call is safe. + _ = loadMachineProxy() +} diff --git a/internal/selfupdate/fakeapi_test.go b/internal/selfupdate/fakeapi_test.go new file mode 100644 index 00000000..27d9daa7 --- /dev/null +++ b/internal/selfupdate/fakeapi_test.go @@ -0,0 +1,186 @@ +package selfupdate + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +// fakeAPI is an in-process latest-binary + release download + run-status +// server. +type fakeAPI struct { + t testing.TB + srv *httptest.Server + signer *testSigner + + mu sync.Mutex + target string // version latest-binary returns + releases map[string]map[string][]byte // version -> remote asset name -> body + // fault knobs + metaStatus int // non-zero: latest-binary returns this + metaBody string // non-empty: raw latest-binary body + linuxVersion string // non-empty: linux response names this version + githubDown bool // release downloads 404 + mirrorDown bool // mirror downloads 404 + badSig map[string]bool // remote asset -> signature over wrong data + checksumOver map[string]string // remote asset -> served checksum override + eventStatus int // non-zero: run-status returns this + requests []*http.Request + events []map[string]any + githubHits map[string]int + mirrorHits map[string]int + latestRequests []string +} + +// newFakeAPI serves over TLS (srv.Client trusts it) so the HTTPS-only mirror +// rule holds; newFakeAPIPlain serves plain HTTP for subprocess tests. +func newFakeAPI(t testing.TB) *fakeAPI { return startFakeAPI(t, true) } + +func newFakeAPIPlain(t testing.TB) *fakeAPI { return startFakeAPI(t, false) } + +func startFakeAPI(t testing.TB, useTLS bool) *fakeAPI { + f := &fakeAPI{ + t: t, signer: newTestSigner(t), + releases: map[string]map[string][]byte{}, + badSig: map[string]bool{}, + checksumOver: map[string]string{}, + githubHits: map[string]int{}, + mirrorHits: map[string]int{}, + } + mux := http.NewServeMux() + mux.HandleFunc("/v1/cust/developer-mdm-agent/latest-binary", f.latest) + mux.HandleFunc("/v1/cust/developer-mdm-agent/telemetry/run-status", f.runStatus) + mux.HandleFunc("/releases/", f.download("/releases/v", &f.githubDown, f.githubHits)) + mux.HandleFunc("/mirror/", f.download("/mirror/", &f.mirrorDown, f.mirrorHits)) + if useTLS { + f.srv = httptest.NewTLSServer(mux) + } else { + f.srv = httptest.NewServer(mux) + } + t.Cleanup(f.srv.Close) + return f +} + +func (f *fakeAPI) URL() string { return f.srv.URL } + +// addRelease registers the three assets of version v for arch. +func (f *fakeAPI) addRelease(v, arch string, agent, launcher, linux []byte) { + f.mu.Lock() + defer f.mu.Unlock() + f.releases[v] = map[string][]byte{ + fmt.Sprintf("stepsecurity-dev-machine-guard-%s-windows_%s.exe", v, arch): agent, + fmt.Sprintf("stepsecurity-dev-machine-guard-task-%s-windows_%s.exe", v, arch): launcher, + fmt.Sprintf("stepsecurity-dev-machine-guard-%s-linux_%s", v, arch): linux, + } +} + +func (f *fakeAPI) setTarget(v string) { f.mu.Lock(); f.target = v; f.mu.Unlock() } + +func sum(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) } + +func (f *fakeAPI) assetFields(v, remote string) (checksum, signed string) { + body, ok := f.releases[v][remote] + if !ok { + return "", "" + } + checksum = sum(body) + if o, ok := f.checksumOver[remote]; ok { + checksum = o + } + signed = f.signer.transport(checksum) + if f.badSig[remote] { + signed = f.signer.transport(strings.Repeat("0", 64)) + } + return checksum, signed +} + +func (f *fakeAPI) latest(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + f.requests = append(f.requests, r) + f.latestRequests = append(f.latestRequests, r.URL.RawQuery) + if f.metaStatus != 0 { + w.WriteHeader(f.metaStatus) + return + } + if f.metaBody != "" { + _, _ = io.WriteString(w, f.metaBody) + return + } + q := r.URL.Query() + arch, v := q.Get("arch"), f.target + resp := map[string]string{"version": v} + switch q.Get("os") { + case "windows": + resp["checksum"], resp["signed_checksum"] = f.assetFields(v, fmt.Sprintf("stepsecurity-dev-machine-guard-%s-windows_%s.exe", v, arch)) + resp["launcher_checksum"], resp["signed_launcher_checksum"] = f.assetFields(v, fmt.Sprintf("stepsecurity-dev-machine-guard-task-%s-windows_%s.exe", v, arch)) + case "linux": + if f.linuxVersion != "" { + v = f.linuxVersion + resp["version"] = v + } + resp["checksum"], resp["signed_checksum"] = f.assetFields(v, fmt.Sprintf("stepsecurity-dev-machine-guard-%s-linux_%s", v, arch)) + default: + w.WriteHeader(http.StatusBadRequest) + return + } + _ = json.NewEncoder(w).Encode(resp) +} + +func (f *fakeAPI) download(prefix string, down *bool, hits map[string]int) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + rest := strings.TrimPrefix(r.URL.Path, prefix) + v, remote, _ := strings.Cut(rest, "/") + hits[remote]++ + body, ok := f.releases[v][remote] + if *down || !ok { + http.NotFound(w, r) + return + } + _, _ = w.Write(body) + } +} + +func (f *fakeAPI) runStatus(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + f.mu.Lock() + defer f.mu.Unlock() + if f.eventStatus != 0 { + w.WriteHeader(f.eventStatus) + return + } + f.events = append(f.events, body) + w.WriteHeader(http.StatusOK) +} + +// eventNames lists the update_event of every accepted update post. +func (f *fakeAPI) eventNames() []string { + f.mu.Lock() + defer f.mu.Unlock() + var out []string + for _, e := range f.events { + if e["status"] == "update" { + out = append(out, fmt.Sprint(e["update_event"])) + } + } + return out +} + +func (f *fakeAPI) client(arch string) *Client { + return &Client{ + HTTP: f.srv.Client(), APIEndpoint: f.srv.URL, CustomerID: "cust", APIKey: "key", + AgentVersion: "1.19.0", Arch: arch, + ReleaseBaseURL: f.srv.URL + "/releases", MirrorURL: "", + SigningKey: f.signer.pubB64, + } +} diff --git a/internal/selfupdate/knobs.go b/internal/selfupdate/knobs.go new file mode 100644 index 00000000..e4f19f95 --- /dev/null +++ b/internal/selfupdate/knobs.go @@ -0,0 +1,29 @@ +package selfupdate + +import "time" + +// knob names a time constant that selfupdate_testkeys builds may override. +type knob int + +const ( + knobProbationWindow knob = iota // smoke-only promotion / inconclusive window (72 h) + knobSwitchWait // S6 wait for the scan instance mutex (10 min) + knobDedupeWindow // event dedupe window (24 h) + knobCheckInterval // minimum spacing of S3 target checks (6 h) + knobSmokeTimeout // S5 self-check limit (60 s) +) + +var knobDefaults = map[knob]time.Duration{ + knobProbationWindow: 72 * time.Hour, + knobSwitchWait: 10 * time.Minute, + knobDedupeWindow: 24 * time.Hour, + knobCheckInterval: 6 * time.Hour, + knobSmokeTimeout: 60 * time.Second, +} + +func duration(k knob) time.Duration { + if d, ok := testDuration(k); ok { + return d + } + return knobDefaults[k] +} diff --git a/internal/selfupdate/proxy.go b/internal/selfupdate/proxy.go new file mode 100644 index 00000000..f5053597 --- /dev/null +++ b/internal/selfupdate/proxy.go @@ -0,0 +1,125 @@ +package selfupdate + +import ( + "net" + "net/http" + "net/url" + "strings" + "time" +) + +// machineProxy is the WinHTTP default (machine) proxy; nil means none. The +// SYSTEM updater has no user proxy env, so after the env vars this is what +// corporate proxy-only fleets rely on (spec Contracts: Proxy). +type machineProxy struct { + servers map[string]string // scheme ("" = any) -> host:port + bypass []string +} + +// NewHTTPClient returns the updater's client: the environment proxy vars +// first, then the WinHTTP machine proxy. +func NewHTTPClient() *http.Client { + tr := http.DefaultTransport.(*http.Transport).Clone() + tr.Proxy = proxyFunc(http.ProxyFromEnvironment, loadMachineProxy()) + tr.TLSHandshakeTimeout = 30 * time.Second + tr.ResponseHeaderTimeout = 60 * time.Second + return &http.Client{Transport: tr} +} + +func proxyFunc(env func(*http.Request) (*url.URL, error), mp *machineProxy) func(*http.Request) (*url.URL, error) { + return func(req *http.Request) (*url.URL, error) { + if u, err := env(req); u != nil || err != nil { + return u, err + } + if mp == nil { + return nil, nil + } + return mp.proxyFor(req.URL), nil + } +} + +// parseWinHTTPProxy parses WINHTTP_PROXY_INFO strings: lpszProxy is a list +// of "[scheme=][scheme://]host[:port]" entries separated by ';' or spaces, +// lpszProxyBypass a list of host patterns ("", "*.corp", "10.*"). +func parseWinHTTPProxy(proxy, bypass string) *machineProxy { + mp := &machineProxy{servers: map[string]string{}} + for _, e := range splitList(proxy) { + scheme := "" + if i := strings.IndexByte(e, '='); i >= 0 { + scheme, e = strings.ToLower(e[:i]), e[i+1:] + } + if i := strings.Index(e, "://"); i >= 0 { + e = e[i+3:] + } + e = strings.TrimRight(e, "/") + if e == "" { + continue + } + if _, ok := mp.servers[scheme]; !ok { + mp.servers[scheme] = e + } + } + if len(mp.servers) == 0 { + return nil + } + mp.bypass = splitList(bypass) + return mp +} + +func splitList(s string) []string { + return strings.FieldsFunc(s, func(r rune) bool { return r == ';' || r == ' ' || r == '\t' || r == ',' }) +} + +func (mp *machineProxy) proxyFor(u *url.URL) *url.URL { + host := strings.ToLower(u.Hostname()) + for _, b := range mp.bypass { + if bypassMatch(strings.ToLower(b), host) { + return nil + } + } + server, ok := mp.servers[u.Scheme] + if !ok { + if server, ok = mp.servers[""]; !ok { + if server, ok = mp.servers["http"]; !ok { + return nil + } + } + } + return &url.URL{Scheme: "http", Host: server} +} + +func bypassMatch(pattern, host string) bool { + if pattern == "" { + return !strings.Contains(host, ".") || host == "localhost" || net.ParseIP(host).IsLoopback() + } + if i := strings.Index(pattern, "://"); i >= 0 { + pattern = pattern[i+3:] + } + if h, _, err := net.SplitHostPort(pattern); err == nil { + pattern = h + } + return wildcardMatch(pattern, host) +} + +// wildcardMatch matches '*' against any run of characters. +func wildcardMatch(pattern, s string) bool { + for len(pattern) > 0 { + if pattern[0] == '*' { + pattern = strings.TrimLeft(pattern, "*") + if pattern == "" { + return true + } + for i := 0; i <= len(s); i++ { + if wildcardMatch(pattern, s[i:]) { + return true + } + } + return false + } + if s == "" || pattern[0] != s[0] { + return false + } + pattern, s = pattern[1:], s[1:] + } + return s == "" +} diff --git a/internal/selfupdate/proxy_other.go b/internal/selfupdate/proxy_other.go new file mode 100644 index 00000000..0bd18c4c --- /dev/null +++ b/internal/selfupdate/proxy_other.go @@ -0,0 +1,5 @@ +//go:build !windows + +package selfupdate + +func loadMachineProxy() *machineProxy { return nil } diff --git a/internal/selfupdate/proxy_windows.go b/internal/selfupdate/proxy_windows.go new file mode 100644 index 00000000..b1d9996f --- /dev/null +++ b/internal/selfupdate/proxy_windows.go @@ -0,0 +1,44 @@ +//go:build windows + +package selfupdate + +import ( + "unsafe" + + "golang.org/x/sys/windows" +) + +var ( + modwinhttp = windows.NewLazySystemDLL("winhttp.dll") + procWinHttpGetDefaultProxyConfiguration = modwinhttp.NewProc("WinHttpGetDefaultProxyConfiguration") + procGlobalFree = windows.NewLazySystemDLL("kernel32.dll").NewProc("GlobalFree") +) + +const winhttpAccessTypeNamedProxy = 3 + +// winhttpProxyInfo is WINHTTP_PROXY_INFO. +type winhttpProxyInfo struct { + accessType uint32 + proxy *uint16 + proxyBypass *uint16 +} + +// loadMachineProxy reads the WinHTTP default proxy (netsh winhttp set proxy). +func loadMachineProxy() *machineProxy { + if procWinHttpGetDefaultProxyConfiguration.Find() != nil { + return nil + } + var info winhttpProxyInfo + r, _, _ := procWinHttpGetDefaultProxyConfiguration.Call(uintptr(unsafe.Pointer(&info))) + defer func() { + for _, p := range []*uint16{info.proxy, info.proxyBypass} { + if p != nil { + _, _, _ = procGlobalFree.Call(uintptr(unsafe.Pointer(p))) + } + } + }() + if r == 0 || info.accessType != winhttpAccessTypeNamedProxy || info.proxy == nil { + return nil + } + return parseWinHTTPProxy(windows.UTF16PtrToString(info.proxy), windows.UTF16PtrToString(info.proxyBypass)) +} diff --git a/internal/selfupdate/release.go b/internal/selfupdate/release.go new file mode 100644 index 00000000..91dbbb79 --- /dev/null +++ b/internal/selfupdate/release.go @@ -0,0 +1,333 @@ +// Package selfupdate is the Windows MSI self-updater (`agent self-update +// --system`, run by the SYSTEM update task through the bootstrap). It asks +// agent-api latest-binary for the tenant's target, verifies every artefact +// (SSHSIG over the checksum before download, sha256 after, Authenticode +// publisher), stages it as an immutable versions\ directory and switches +// the state.json pointer the bootstrap reads. See internal/updatestate for +// the on-disk contract. +package selfupdate + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/updatestate" +) + +const ( + metaTimeout = 30 * time.Second + maxMetaBytes = 64 << 10 + downloadTimeout = 5 * time.Minute + maxAssetBytes = 128 << 20 + + agentAssetBase = "stepsecurity-dev-machine-guard" + launcherAssetBase = "stepsecurity-dev-machine-guard-task" + + defaultReleaseBaseURL = "https://github.com/step-security/dev-machine-guard/releases/download" +) + +// minWindowsSelfUpdateVersion is the floor F (spec §6): the first release +// that contains this updater. A target below it would land a version with no +// SYSTEM updater, freezing the device, so it is refused. Set at release cut. +const minWindowsSelfUpdateVersion = "1.19.0" + +// Floor returns F. +func Floor() updatestate.Version { + v, err := updatestate.ParseVersion(minWindowsSelfUpdateVersion) + if err != nil { + panic(err) + } + return v +} + +// Asset is one file of a release: the name it gets in versions\, the +// release asset name, and its signed checksum. +type Asset struct { + File string // updatestate.AgentFile etc. + Remote string // release asset name + Checksum string // lowercase hex sha256 + Signed string // signed_checksum transport value +} + +// Release is a latest-binary target with all three assets. +type Release struct { + Version updatestate.Version + Assets []Asset +} + +// Client talks to latest-binary and downloads release assets. +type Client struct { + HTTP *http.Client + APIEndpoint string + CustomerID string + APIKey string + AgentVersion string + Arch string // native windows arch: amd64 | arm64 + LagBehind int + CooldownHours int + MirrorURL string // optional HTTPS mirror (config update_mirror_url) + // ReleaseBaseURL and SigningKey default to the GitHub releases and the + // pinned release key; only tests and selfupdate_testkeys builds change them. + ReleaseBaseURL string + SigningKey string +} + +func (c *Client) releaseBase() string { + if c.ReleaseBaseURL != "" { + return c.ReleaseBaseURL + } + if v := testReleaseBaseURL(); v != "" { + return v + } + return defaultReleaseBaseURL +} + +func (c *Client) signingKey() string { + if c.SigningKey != "" { + return c.SigningKey + } + if v := testSSHSIGKey(); v != "" { + return v + } + return releasePublicKeyB64 +} + +type latestBinaryResponse struct { + Version string `json:"version"` + Checksum string `json:"checksum"` + SignedChecksum string `json:"signed_checksum"` + LauncherChecksum string `json:"launcher_checksum"` + SignedLauncherChecksum string `json:"signed_launcher_checksum"` +} + +// ErrCheck wraps every latest-binary failure (update_check_failed). +var ErrCheck = errors.New("latest-binary check failed") + +// Latest resolves the target: the windows response (agent + launcher) and +// the linux response for dmg-linux, which must name the same version. +func (c *Client) Latest(ctx context.Context) (*Release, error) { + win, err := c.fetch(ctx, "windows") + if err != nil { + return nil, err + } + v, err := updatestate.ParseVersion(win.Version) + if err != nil { + return nil, fmt.Errorf("%w: target %q is not a strict x.y.z release", ErrCheck, win.Version) + } + if win.Checksum == "" || win.SignedChecksum == "" || win.LauncherChecksum == "" || win.SignedLauncherChecksum == "" { + return nil, fmt.Errorf("%w: response for %s lacks agent or launcher checksums", ErrCheck, v) + } + lin, err := c.fetch(ctx, "linux") + if err != nil { + return nil, err + } + if lin.Version != win.Version { + // The gate moved between the two calls; the next tick retries. + return nil, fmt.Errorf("%w: linux target %q differs from windows target %s", ErrCheck, lin.Version, v) + } + if lin.Checksum == "" || lin.SignedChecksum == "" { + return nil, fmt.Errorf("%w: linux response for %s lacks checksums", ErrCheck, v) + } + vs := v.String() + return &Release{Version: v, Assets: []Asset{ + {File: updatestate.AgentFile, Remote: fmt.Sprintf("%s-%s-windows_%s.exe", agentAssetBase, vs, c.Arch), + Checksum: strings.ToLower(win.Checksum), Signed: win.SignedChecksum}, + {File: updatestate.LauncherFile, Remote: fmt.Sprintf("%s-%s-windows_%s.exe", launcherAssetBase, vs, c.Arch), + Checksum: strings.ToLower(win.LauncherChecksum), Signed: win.SignedLauncherChecksum}, + {File: updatestate.LinuxFile, Remote: fmt.Sprintf("%s-%s-linux_%s", agentAssetBase, vs, c.Arch), + Checksum: strings.ToLower(lin.Checksum), Signed: lin.SignedChecksum}, + }}, nil +} + +func (c *Client) fetch(ctx context.Context, goos string) (*latestBinaryResponse, error) { + q := url.Values{} + q.Set("os", goos) + q.Set("arch", c.Arch) + if c.LagBehind > 0 || c.CooldownHours > 0 { + q.Set("lag_behind", fmt.Sprint(c.LagBehind)) + q.Set("cooldown_hours", fmt.Sprint(c.CooldownHours)) + } + endpoint := fmt.Sprintf("%s/v1/%s/developer-mdm-agent/latest-binary?%s", + strings.TrimRight(c.APIEndpoint, "/"), url.PathEscape(c.CustomerID), q.Encode()) + + ctx, cancel := context.WithTimeout(ctx, metaTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrCheck, err) + } + req.Header.Set("Authorization", "Bearer "+c.APIKey) + req.Header.Set("X-Agent-Version", c.AgentVersion) + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrCheck, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: latest-binary (%s) returned HTTP %d", ErrCheck, goos, resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxMetaBytes+1)) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrCheck, err) + } + if len(body) > maxMetaBytes { + return nil, fmt.Errorf("%w: latest-binary response too large", ErrCheck) + } + var meta latestBinaryResponse + if err := json.Unmarshal(body, &meta); err != nil { + return nil, fmt.Errorf("%w: parse latest-binary response: %w", ErrCheck, err) + } + return &meta, nil +} + +// ErrVerify wraps every signature, checksum or publisher failure +// (update_verify_failed). +var ErrVerify = errors.New("verification failed") + +// VerifySignatures checks every asset's SSHSIG before anything is +// downloaded: one unverifiable asset rejects the whole release. +func (c *Client) VerifySignatures(r *Release) error { + for _, a := range r.Assets { + if !isHexSHA256(a.Checksum) { + return fmt.Errorf("%w: %s checksum is not a sha256", ErrVerify, a.File) + } + armored, err := decodeSignedChecksum(a.Signed) + if err != nil { + return fmt.Errorf("%w: %s signature: %w", ErrVerify, a.File, err) + } + if err := verifySSHSig(armored, []byte(a.Checksum), c.signingKey(), signatureNamespace); err != nil { + return fmt.Errorf("%w: %s signature: %w", ErrVerify, a.File, err) + } + } + return nil +} + +func isHexSHA256(s string) bool { + b, err := hex.DecodeString(s) + return err == nil && len(b) == sha256.Size +} + +// ErrDownload wraps a download that failed from every source +// (update_download_failed). +var ErrDownload = errors.New("download failed") + +// Download fetches asset a of version v into dir\: GitHub first, +// then the mirror. The written file's sha256 must equal the signed checksum. +// Local write failures (disk full) are returned unwrapped by ErrDownload so +// the caller reports them as stage failures. +func (c *Client) Download(ctx context.Context, v updatestate.Version, a Asset, dir string) error { + dst := filepath.Join(dir, a.File) + primary := fmt.Sprintf("%s/v%s/%s", strings.TrimRight(c.releaseBase(), "/"), v, a.Remote) + err := c.downloadURL(ctx, primary, dst, a.Checksum) + if err == nil || !errors.Is(err, ErrDownload) || c.MirrorURL == "" { + return err + } + mirror, merr := c.mirrorAssetURL(v, a) + if merr != nil { + return fmt.Errorf("%w: %w (mirror: %w)", ErrDownload, err, merr) + } + if err2 := c.downloadURL(ctx, mirror, dst, a.Checksum); err2 != nil { + if !errors.Is(err2, ErrDownload) { + return err2 + } + return fmt.Errorf("%w: github: %w; mirror: %w", ErrDownload, err, err2) + } + return nil +} + +func (c *Client) mirrorAssetURL(v updatestate.Version, a Asset) (string, error) { + u, err := url.Parse(c.MirrorURL) + insecureOK := testAllowInsecureMirror() && u != nil && u.Scheme == "http" + if err != nil || (u.Scheme != "https" && !insecureOK) || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return "", errors.New("invalid HTTPS update mirror") + } + return strings.TrimRight(u.String(), "/") + "/" + v.String() + "/" + a.Remote, nil +} + +func (c *Client) downloadURL(ctx context.Context, assetURL, dst, want string) error { + ctx, cancel := context.WithTimeout(ctx, downloadTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, assetURL, nil) + if err != nil { + return fmt.Errorf("%w: %w", ErrDownload, err) + } + resp, err := c.HTTP.Do(req) + if err != nil { + return fmt.Errorf("%w: %w", ErrDownload, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%w: %s returned HTTP %d", ErrDownload, assetURL, resp.StatusCode) + } + if resp.ContentLength > maxAssetBytes { + return fmt.Errorf("%w: %s exceeds the size limit", ErrDownload, assetURL) + } + // #nosec G304 -- dst is inside the updater's own staging directory. + f, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o755) + if err != nil { + return err + } + h := sha256.New() + n, copyErr := io.Copy(io.MultiWriter(f, h), io.LimitReader(resp.Body, maxAssetBytes+1)) + syncErr := f.Sync() + closeErr := f.Close() + switch { + case copyErr != nil && isLocalWriteErr(copyErr): + return copyErr + case copyErr != nil: + return fmt.Errorf("%w: %s: %w", ErrDownload, assetURL, copyErr) + case n > maxAssetBytes: + return fmt.Errorf("%w: %s exceeds the size limit", ErrDownload, assetURL) + case syncErr != nil: + return syncErr + case closeErr != nil: + return closeErr + } + if got := hex.EncodeToString(h.Sum(nil)); got != want { + return fmt.Errorf("%w: %s sha256 %s, want %s", ErrVerify, filepath.Base(dst), got, want) + } + return nil +} + +// isLocalWriteErr tells a failed write to the staging file (disk full, +// quota) from a failed read of the response body. +func isLocalWriteErr(err error) bool { + var pe *os.PathError + return errors.As(err, &pe) +} + +// decodeSignedChecksum recovers the armored SSHSIG block from the API's +// signed_checksum field, which is base64-wrapped for single-line JSON. A +// value that already carries the armor header is accepted as is. +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 "", errors.New("decoded signed_checksum is not an armored SSH signature block") + } + return armored, nil +} diff --git a/internal/selfupdate/release_test.go b/internal/selfupdate/release_test.go new file mode 100644 index 00000000..b83a8b41 --- /dev/null +++ b/internal/selfupdate/release_test.go @@ -0,0 +1,286 @@ +package selfupdate + +import ( + "context" + "errors" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/step-security/dev-machine-guard/internal/updatestate" +) + +func newReleaseFixture(t *testing.T) (*fakeAPI, *Client) { + t.Helper() + f := newFakeAPI(t) + f.addRelease("1.20.0", "arm64", []byte("agent-1.20.0"), []byte("task-1.20.0"), []byte("linux-1.20.0")) + f.setTarget("1.20.0") + return f, f.client("arm64") +} + +func TestLatestRequestsNativeArchWithAuth(t *testing.T) { + f, c := newReleaseFixture(t) + c.LagBehind, c.CooldownHours = 1, 24 + rel, err := c.Latest(context.Background()) + if err != nil { + t.Fatal(err) + } + if rel.Version.String() != "1.20.0" || len(rel.Assets) != 3 { + t.Fatalf("release = %+v", rel) + } + wantRemote := map[string]string{ + updatestate.AgentFile: "stepsecurity-dev-machine-guard-1.20.0-windows_arm64.exe", + updatestate.LauncherFile: "stepsecurity-dev-machine-guard-task-1.20.0-windows_arm64.exe", + updatestate.LinuxFile: "stepsecurity-dev-machine-guard-1.20.0-linux_arm64", + } + for _, a := range rel.Assets { + if wantRemote[a.File] != a.Remote { + t.Errorf("%s remote = %s, want %s", a.File, a.Remote, wantRemote[a.File]) + } + } + if len(f.requests) != 2 { + t.Fatalf("latest-binary requests = %d, want 2 (windows + linux)", len(f.requests)) + } + for i, goos := range []string{"windows", "linux"} { + r := f.requests[i] + q := r.URL.Query() + if q.Get("os") != goos || q.Get("arch") != "arm64" || q.Get("lag_behind") != "1" || q.Get("cooldown_hours") != "24" { + t.Errorf("query %d = %s", i, r.URL.RawQuery) + } + if r.Header.Get("Authorization") != "Bearer key" || r.Header.Get("X-Agent-Version") != "1.19.0" { + t.Errorf("headers %d = %v", i, r.Header) + } + } +} + +func TestLatestRejectsNonStrictVersions(t *testing.T) { + for _, v := range []string{"1.20.0-rc.1", "v1.20.0", "1.20", "../1.20.0", `..\x`, "01.2.3", "1.2.3+meta", ""} { + t.Run(v, func(t *testing.T) { + f, c := newReleaseFixture(t) + f.setTarget(v) + if _, err := c.Latest(context.Background()); !errors.Is(err, ErrCheck) { + t.Fatalf("Latest(%q) err = %v, want ErrCheck", v, err) + } + }) + } +} + +func TestLatestFailures(t *testing.T) { + cases := map[string]func(f *fakeAPI){ + "http 500": func(f *fakeAPI) { f.metaStatus = 500 }, + "http 422": func(f *fakeAPI) { f.metaStatus = 422 }, + "garbage": func(f *fakeAPI) { f.metaBody = "" }, + "oversized": func(f *fakeAPI) { f.metaBody = `{"version":"` + strings.Repeat("1", maxMetaBytes) + `"}` }, + "no launcher": func(f *fakeAPI) { f.metaBody = `{"version":"1.20.0","checksum":"a","signed_checksum":"b"}` }, + "linux mismatch": func(f *fakeAPI) { f.linuxVersion = "1.19.5" }, + "missing release": func(f *fakeAPI) { f.target = "1.21.0" }, + } + for name, mut := range cases { + t.Run(name, func(t *testing.T) { + f, c := newReleaseFixture(t) + mut(f) + if _, err := c.Latest(context.Background()); !errors.Is(err, ErrCheck) { + t.Fatalf("err = %v, want ErrCheck", err) + } + }) + } +} + +func TestVerifySignatures(t *testing.T) { + f, c := newReleaseFixture(t) + rel, err := c.Latest(context.Background()) + if err != nil { + t.Fatal(err) + } + if err := c.VerifySignatures(rel); err != nil { + t.Fatalf("good release rejected: %v", err) + } + + other := newTestSigner(t) + wrongKey := *c + wrongKey.SigningKey = other.pubB64 + if err := wrongKey.VerifySignatures(rel); !errors.Is(err, ErrVerify) { + t.Fatalf("wrong key: err = %v", err) + } + + f.badSig["stepsecurity-dev-machine-guard-task-1.20.0-windows_arm64.exe"] = true + rel2, err := c.Latest(context.Background()) + if err != nil { + t.Fatal(err) + } + if err := c.VerifySignatures(rel2); !errors.Is(err, ErrVerify) { + t.Fatalf("bad launcher signature: err = %v", err) + } + + rel2.Assets[1].Signed = "" + if err := c.VerifySignatures(rel2); !errors.Is(err, ErrVerify) { + t.Fatalf("missing signature: err = %v", err) + } + rel2.Assets[1].Checksum = "nothex" + if err := c.VerifySignatures(rel2); !errors.Is(err, ErrVerify) { + t.Fatalf("non-sha checksum: err = %v", err) + } +} + +func TestReleaseKeyIsPinnedByDefault(t *testing.T) { + c := &Client{} + if testSSHSIGKey() == "" && c.signingKey() != releasePublicKeyB64 { + t.Fatalf("default signing key = %q", c.signingKey()) + } + if testReleaseBaseURL() == "" && c.releaseBase() != defaultReleaseBaseURL { + t.Fatalf("default release base = %q", c.releaseBase()) + } +} + +func TestDownloadGitHubThenMirror(t *testing.T) { + f, c := newReleaseFixture(t) + rel, err := c.Latest(context.Background()) + if err != nil { + t.Fatal(err) + } + v := rel.Version + agent := rel.Assets[0] + + dir := t.TempDir() + if err := c.Download(context.Background(), v, agent, dir); err != nil { + t.Fatalf("github download: %v", err) + } + if b, _ := os.ReadFile(filepath.Join(dir, updatestate.AgentFile)); string(b) != "agent-1.20.0" { + t.Fatalf("content = %q", b) + } + + f.githubDown = true + if err := c.Download(context.Background(), v, agent, t.TempDir()); !errors.Is(err, ErrDownload) { + t.Fatalf("github down, no mirror: err = %v", err) + } + c.MirrorURL = f.URL() + "/mirror" + if err := c.Download(context.Background(), v, agent, t.TempDir()); err != nil { + t.Fatalf("mirror fallback: %v", err) + } + if f.mirrorHits[agent.Remote] != 1 { + t.Fatalf("mirror hits = %d", f.mirrorHits[agent.Remote]) + } + f.mirrorDown = true + if err := c.Download(context.Background(), v, agent, t.TempDir()); !errors.Is(err, ErrDownload) { + t.Fatalf("both down: err = %v", err) + } +} + +func TestDownloadRejectsInsecureMirror(t *testing.T) { + f, c := newReleaseFixture(t) + rel, _ := c.Latest(context.Background()) + f.githubDown = true + u, _ := url.Parse(f.URL()) + u.Scheme = "http" + c.MirrorURL = u.String() + "/mirror" + err := c.Download(context.Background(), rel.Version, rel.Assets[0], t.TempDir()) + if testAllowInsecureMirror() { + t.Skip("insecure mirror allowed by DMG_TEST_ALLOW_HTTP_MIRROR") + } + if err == nil || !strings.Contains(err.Error(), "invalid HTTPS update mirror") { + t.Fatalf("err = %v", err) + } +} + +func TestDownloadChecksumMismatchIsVerifyFailure(t *testing.T) { + _, c := newReleaseFixture(t) + rel, _ := c.Latest(context.Background()) + a := rel.Assets[2] + a.Checksum = strings.Repeat("ab", 32) + if err := c.Download(context.Background(), rel.Version, a, t.TempDir()); !errors.Is(err, ErrVerify) { + t.Fatalf("err = %v, want ErrVerify", err) + } +} + +func TestDownloadSizeLimit(t *testing.T) { + f, c := newReleaseFixture(t) + big := make([]byte, maxAssetBytes+1) + f.addRelease("1.20.0", "arm64", big, []byte("t"), []byte("l")) + rel, err := c.Latest(context.Background()) + if err != nil { + t.Fatal(err) + } + if err := c.Download(context.Background(), rel.Version, rel.Assets[0], t.TempDir()); !errors.Is(err, ErrDownload) { + t.Fatalf("err = %v, want ErrDownload (size limit)", err) + } +} + +func TestDecodeSignedChecksum(t *testing.T) { + if _, err := decodeSignedChecksum("!!!"); err == nil { + t.Fatal("garbage accepted") + } + if _, err := decodeSignedChecksum("aGVsbG8="); err == nil { + t.Fatal("non-armored payload accepted") + } + if got, err := decodeSignedChecksum(fixtureSig); err != nil || got != fixtureSig { + t.Fatalf("raw armor: %v", err) + } +} + +func TestFloorParses(t *testing.T) { + if Floor().String() != minWindowsSelfUpdateVersion { + t.Fatal(Floor()) + } +} + +func TestProxyParsing(t *testing.T) { + mp := parseWinHTTPProxy("http=proxy:3128;https=secure:8443", ";*.corp.example;10.*") + mustProxy := func(raw, want string) { + t.Helper() + u, _ := url.Parse(raw) + got := mp.proxyFor(u) + gs := "" + if got != nil { + gs = got.Host + } + if gs != want { + t.Errorf("proxyFor(%s) = %q, want %q", raw, gs, want) + } + } + mustProxy("https://api.stepsecurity.io/x", "secure:8443") + mustProxy("http://example.com/", "proxy:3128") + mustProxy("https://intranet/", "") + mustProxy("https://a.corp.example/", "") + mustProxy("https://10.1.2.3/", "") + mustProxy("https://127.0.0.1/", "") + + single := parseWinHTTPProxy("http://gw.example:8080", "") + u, _ := url.Parse("https://github.com/") + if p := single.proxyFor(u); p == nil || p.Host != "gw.example:8080" { + t.Fatalf("single proxy = %v", p) + } + if parseWinHTTPProxy("", "") != nil || parseWinHTTPProxy(" ; ", "") != nil { + t.Fatal("empty proxy list parsed as a proxy") + } +} + +func TestProxyFuncPrefersEnvironment(t *testing.T) { + envURL, _ := url.Parse("http://env-proxy:1") + mp := parseWinHTTPProxy("machine:2", "") + req, _ := http.NewRequest(http.MethodGet, "https://api.example/", nil) + + fromEnv := proxyFunc(func(*http.Request) (*url.URL, error) { return envURL, nil }, mp) + if u, _ := fromEnv(req); u.Host != "env-proxy:1" { + t.Fatalf("env proxy not preferred: %v", u) + } + noEnv := proxyFunc(func(*http.Request) (*url.URL, error) { return nil, nil }, mp) + if u, _ := noEnv(req); u == nil || u.Host != "machine:2" { + t.Fatalf("machine proxy not used: %v", u) + } + none := proxyFunc(func(*http.Request) (*url.URL, error) { return nil, nil }, nil) + if u, _ := none(req); u != nil { + t.Fatalf("proxy without config: %v", u) + } + if NewHTTPClient() == nil { + t.Fatal("nil client") + } +} + +func TestNativeArch(t *testing.T) { + if a := NativeArch(); a != "amd64" && a != "arm64" && a != "386" && a != "riscv64" && a != "s390x" && a != "ppc64le" && a != "loong64" { + t.Fatalf("NativeArch = %q", a) + } +} diff --git a/internal/selfupdate/sshsig.go b/internal/selfupdate/sshsig.go new file mode 100644 index 00000000..d48d575b --- /dev/null +++ b/internal/selfupdate/sshsig.go @@ -0,0 +1,175 @@ +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 + // #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...) +} + +// 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) + // 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 { + 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_sign_test.go b/internal/selfupdate/sshsig_sign_test.go new file mode 100644 index 00000000..f4c706d3 --- /dev/null +++ b/internal/selfupdate/sshsig_sign_test.go @@ -0,0 +1,68 @@ +package selfupdate + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha512" + "encoding/base64" + "strings" + "testing" +) + +// testSigner produces SSHSIG signatures like `ssh-keygen -Y sign -n +// stepsecurity-mdm-checksum` with a throwaway key. +type testSigner struct { + priv ed25519.PrivateKey + pubB64 string +} + +func newTestSigner(t testing.TB) *testSigner { + t.Helper() + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + blob := appendSSHString(appendSSHString(nil, []byte(keyTypeEd)), pub) + return &testSigner{priv: priv, pubB64: base64.StdEncoding.EncodeToString(blob)} +} + +// armored signs msg in namespace ns. +func (s *testSigner) armored(msg, ns string) string { + pubBlob, _ := base64.StdEncoding.DecodeString(s.pubB64) + h := sha512.Sum512([]byte(msg)) + signed := []byte(sshSigMagic) + signed = appendSSHString(signed, []byte(ns)) + signed = appendSSHString(signed, nil) + signed = appendSSHString(signed, []byte("sha512")) + signed = appendSSHString(signed, h[:]) + sig := ed25519.Sign(s.priv, signed) + sigBlob := appendSSHString(appendSSHString(nil, []byte(keyTypeEd)), sig) + + blob := []byte(sshSigMagic) + blob = append(blob, 0, 0, 0, 1) + blob = appendSSHString(blob, pubBlob) + blob = appendSSHString(blob, []byte(ns)) + blob = appendSSHString(blob, nil) + blob = appendSSHString(blob, []byte("sha512")) + blob = appendSSHString(blob, sigBlob) + b64 := base64.StdEncoding.EncodeToString(blob) + var lines []string + for len(b64) > 70 { + lines = append(lines, b64[:70]) + b64 = b64[70:] + } + lines = append(lines, b64) + return sigArmorBegin + "\n" + strings.Join(lines, "\n") + "\n" + sigArmorEnd + "\n" +} + +// transport wraps the armored block the way latest-binary does. +func (s *testSigner) transport(checksum string) string { + return base64.StdEncoding.EncodeToString([]byte(s.armored(checksum, signatureNamespace))) +} + +func TestTestSignerRoundTrip(t *testing.T) { + s := newTestSigner(t) + if err := verifySSHSig(s.armored("abc", signatureNamespace), []byte("abc"), s.pubB64, signatureNamespace); err != nil { + t.Fatalf("own signature rejected: %v", err) + } +} 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) + } + }) + } +} diff --git a/internal/selfupdate/testkeys_off.go b/internal/selfupdate/testkeys_off.go new file mode 100644 index 00000000..bc61a998 --- /dev/null +++ b/internal/selfupdate/testkeys_off.go @@ -0,0 +1,13 @@ +//go:build !selfupdate_testkeys + +package selfupdate + +import "time" + +// Release builds: every test-key and time-knob override is absent, so none +// of the DMG_TEST_* names exist in the binary. + +func testSSHSIGKey() string { return "" } +func testReleaseBaseURL() string { return "" } +func testAllowInsecureMirror() bool { return false } +func testDuration(knob) (time.Duration, bool) { return 0, false } diff --git a/internal/selfupdate/testkeys_on.go b/internal/selfupdate/testkeys_on.go new file mode 100644 index 00000000..140f088f --- /dev/null +++ b/internal/selfupdate/testkeys_on.go @@ -0,0 +1,49 @@ +//go:build selfupdate_testkeys + +package selfupdate + +import ( + "os" + "strings" + "time" +) + +// Test-key builds (spec Contracts, build tag selfupdate_testkeys) take the +// SSHSIG key, the GitHub release base and the time constants from DMG_TEST_* +// env. Release builds use testkeys_off.go. + +// testSSHSIGKey accepts the base64 wire blob or a whole +// "ssh-ed25519 [comment]" public-key line. +func testSSHSIGKey() string { + v := strings.TrimSpace(os.Getenv("DMG_TEST_SSHSIG_PUBKEY")) + if f := strings.Fields(v); len(f) >= 2 && f[0] == keyTypeEd { + return f[1] + } + return v +} + +func testReleaseBaseURL() string { return strings.TrimSpace(os.Getenv("DMG_TEST_RELEASE_BASE_URL")) } + +// testAllowInsecureMirror lets a fake mirror on http://127.0.0.1 serve assets. +func testAllowInsecureMirror() bool { return os.Getenv("DMG_TEST_ALLOW_HTTP_MIRROR") == "1" } + +var knobEnv = map[knob]string{ + knobProbationWindow: "DMG_TEST_PROBATION_WINDOW", + knobSwitchWait: "DMG_TEST_SWITCH_WAIT", + knobDedupeWindow: "DMG_TEST_DEDUPE_WINDOW", + knobCheckInterval: "DMG_TEST_CHECK_INTERVAL", + knobSmokeTimeout: "DMG_TEST_SMOKE_TIMEOUT", +} + +// testDuration reads a Go duration knob such as DMG_TEST_PROBATION_WINDOW. +func testDuration(k knob) (time.Duration, bool) { + v := strings.TrimSpace(os.Getenv(knobEnv[k])) + if v == "" { + return 0, false + } + d, err := time.ParseDuration(v) + if err != nil || d < 0 { + return 0, false + } + return d, true +} diff --git a/internal/updatestate/manifest.go b/internal/updatestate/manifest.go new file mode 100644 index 00000000..011eff57 --- /dev/null +++ b/internal/updatestate/manifest.go @@ -0,0 +1,120 @@ +package updatestate + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" +) + +// Files of a version directory. They keep their release names so the +// launcher finds the agent beside itself (internal/launcher.AgentBinary) and +// the WSL scan finds dmg-linux beside the agent, unchanged. +const ( + AgentFile = "stepsecurity-dev-machine-guard.exe" + LauncherFile = "stepsecurity-dev-machine-guard-task.exe" + LinuxFile = "dmg-linux" +) + +// VersionFiles lists every file a complete version directory holds besides +// its manifest. +var VersionFiles = []string{AgentFile, LauncherFile, LinuxFile} + +// maxManifestSize bounds what ReadManifest reads. +const maxManifestSize = 64 << 10 + +// Manifest is versions\\manifest.json: the sha256 of every file, written +// (and fsynced) before the directory is renamed into place. +type Manifest struct { + Schema int `json:"schema"` + Version string `json:"version"` + Files map[string]string `json:"files"` // file name -> lowercase hex sha256 +} + +// WriteManifest atomically writes dir\manifest.json. +func WriteManifest(dir string, m Manifest) error { + if !validVersion(m.Version) { + return fmt.Errorf("updatestate: manifest version %q is not x.y.z", m.Version) + } + m.Schema = SchemaVersion + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + return writeAtomic(filepath.Join(dir, ManifestName), append(data, '\n'), nil, nil) +} + +// ReadManifest reads dir\manifest.json. +func ReadManifest(dir string) (*Manifest, error) { + data, err := readBounded(filepath.Join(dir, ManifestName), maxManifestSize) + if err != nil { + return nil, err + } + var m Manifest + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("updatestate: manifest: %w", err) + } + return &m, nil +} + +// ErrIncomplete means a version directory lacks a file or its manifest. +var ErrIncomplete = errors.New("updatestate: version directory incomplete") + +// VersionDirComplete is the cheap check: every VersionFiles entry and the +// manifest exist as regular files. It does not hash. +func VersionDirComplete(dir string) error { + for _, name := range append([]string{ManifestName}, VersionFiles...) { + fi, err := os.Lstat(filepath.Join(dir, name)) + if err != nil || !fi.Mode().IsRegular() { + return fmt.Errorf("%w: %s", ErrIncomplete, name) + } + } + return nil +} + +// VerifyVersionDir checks that dir is a complete version v whose files match +// the manifest hashes. +func VerifyVersionDir(dir string, v Version) error { + if err := VersionDirComplete(dir); err != nil { + return err + } + m, err := ReadManifest(dir) + if err != nil { + return err + } + if m.Version != v.String() { + return fmt.Errorf("updatestate: manifest version %q in %s, want %s", m.Version, dir, v) + } + for _, name := range VersionFiles { + want := m.Files[name] + if want == "" { + return fmt.Errorf("%w: manifest lacks %s", ErrIncomplete, name) + } + got, err := FileSHA256(filepath.Join(dir, name)) + if err != nil { + return err + } + if got != want { + return fmt.Errorf("updatestate: %s in %s does not match its manifest hash", name, dir) + } + } + return nil +} + +// FileSHA256 returns the lowercase hex sha256 of a file. +func FileSHA256(path string) (string, error) { + f, err := openShared(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 +} diff --git a/internal/updatestate/manifest_test.go b/internal/updatestate/manifest_test.go new file mode 100644 index 00000000..3644aa71 --- /dev/null +++ b/internal/updatestate/manifest_test.go @@ -0,0 +1,58 @@ +package updatestate + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func writeVersionDir(t *testing.T, dir, v string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + m := Manifest{Version: v, Files: map[string]string{}} + for _, name := range VersionFiles { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(name+v), 0o755); err != nil { + t.Fatal(err) + } + h, err := FileSHA256(p) + if err != nil { + t.Fatal(err) + } + m.Files[name] = h + } + if err := WriteManifest(dir, m); err != nil { + t.Fatal(err) + } +} + +func TestVerifyVersionDir(t *testing.T) { + v, _ := ParseVersion("1.20.0") + dir := filepath.Join(t.TempDir(), "1.20.0") + writeVersionDir(t, dir, "1.20.0") + if err := VerifyVersionDir(dir, v); err != nil { + t.Fatalf("good dir: %v", err) + } + other, _ := ParseVersion("1.21.0") + if err := VerifyVersionDir(dir, other); err == nil { + t.Fatal("version mismatch accepted") + } + if err := os.WriteFile(filepath.Join(dir, LinuxFile), []byte("tampered"), 0o755); err != nil { + t.Fatal(err) + } + if err := VerifyVersionDir(dir, v); err == nil { + t.Fatal("tampered file accepted") + } + if err := os.Remove(filepath.Join(dir, AgentFile)); err != nil { + t.Fatal(err) + } + if err := VersionDirComplete(dir); !errors.Is(err, ErrIncomplete) { + t.Fatalf("missing agent: %v", err) + } + if err := WriteManifest(dir, Manifest{Version: "v1"}); err == nil { + t.Fatal("non-strict manifest version written") + } +} From 6b24aee556592c579321fda6af8d25b2bbf5c6f9 Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Mon, 19 Oct 2026 18:11:36 +0530 Subject: [PATCH 05/34] feat(launcher): add bootstrap mode (run, update, adopt-seed) The MSI installs the launcher a second time as stepsecurity-dev-machine-guard-bootstrap.exe. Under that name it resolves the self-update state.json pointer (current -> previous -> seed), launches versions\\task.exe with run-record env, picks the SYSTEM updater with a seed fallback after 3 consecutive failures, and adopts the MSI seed into versions\ without ever failing the MSI. No network or crypto code. The launcher exits 70 when started by the bootstrap and unable to start the agent, and falls back to a sibling agent.exe in version dirs. updatestate gains the frozen pointer reader, the run-chain resolver and a field-preserving state patch. --- .github/workflows/tests.yml | 6 + .../main.go | 69 +- internal/launcher/bootstrap.go | 632 +++++++++++++++++ internal/launcher/bootstrap_faults_test.go | 61 ++ internal/launcher/bootstrap_test.go | 654 ++++++++++++++++++ .../launcher/job_windows_test.go | 6 +- internal/launcher/launcher.go | 22 +- internal/launcher/spawn.go | 52 ++ internal/launcher/spawn_other.go | 22 + internal/launcher/spawn_other_test.go | 24 + internal/launcher/spawn_windows.go | 86 +++ internal/updatestate/pointers.go | 290 ++++++++ internal/updatestate/pointers_test.go | 182 +++++ 13 files changed, 2063 insertions(+), 43 deletions(-) create mode 100644 internal/launcher/bootstrap.go create mode 100644 internal/launcher/bootstrap_faults_test.go create mode 100644 internal/launcher/bootstrap_test.go rename cmd/stepsecurity-dev-machine-guard-task/main_test.go => internal/launcher/job_windows_test.go (88%) create mode 100644 internal/launcher/spawn.go create mode 100644 internal/launcher/spawn_other.go create mode 100644 internal/launcher/spawn_other_test.go create mode 100644 internal/launcher/spawn_windows.go create mode 100644 internal/updatestate/pointers.go create mode 100644 internal/updatestate/pointers_test.go diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index dec13c8c..d412b6b6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -114,6 +114,8 @@ jobs: - run: make test - name: Test self-update fault injection (selfupdate_faults tag) run: go test -race -count=1 -tags selfupdate_faults ./internal/faultinject/ ./internal/updatestate/ + - name: Test bootstrap fault checkpoints (selfupdate_faults tag) + run: go test -race -count=1 -tags selfupdate_faults ./internal/launcher/ # These packages have Windows-only code and Windows-specific behavior whose # tests the macOS test job can only cross-compile, not run: @@ -153,6 +155,10 @@ jobs: run: go test -race -count=1 -run 'Test(IsTaskRegistered|DetectInvocationMethod_Windows|HideWindow)' ./internal/schtasks/ ./internal/telemetry/ ./internal/winproc/ - name: Test self-update fault injection (selfupdate_faults tag) run: go test -race -count=1 -tags selfupdate_faults ./internal/faultinject/ ./internal/updatestate/ + - name: Test launcher bootstrap mode (job objects, bootstrap matrix) + run: | + go test -race -count=1 ./internal/launcher/ + go test -race -count=1 -tags selfupdate_faults ./internal/launcher/ smoke: name: Smoke Tests diff --git a/cmd/stepsecurity-dev-machine-guard-task/main.go b/cmd/stepsecurity-dev-machine-guard-task/main.go index 70dd10a2..17d3f7c5 100644 --- a/cmd/stepsecurity-dev-machine-guard-task/main.go +++ b/cmd/stepsecurity-dev-machine-guard-task/main.go @@ -25,6 +25,16 @@ // `powershell.exe -File loader.ps1 send-telemetry` in the same // no-console envelope the MSI flow uses for the agent. // +// - Bootstrap mode. Invoked under the file name +// stepsecurity-dev-machine-guard-bootstrap.exe (the MSI installs a +// second copy of this launcher under that name), the verbs run / +// update / adopt-seed resolve the self-update state.json pointer and +// launch the versioned binaries. See internal/launcher/bootstrap.go. +// +// When launched by the bootstrap (STEPSEC_BOOTSTRAP_VERSION set), failing to +// resolve or start the agent exits 70 so the run counts as bad (D5); other +// invocations keep their legacy exit codes. +// // The agent (and any --exec target) stays console-subsystem so // interactive CLI use continues to work normally. // @@ -40,14 +50,13 @@ import ( "os" "os/exec" "syscall" - "unsafe" + "github.com/step-security/dev-machine-guard/internal/faultinject" "github.com/step-security/dev-machine-guard/internal/launcher" + "github.com/step-security/dev-machine-guard/internal/winproc" "golang.org/x/sys/windows" ) -const createNoWindow uint32 = 0x08000000 - func main() { os.Exit(run(os.Args[1:])) } @@ -56,6 +65,11 @@ func main() { // after the program name (os.Args[1:] in main); accepting it explicitly // keeps the windows-only test path open if we add one later. func run(argv []string) int { + if me, err := os.Executable(); err == nil && launcher.IsBootstrapBinary(me) { + return launcher.NewBootstrap(me, winproc.IsLocalSystem).Main(argv) + } + underBootstrap := os.Getenv(launcher.EnvBootstrapVersion) != "" + target, childArgs, err := launcher.ResolveTarget(argv) if err != nil { // Two distinct failure shapes, matched to the legacy contract: @@ -76,16 +90,22 @@ func run(argv []string) int { fmt.Fprintln(os.Stderr, err) return 2 } + if underBootstrap { + return launcher.ExitCannotStart + } return 1 } cmd := exec.Command(target, childArgs...) cmd.SysProcAttr = &syscall.SysProcAttr{ HideWindow: true, - CreationFlags: createNoWindow, + CreationFlags: launcher.CreateNoWindow, } if err := cmd.Start(); err != nil { + if underBootstrap { + return launcher.ExitCannotStart + } return 1 } @@ -94,17 +114,18 @@ func run(argv []string) int { // exit, which fires the kill. Failure here only weakens lifecycle // (orphan possible on forced termination), not the work the child // was started to do. - if job, jerr := newKillOnCloseJob(); jerr == nil { - if h, oerr := windows.OpenProcess( - windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, - false, - uint32(cmd.Process.Pid), - ); oerr == nil { - _ = windows.AssignProcessToJobObject(job, h) - _ = windows.CloseHandle(h) + if job, jerr := launcher.NewKillOnCloseJob(); jerr == nil { + if aerr := launcher.AssignToJob(job, cmd.Process.Pid); aerr != nil { + _ = windows.CloseHandle(job) } } + // Lets a test build crash a version's scans after launch (harness + // baked fault boot.launched@user:exit2); a no-op in release builds. + if underBootstrap { + _ = faultinject.Point("boot.launched") + } + if err := cmd.Wait(); err != nil { var ee *exec.ExitError if errors.As(err, &ee) { @@ -114,27 +135,3 @@ func run(argv []string) int { } return 0 } - -func newKillOnCloseJob() (windows.Handle, error) { - job, err := windows.CreateJobObject(nil, nil) - if err != nil { - return 0, err - } - info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{ - BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{ - // BREAKAWAY_OK lets the agent's CREATE_BREAKAWAY_FROM_JOB spawns - // (WSL guest scans) outlive this run instead of being killed. - LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | windows.JOB_OBJECT_LIMIT_BREAKAWAY_OK, - }, - } - if _, err := windows.SetInformationJobObject( - job, - windows.JobObjectExtendedLimitInformation, - uintptr(unsafe.Pointer(&info)), - uint32(unsafe.Sizeof(info)), - ); err != nil { - _ = windows.CloseHandle(job) - return 0, err - } - return job, nil -} diff --git a/internal/launcher/bootstrap.go b/internal/launcher/bootstrap.go new file mode 100644 index 00000000..8d0806fc --- /dev/null +++ b/internal/launcher/bootstrap.go @@ -0,0 +1,632 @@ +package launcher + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/faultinject" + "github.com/step-security/dev-machine-guard/internal/updatestate" +) + +// Bootstrap mode (spec §2, D2). The MSI installs this launcher a second time +// as BootstrapBinary in the install root; under that name it resolves the +// state.json pointer and launches the versioned binaries. It must stay tiny +// and frozen-safe: no network and no crypto, never crash, and read +// state.json only through the frozen pointer contract. +const ( + BootstrapBinary = "stepsecurity-dev-machine-guard-bootstrap.exe" + + // ExitCannotStart is returned when no child could be started (D5). It is + // outside {0, 1}, so a run record carrying it classifies as bad. + ExitCannotStart = 70 + // ExitKilled is the exit status given to a child tree the bootstrap + // terminated at its deadline. + ExitKilled = 124 + // exitUsage is returned for a malformed bootstrap command line. + exitUsage = 64 + + EnvRunRecord = "STEPSEC_RUN_RECORD" + EnvBootstrapVersion = "STEPSEC_BOOTSTRAP_VERSION" + EnvBootstrapFallback = "STEPSEC_BOOTSTRAP_FALLBACK" + + // envMaxExecution mirrors telemetry.EnvMaxExecutionDuration; the + // bootstrap reads it (and config.json) to bound a scan it launched. + envMaxExecution = "STEPSEC_MAX_EXECUTION_DURATION" + + // UpdaterFailureThreshold consecutive failures of one updater version + // make `update` fall back to the seed's updater. + UpdaterFailureThreshold = 3 +) + +// Fallback reasons passed in EnvBootstrapFallback. Reasons about a named +// version carry it after a colon (current_missing:1.20.0) so the agent can +// report update_pointer_invalid with update_from = that version. +const ( + ReasonStateMissing = updatestate.FallbackStateMissing + ReasonStateCorrupt = updatestate.FallbackStateCorrupt + ReasonStateUnreadable = updatestate.FallbackStateUnreadable + ReasonCurrentMissing = updatestate.FallbackCurrentMissing + ReasonCurrentIncomplete = updatestate.FallbackCurrentIncomplete + ReasonStartFailed = updatestate.FallbackStartFailed + ReasonUpdaterFailed = updatestate.FallbackUpdaterFailed + ReasonNoUpdater = updatestate.FallbackNoUpdater +) + +const ( + defaultExecutionDeadline = 4 * time.Hour + // runDeadlineSlack lets the agent's own watchdog fire first; the scan + // task's ExecutionTimeLimit is the watchdog + 30 min. + runDeadlineSlack = 10 * time.Minute + // defaultUpdateDeadline stays under the update task's 30 min + // ExecutionTimeLimit so a hung updater is recorded as a failure. + defaultUpdateDeadline = 25 * time.Minute + maxConfigSize = 1 << 20 + maxLogSize = 1 << 20 +) + +// IsBootstrapBinary reports whether exePath is the bootstrap's file name. +func IsBootstrapBinary(exePath string) bool { + base := exePath + if i := strings.LastIndexAny(base, `\/`); i >= 0 { + base = base[i+1:] + } + return strings.EqualFold(base, BootstrapBinary) +} + +// Bootstrap runs the bootstrap verbs. All fields are injectable for tests; +// NewBootstrap fills production values. +type Bootstrap struct { + Layout updatestate.Layout + Spawn Spawner + IsSystem func() bool + Now func() time.Time + Sleep func(time.Duration) + Getenv func(string) string + Environ func() []string + Out io.Writer + + AdoptLockWait time.Duration // adopt-seed: bounded wait for the update lock + RecordLockWait time.Duration // update: wait to record the updater result + LockPoll time.Duration + UpdateDeadline time.Duration + + log io.Writer +} + +// NewBootstrap returns a production bootstrap rooted at the directory of +// exePath, with runs under the default data root. +func NewBootstrap(exePath string, isSystem func() bool) *Bootstrap { + return &Bootstrap{ + Layout: updatestate.Layout{InstallRoot: filepath.Dir(exePath), DataRoot: updatestate.DefaultDataRoot}, + Spawn: SpawnHidden, + IsSystem: isSystem, + Now: time.Now, + Sleep: time.Sleep, + Getenv: os.Getenv, + Environ: os.Environ, + Out: os.Stdout, + AdoptLockWait: 60 * time.Second, + RecordLockWait: 30 * time.Second, + LockPoll: 500 * time.Millisecond, + UpdateDeadline: defaultUpdateDeadline, + } +} + +// Main dispatches a bootstrap command line (argv without the program name). +func (b *Bootstrap) Main(argv []string) int { + if len(argv) == 0 { + b.logf("usage: %s run | update | adopt-seed --seed-dir ", BootstrapBinary) + return exitUsage + } + switch argv[0] { + case "run": + return b.Run(argv[1:]) + case "update": + return b.Update() + case "adopt-seed": + return b.AdoptSeed(argv[1:]) + default: + b.logf("unknown bootstrap verb %q", argv[0]) + return exitUsage + } +} + +func (b *Bootstrap) logf(format string, args ...any) { + line := b.Now().UTC().Format(time.RFC3339) + " bootstrap: " + fmt.Sprintf(format, args...) + "\n" + if b.Out != nil { + _, _ = io.WriteString(b.Out, line) + } + if b.log != nil { + _, _ = io.WriteString(b.log, line) + } +} + +// openUpdateLog appends SYSTEM-verb logs to state\update.log (best effort, +// restarted when it grows past maxLogSize). +func (b *Bootstrap) openUpdateLog() func() { + path := b.Layout.LogFile() + flags := os.O_CREATE | os.O_WRONLY | os.O_APPEND + if fi, err := os.Stat(path); err == nil && fi.Size() > maxLogSize { + flags |= os.O_TRUNC + } + f, err := os.OpenFile(path, flags, 0o644) // #nosec G304 -- fixed path in the protected state dir + if err != nil { + return func() {} + } + b.log = f + return func() { b.log = nil; _ = f.Close() } +} + +func complete(dir string) bool { return updatestate.VersionDirUsable(dir) } + +// installDirArg returns the --install-dir value in args, if any. +func installDirArg(args []string) string { + for i, a := range args { + switch { + case strings.HasPrefix(a, "--install-dir="): + return strings.Trim(strings.TrimPrefix(a, "--install-dir="), `"`) + case a == "--install-dir" && i+1 < len(args): + return strings.Trim(args[i+1], `"`) + } + } + return "" +} + +// childEnv is the bootstrap's environment with our variables replaced. +func (b *Bootstrap) childEnv(set map[string]string) []string { + ours := []string{EnvRunRecord, EnvBootstrapVersion, EnvBootstrapFallback} + var env []string + for _, kv := range b.Environ() { + k, _, _ := strings.Cut(kv, "=") + drop := false + for _, o := range ours { + if strings.EqualFold(k, o) { + drop = true + } + } + if !drop { + env = append(env, kv) + } + } + for _, k := range ours { + if v, ok := set[k]; ok && v != "" { + env = append(env, k+"="+v) + } + } + return env +} + +func parseDeadline(v string) (time.Duration, bool) { + switch v { + case "": + return 0, false + case "0", "off": + return 0, true + } + d, err := time.ParseDuration(v) + if err != nil || d <= 0 { + return 0, false + } + return d, true +} + +// runDeadline mirrors the agent's execution watchdog (env > config.json > +// 4 h) plus slack. It catches a scan that hangs before the agent arms its +// own watchdog. 0 disables it, as for the agent. +func (b *Bootstrap) runDeadline(dataRoot string) time.Duration { + d, ok := parseDeadline(b.Getenv(envMaxExecution)) + if !ok { + var cfg struct { + MaxExecutionDuration string `json:"max_execution_duration"` + } + if data, err := readSmall(filepath.Join(dataRoot, "config.json"), maxConfigSize); err == nil && json.Unmarshal(data, &cfg) == nil { + d, ok = parseDeadline(cfg.MaxExecutionDuration) + } + } + if !ok { + d = defaultExecutionDeadline + } + if d == 0 { + return 0 + } + return d + runDeadlineSlack +} + +func readSmall(path string, limit int64) ([]byte, error) { + f, err := os.Open(path) // #nosec G304 -- the agent's own config path + if err != nil { + return nil, err + } + defer f.Close() + data, err := io.ReadAll(io.LimitReader(f, limit+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > limit { + return nil, errors.New("too large") + } + return data, nil +} + +func checkpointLogged(b *Bootstrap, name string) { + if err := faultinject.Point(name); err != nil { + b.logf("checkpoint %s: %v (continuing)", name, err) + } +} + +// Run is `run `: launch versions\\task.exe with args from +// the first usable of current → previous → seed, record the run, and return +// the child's exit code. +func (b *Bootstrap) Run(args []string) int { + layout := b.Layout + if d := installDirArg(args); d != "" { + layout.DataRoot = d + } + p, corrupt, err := updatestate.LoadPointers(layout.StateFile()) + if err != nil { + b.logf("reading state: %v", err) + } + cands := updatestate.ResolveRunChain(layout, p, updatestate.LoadReason(p != nil, corrupt, err)) + checkpointLogged(b, "boot.resolved") + if len(cands) == 0 { + b.logf("no launchable version (state, previous and seed all unusable)") + return ExitCannotStart + } + deadline := b.runDeadline(layout.DataRoot) + startReason := "" + for _, c := range cands { + reason := c.Reason + if startReason != "" { + reason = startReason + } + runFile := layout.RunFile(c.Version) + if err := updatestate.AppendRun(runFile, updatestate.RunRecord{Version: c.Version.String()}); err != nil { + b.logf("run record %s: %v", runFile, err) + } + env := b.childEnv(map[string]string{ + EnvRunRecord: runFile, + EnvBootstrapVersion: c.Version.String(), + EnvBootstrapFallback: reason, + }) + res := b.Spawn(filepath.Join(c.Dir, VersionedTaskBinary), args, env, deadline, func() { + checkpointLogged(b, "boot.launched") + }) + if !res.Started { + b.logf("starting %s: %v", c.Dir, res.Err) + b.finalize(runFile, ExitCannotStart, false) + startReason = ReasonStartFailed + ":" + c.Version.String() + continue + } + code := res.ExitCode + if res.Killed { + code = ExitKilled + } + b.finalize(runFile, code, res.Killed) + return code + } + return ExitCannotStart +} + +func (b *Bootstrap) finalize(runFile string, code int, killed bool) { + if err := updatestate.FinalizeRun(runFile, code, killed, b.Now()); err != nil { + b.logf("finalizing run record %s: %v", runFile, err) + } +} + +// lockWithWait polls for the update lock for up to wait. +func (b *Bootstrap) lockWithWait(wait time.Duration) (*updatestate.Lock, error) { + deadline := b.Now().Add(wait) + for { + l, err := updatestate.AcquireLock(b.Layout.LockFile()) + if err == nil { + return l, nil + } + if !errors.Is(err, updatestate.ErrLocked) || !b.Now().Before(deadline) { + return nil, err + } + b.Sleep(b.LockPoll) + } +} + +// Update is `update` (SYSTEM): pick the updater binary, run +// `agent.exe self-update --system`, and record its result in state.updater. +func (b *Bootstrap) Update() int { + if !b.IsSystem() { + b.logf("update must run as SYSTEM") + return 1 + } + defer b.openUpdateLog()() + + p, corrupt, err := updatestate.LoadPointers(b.Layout.StateFile()) + if err != nil { + b.logf("reading state: %v", err) + } + var primary *updatestate.RunChoice + reason := updatestate.LoadReason(p != nil, corrupt, err) + if p != nil { + pick := p.Current + if p.Probation != "" && p.Probation == p.Current { + pick = p.Previous + } + if pick == "" { + reason = ReasonNoUpdater + } else if v, why := b.Layout.CheckVersionDir(pick); why != "" { + reason = why + } else if p.Updater.Version == pick && p.Updater.ConsecutiveFailures >= UpdaterFailureThreshold { + reason = ReasonUpdaterFailed + ":" + pick + } else { + primary = &updatestate.RunChoice{Version: v, Dir: b.Layout.VersionDir(v)} + } + } + var cands []updatestate.RunChoice + if primary != nil { + cands = append(cands, *primary) + } + if v, ok := b.Layout.FindSeed(); ok { + cands = append(cands, updatestate.RunChoice{Version: v, Dir: b.Layout.SeedDir(v), Reason: reason}) + } + checkpointLogged(b, "boot.resolved") + if len(cands) == 0 { + b.logf("no updater binary available") + return ExitCannotStart + } + for i, c := range cands { + isPrimary := primary != nil && i == 0 + if !isPrimary { + b.logf("updater fallback to seed %s (%s)", c.Version, c.Reason) + } + agent := filepath.Join(c.Dir, VersionedAgentBinary) + res := b.Spawn(agent, []string{"self-update", "--system"}, b.childEnv(nil), b.UpdateDeadline, func() { + checkpointLogged(b, "boot.launched") + }) + code := res.ExitCode + switch { + case !res.Started: + b.logf("starting updater %s: %v", agent, res.Err) + code = ExitCannotStart + case res.Killed: + b.logf("updater %s killed after %s", c.Version, b.UpdateDeadline) + code = ExitKilled + } + b.logf("updater %s exited %d", c.Version, code) + if isPrimary { + b.recordUpdater(c.Version.String(), code) + } + if res.Started { + return code + } + } + return ExitCannotStart +} + +// recordUpdater stores the updater result for the next tick's selection. +func (b *Bootstrap) recordUpdater(version string, code int) { + l, err := b.lockWithWait(b.RecordLockWait) + if err != nil { + b.logf("not recording updater result (lock: %v)", err) + return + } + defer l.Release() + err = updatestate.PatchCheckpointed(b.Layout.StateFile(), "boot", func(s *updatestate.State) error { + switch { + case code == 0: + s.Updater = updatestate.Updater{Version: version} + case s.Updater.Version == version: + s.Updater.ConsecutiveFailures++ + default: + s.Updater = updatestate.Updater{Version: version, ConsecutiveFailures: 1} + } + return nil + }) + if err != nil { + b.logf("recording updater result: %v", err) + } +} + +// errNoChange aborts a state patch that has nothing to write. +var errNoChange = errors.New("no change") + +// AdoptSeed is `adopt-seed --seed-dir ` (SYSTEM, MSI custom action): +// protect versions\ and state\, copy the seed into versions\ if +// absent, and point current at it when current is unusable or older. It +// always exits 0: it must never fail the MSI. +func (b *Bootstrap) AdoptSeed(args []string) int { + seedDir := "" + for i, a := range args { + switch { + case strings.HasPrefix(a, "--seed-dir="): + seedDir = strings.TrimPrefix(a, "--seed-dir=") + case a == "--seed-dir" && i+1 < len(args): + seedDir = args[i+1] + } + } + if seedDir == "" { + b.logf("adopt-seed: --seed-dir is required") + return 0 + } + if !b.IsSystem() { + b.logf("adopt-seed must run as SYSTEM") + return 0 + } + seedDir = filepath.Clean(strings.Trim(seedDir, `"`)) + v, err := updatestate.ParseVersion(filepath.Base(seedDir)) + if err != nil { + b.logf("adopt-seed: seed dir %q is not seed\\: %v", seedDir, err) + return 0 + } + if !samePath(seedDir, b.Layout.SeedDir(v)) { + b.logf("adopt-seed: seed dir %q is not %q", seedDir, b.Layout.SeedDir(v)) + return 0 + } + if !complete(seedDir) { + b.logf("adopt-seed: seed %s is incomplete", seedDir) + return 0 + } + for _, d := range []string{b.Layout.VersionsDir(), b.Layout.StateDir()} { + fixed, err := updatestate.EnsureProtectedDir(d) + if err != nil { + b.logf("adopt-seed: protecting %s: %v", d, err) + return 0 + } + if fixed { + b.logf("adopt-seed: re-asserted owner/DACL on %s", d) + } + } + defer b.openUpdateLog()() + + l, err := b.lockWithWait(b.AdoptLockWait) + if err != nil { + b.logf("adopt-seed: update lock not taken (%v); the updater converges on its next tick", err) + return 0 + } + defer l.Release() + + if err := b.copySeed(v, seedDir); err != nil { + b.logf("adopt-seed: copying seed %s: %v", v, err) + } + if !complete(b.Layout.VersionDir(v)) { + b.logf("adopt-seed: versions\\%s unusable; state left unchanged", v) + return 0 + } + b.adoptState(v) + return 0 +} + +func samePath(a, b string) bool { + a, b = filepath.Clean(a), filepath.Clean(b) + if filepath.Separator == '\\' { + return strings.EqualFold(a, b) + } + return a == b +} + +// copySeed copies the seed into versions\ through a staging dir and an +// atomic rename. A complete existing dir is kept (immutable); an incomplete +// one is moved to trash first. +func (b *Bootstrap) copySeed(v updatestate.Version, seedDir string) error { + dst := b.Layout.VersionDir(v) + if _, err := os.Lstat(dst); err == nil { + if complete(dst) { + return nil + } + trash := filepath.Join(b.Layout.VersionsDir(), updatestate.TrashPrefix+v.String()+"-"+strconv.FormatInt(b.Now().UnixNano(), 36)) + if err := os.Rename(dst, trash); err != nil { + return fmt.Errorf("moving incomplete %s aside: %w", dst, err) + } + b.logf("adopt-seed: moved incomplete %s to %s", dst, trash) + _ = os.RemoveAll(trash) + } + staging, err := b.Layout.StagingDir(v) + if err != nil { + return err + } + done := false + defer func() { + if !done { + _ = os.RemoveAll(staging) + } + }() + entries, err := os.ReadDir(seedDir) + if err != nil { + return err + } + for _, e := range entries { + if !e.Type().IsRegular() { + continue + } + if err := copyFile(filepath.Join(seedDir, e.Name()), filepath.Join(staging, e.Name())); err != nil { + return err + } + } + if err := faultinject.Point("adopt.copied"); err != nil { + return err + } + if err := os.Rename(staging, dst); err != nil { + return err + } + done = true + b.logf("adopt-seed: copied seed %s into %s", v, dst) + return nil +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) // #nosec G304 -- MSI-owned seed payload + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o755) // #nosec G304 G302 -- fresh staging dir + if err != nil { + return err + } + _, err = io.Copy(out, in) + if err == nil { + err = out.Sync() + } + if cerr := out.Close(); err == nil { + err = cerr + } + return err +} + +// adoptState sets current = seed when current is missing, invalid or +// unusable, or older than the seed (and the seed is not quarantined). An +// MSI never downgrades a self-updated device. +func (b *Bootstrap) adoptState(seed updatestate.Version) { + path := b.Layout.StateFile() + p, corrupt, err := updatestate.LoadPointers(path) + switch { + case err != nil: + b.logf("adopt-seed: reading state: %v; left unchanged", err) + return + case p == nil: + st := &updatestate.State{Schema: updatestate.SchemaVersion, Current: seed.String()} + if err := updatestate.SaveCheckpointed(path, st, "adopt"); err != nil { + b.logf("adopt-seed: writing state: %v", err) + return + } + b.logf("adopt-seed: state %s; current = seed %s", map[bool]string{true: "corrupt", false: "missing"}[corrupt], seed) + return + case p.Schema > updatestate.SchemaVersion: + b.logf("adopt-seed: state schema %d is newer than this bootstrap; left unchanged", p.Schema) + return + } + s := seed.String() + err = updatestate.PatchCheckpointed(path, "adopt", func(st *updatestate.State) error { + old := st.Current + cur, _ := updatestate.ParseVersion(old) + if !complete(b.Layout.VersionDir(cur)) { + st.Current = s + if st.Probation != nil && st.Probation.Version == old { + st.Probation = nil + } + if st.Previous == s { + st.Previous = "" + } + b.logf("adopt-seed: current %s unusable; current = seed %s", old, s) + return nil + } + if updatestate.Compare(seed, cur) <= 0 { + return errNoChange + } + if _, q := st.Quarantine[s]; q { + b.logf("adopt-seed: seed %s is quarantined; current %s kept", s, old) + return errNoChange + } + st.Previous = old + st.Current = s + st.Probation = nil + b.logf("adopt-seed: seed %s newer than current %s; current = seed", s, old) + return nil + }) + if err != nil && !errors.Is(err, errNoChange) { + b.logf("adopt-seed: updating state: %v", err) + } +} diff --git a/internal/launcher/bootstrap_faults_test.go b/internal/launcher/bootstrap_faults_test.go new file mode 100644 index 00000000..ff383ef6 --- /dev/null +++ b/internal/launcher/bootstrap_faults_test.go @@ -0,0 +1,61 @@ +//go:build selfupdate_faults + +package launcher + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestAdoptSeed_FaultAtCopiedLeavesNoStagingAndNoPointer(t *testing.T) { + t.Setenv("DMG_FAULT", "adopt.copied:err") + f := newFixture(t) + f.adopt(f.seed("1.5.0")) + entries, _ := os.ReadDir(f.layout.VersionsDir()) + if len(entries) != 0 { + t.Fatalf("versions not empty: %v", entries) + } + if _, err := os.Stat(f.layout.StateFile()); !os.IsNotExist(err) { + t.Fatal("state points at a seed that was never renamed in") + } + // The next adopt converges. + t.Setenv("DMG_FAULT", "") + f.adopt(filepath.Join(f.layout.InstallRoot, "seed", "1.5.0")) + if f.load().Current != "1.5.0" { + t.Fatal("did not converge") + } +} + +func TestAdoptSeed_FaultAtStateTempKeepsOldState(t *testing.T) { + f := newFixture(t) + f.state(`{"schema":1,"current":"1.4.0"}`) + f.version("1.4.0") + t.Setenv("DMG_FAULT", "adopt.state-temp:err") + f.adopt(f.seed("1.5.0")) + if f.load().Current != "1.4.0" { + t.Fatal("state changed despite the fault") + } + t.Setenv("DMG_FAULT", "") + f.adopt(filepath.Join(f.layout.InstallRoot, "seed", "1.5.0")) + if st := f.load(); st.Current != "1.5.0" || st.Previous != "1.4.0" { + t.Fatalf("did not converge: %+v", st) + } +} + +func TestRun_CheckpointErrorsDoNotBlockLaunch(t *testing.T) { + t.Setenv("DMG_FAULT", "boot.resolved:err,boot.launched:err") + trace := filepath.Join(t.TempDir(), "trace") + t.Setenv("DMG_FAULT_TRACE", trace) + f := newFixture(t) + f.state(`{"schema":1,"current":"1.2.0"}`) + f.version("1.2.0") + if code := f.b.Run(scanArgs(f.layout.DataRoot)); code != 0 || len(f.sp.calls) != 1 { + t.Fatalf("code=%d calls=%d", code, len(f.sp.calls)) + } + got, _ := os.ReadFile(trace) + if !strings.Contains(string(got), "boot.resolved\nboot.launched\n") { + t.Fatalf("trace = %q", got) + } +} diff --git a/internal/launcher/bootstrap_test.go b/internal/launcher/bootstrap_test.go new file mode 100644 index 00000000..d70d1c6e --- /dev/null +++ b/internal/launcher/bootstrap_test.go @@ -0,0 +1,654 @@ +package launcher + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/step-security/dev-machine-guard/internal/updatestate" +) + +type spawnCall struct { + path string + args []string + env []string + deadline time.Duration +} + +type fakeSpawner struct { + calls []spawnCall + results []SpawnResult // consumed in order; default exit 0 + during func(call spawnCall) +} + +func (f *fakeSpawner) spawn(path string, args, env []string, deadline time.Duration, onStart func()) SpawnResult { + c := spawnCall{path, append([]string(nil), args...), env, deadline} + f.calls = append(f.calls, c) + res := SpawnResult{Started: true} + if len(f.results) > 0 { + res, f.results = f.results[0], f.results[1:] + } + if res.Started { + onStart() + if f.during != nil { + f.during(c) + } + } + return res +} + +func (c spawnCall) envValue(key string) (string, bool) { + for _, kv := range c.env { + if k, v, _ := strings.Cut(kv, "="); k == key { + return v, true + } + } + return "", false +} + +type fixture struct { + t *testing.T + b *Bootstrap + sp *fakeSpawner + out *bytes.Buffer + layout updatestate.Layout +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + root := t.TempDir() + l := updatestate.Layout{InstallRoot: filepath.Join(root, "install"), DataRoot: filepath.Join(root, "data")} + for _, d := range []string{l.VersionsDir(), l.StateDir(), l.DataRoot} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + sp := &fakeSpawner{} + out := &bytes.Buffer{} + now := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC) + b := &Bootstrap{ + Layout: l, + Spawn: sp.spawn, + IsSystem: func() bool { return true }, + Now: func() time.Time { return now }, + Sleep: func(d time.Duration) { now = now.Add(d) }, + Getenv: func(string) string { return "" }, + Environ: func() []string { + return []string{"PATH=/bin", "STEPSEC_RUN_RECORD=stale", "STEPSEC_BOOTSTRAP_FALLBACK=stale"} + }, + Out: out, + AdoptLockWait: 2 * time.Second, + RecordLockWait: time.Second, + LockPoll: 500 * time.Millisecond, + UpdateDeadline: defaultUpdateDeadline, + } + return &fixture{t: t, b: b, sp: sp, out: out, layout: l} +} + +func mkVersionDir(t *testing.T, dir string, files ...string) { + t.Helper() + if len(files) == 0 { + files = []string{VersionedTaskBinary, VersionedAgentBinary, "dmg-linux"} + } + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + for _, f := range files { + if err := os.WriteFile(filepath.Join(dir, f), []byte("bin:"+filepath.Base(dir)+":"+f), 0o755); err != nil { + t.Fatal(err) + } + } +} + +func (f *fixture) version(v string, files ...string) { + mkVersionDir(f.t, filepath.Join(f.layout.VersionsDir(), v), files...) +} + +func (f *fixture) seed(v string) string { + d := filepath.Join(f.layout.InstallRoot, "seed", v) + mkVersionDir(f.t, d) + return d +} + +func (f *fixture) state(body string) { + if err := os.WriteFile(f.layout.StateFile(), []byte(body), 0o600); err != nil { + f.t.Fatal(err) + } +} + +func (f *fixture) load() *updatestate.State { + st, corrupt, err := updatestate.Load(f.layout.StateFile()) + if err != nil || corrupt || st == nil { + f.t.Fatalf("load state: st=%v corrupt=%v err=%v", st, corrupt, err) + } + return st +} + +func scanArgs(dataRoot string) []string { + return []string{"send-telemetry", "--install-dir=" + dataRoot} +} + +// TestRun_BootstrapMatrix: every state.json / directory corruption case +// must still launch something (harness §2 "Bootstrap matrix"). +func TestRun_BootstrapMatrix(t *testing.T) { + huge := `{"schema":1,"current":"1.2.0","pad":"` + strings.Repeat("x", 2<<20) + `"}` + cases := []struct { + name string + state *string // nil = no file + versions map[string][]string + seed bool + wantDir string // "seed" or a version + wantReason string + }{ + {"healthy current", ptr(`{"schema":1,"current":"1.2.0","previous":"1.1.0"}`), map[string][]string{"1.2.0": nil, "1.1.0": nil}, true, "1.2.0", ""}, + {"state missing", nil, map[string][]string{"1.2.0": nil}, true, "seed", ReasonStateMissing}, + {"state empty", ptr(``), map[string][]string{"1.2.0": nil}, true, "seed", ReasonStateCorrupt}, + {"state garbage", ptr(`{{{`), map[string][]string{"1.2.0": nil}, true, "seed", ReasonStateCorrupt}, + {"state unknown old schema", ptr(`{"schema":0,"current":"1.2.0"}`), map[string][]string{"1.2.0": nil}, true, "seed", ReasonStateCorrupt}, + {"state newer schema still read", ptr(`{"schema":9,"current":"1.2.0","previous":"1.1.0","new":{"x":1}}`), map[string][]string{"1.2.0": nil}, true, "1.2.0", ""}, + {"state huge", &huge, map[string][]string{"1.2.0": nil}, true, "seed", ReasonStateCorrupt}, + {"current traversal", ptr(`{"schema":1,"current":"..\\x","previous":"1.1.0"}`), map[string][]string{"1.1.0": nil}, true, "seed", ReasonStateCorrupt}, + {"newer schema traversal", ptr(`{"schema":2,"current":"../x"}`), nil, true, "seed", ReasonStateCorrupt}, + {"current dir missing", ptr(`{"schema":1,"current":"1.2.0","previous":"1.1.0"}`), map[string][]string{"1.1.0": nil}, true, "1.1.0", ReasonCurrentMissing + ":1.2.0"}, + {"current missing task.exe", ptr(`{"schema":1,"current":"1.2.0","previous":"1.1.0"}`), map[string][]string{"1.2.0": {VersionedAgentBinary}, "1.1.0": nil}, true, "1.1.0", ReasonCurrentIncomplete + ":1.2.0"}, + {"current missing agent.exe", ptr(`{"schema":1,"current":"1.2.0","previous":"1.1.0"}`), map[string][]string{"1.2.0": {VersionedTaskBinary}, "1.1.0": nil}, true, "1.1.0", ReasonCurrentIncomplete + ":1.2.0"}, + {"current and previous missing", ptr(`{"schema":1,"current":"1.2.0","previous":"1.1.0"}`), nil, true, "seed", ReasonCurrentMissing + ":1.2.0"}, + {"no previous", ptr(`{"schema":1,"current":"1.2.0"}`), nil, true, "seed", ReasonCurrentMissing + ":1.2.0"}, + {"seed missing, current fine", ptr(`{"schema":1,"current":"1.2.0"}`), map[string][]string{"1.2.0": nil}, false, "1.2.0", ""}, + {"seed missing, previous used", ptr(`{"schema":1,"current":"1.2.0","previous":"1.1.0"}`), map[string][]string{"1.1.0": nil}, false, "1.1.0", ReasonCurrentMissing + ":1.2.0"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := newFixture(t) + if tc.state != nil { + f.state(*tc.state) + } + for v, files := range tc.versions { + f.version(v, files...) + } + seedDir := "" + if tc.seed { + seedDir = f.seed("1.0.0") + } + code := f.b.Run(scanArgs(f.layout.DataRoot)) + if code != 0 { + t.Fatalf("exit = %d; log:\n%s", code, f.out) + } + if len(f.sp.calls) != 1 { + t.Fatalf("spawned %d times", len(f.sp.calls)) + } + c := f.sp.calls[0] + wantDir, wantVer := seedDir, "1.0.0" + if tc.wantDir != "seed" { + wantDir, wantVer = filepath.Join(f.layout.VersionsDir(), tc.wantDir), tc.wantDir + } + if c.path != filepath.Join(wantDir, VersionedTaskBinary) { + t.Fatalf("launched %s, want under %s", c.path, wantDir) + } + if strings.Join(c.args, " ") != strings.Join(scanArgs(f.layout.DataRoot), " ") { + t.Fatalf("args = %v", c.args) + } + got, ok := c.envValue(EnvBootstrapFallback) + if tc.wantReason == "" && ok { + t.Fatalf("unexpected fallback %q", got) + } + if tc.wantReason != "" && got != tc.wantReason { + t.Fatalf("fallback = %q, want %q", got, tc.wantReason) + } + if v, _ := c.envValue(EnvBootstrapVersion); v != wantVer { + t.Fatalf("version env = %q", v) + } + runFile := filepath.Join(f.layout.DataRoot, "runs", wantVer+".json") + if v, _ := c.envValue(EnvRunRecord); v != runFile { + t.Fatalf("run record env = %q, want %q", v, runFile) + } + for _, kv := range c.env { + if strings.Contains(kv, "stale") { + t.Fatalf("stale env leaked: %v", c.env) + } + } + recs := updatestate.ReadRuns(runFile) + if len(recs) != 1 || recs[0].ExitCode == nil || *recs[0].ExitCode != 0 { + t.Fatalf("run records = %+v", recs) + } + }) + } +} + +func ptr(s string) *string { return &s } + +func TestRun_NothingLaunchableExits70(t *testing.T) { + f := newFixture(t) + f.state(`garbage`) + if code := f.b.Run(scanArgs(f.layout.DataRoot)); code != ExitCannotStart { + t.Fatalf("exit = %d", code) + } + if len(f.sp.calls) != 0 { + t.Fatal("spawned with nothing launchable") + } +} + +func TestRun_StartFailureFallsThroughAndRecords70(t *testing.T) { + f := newFixture(t) + f.state(`{"schema":1,"current":"1.2.0"}`) + f.version("1.2.0") + f.seed("1.0.0") + f.sp.results = []SpawnResult{{Err: errors.New("access denied")}, {Started: true, ExitCode: 0}} + if code := f.b.Run(scanArgs(f.layout.DataRoot)); code != 0 { + t.Fatalf("exit = %d", code) + } + recs := updatestate.ReadRuns(f.layout.RunFile(updatestate.Version{Major: 1, Minor: 2})) + if len(recs) != 1 || *recs[0].ExitCode != ExitCannotStart || updatestate.ClassifyRun(recs[0]) != updatestate.RunBad { + t.Fatalf("1.2.0 records = %+v", recs) + } + if r, _ := f.sp.calls[1].envValue(EnvBootstrapFallback); r != ReasonStartFailed+":1.2.0" { + t.Fatalf("fallback = %q", r) + } +} + +func TestRun_AllStartsFailExit70(t *testing.T) { + f := newFixture(t) + f.state(`{"schema":1,"current":"1.2.0"}`) + f.version("1.2.0") + f.seed("1.0.0") + f.sp.results = []SpawnResult{{Err: errors.New("x")}, {Err: errors.New("y")}} + if code := f.b.Run(scanArgs(f.layout.DataRoot)); code != ExitCannotStart { + t.Fatalf("exit = %d", code) + } +} + +func TestRun_ChildExitAndWatchdogRecorded(t *testing.T) { + f := newFixture(t) + f.state(`{"schema":1,"current":"1.2.0"}`) + f.version("1.2.0") + runFile := f.layout.RunFile(updatestate.Version{Major: 1, Minor: 2}) + // The agent marks started while it runs. + f.sp.during = func(spawnCall) { + if err := updatestate.MarkRunStarted(runFile, "exec-1"); err != nil { + t.Fatal(err) + } + } + f.sp.results = []SpawnResult{{Started: true, ExitCode: 0}, {Started: true, Killed: true, ExitCode: 1}} + f.b.Run(scanArgs(f.layout.DataRoot)) + if code := f.b.Run(scanArgs(f.layout.DataRoot)); code != ExitKilled { + t.Fatalf("killed exit = %d", code) + } + recs := updatestate.ReadRuns(runFile) + if len(recs) != 2 { + t.Fatalf("records = %+v", recs) + } + if updatestate.ClassifyRun(recs[0]) != updatestate.RunHealthy || recs[0].ExecutionID != "exec-1" { + t.Fatalf("first = %+v", recs[0]) + } + if !recs[1].WatchdogKilled || updatestate.ClassifyRun(recs[1]) != updatestate.RunBad { + t.Fatalf("second = %+v", recs[1]) + } +} + +func TestRun_UnwritableRunsDirStillLaunches(t *testing.T) { + f := newFixture(t) + f.state(`{"schema":1,"current":"1.2.0"}`) + f.version("1.2.0") + // A file where runs\ should be: records can't be written. + if err := os.WriteFile(f.layout.RunsDir(), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + f.sp.results = []SpawnResult{{Started: true, ExitCode: 3}} + if code := f.b.Run(scanArgs(f.layout.DataRoot)); code != 3 { + t.Fatalf("exit = %d", code) + } +} + +func TestRunDeadline(t *testing.T) { + f := newFixture(t) + if d := f.b.runDeadline(f.layout.DataRoot); d != defaultExecutionDeadline+runDeadlineSlack { + t.Fatalf("default = %s", d) + } + if err := os.WriteFile(filepath.Join(f.layout.DataRoot, "config.json"), []byte(`{"max_execution_duration":"2h"}`), 0o600); err != nil { + t.Fatal(err) + } + if d := f.b.runDeadline(f.layout.DataRoot); d != 2*time.Hour+runDeadlineSlack { + t.Fatalf("config = %s", d) + } + f.b.Getenv = func(k string) string { + if k == envMaxExecution { + return "off" + } + return "" + } + if d := f.b.runDeadline(f.layout.DataRoot); d != 0 { + t.Fatalf("off = %s", d) + } + f.b.Getenv = func(k string) string { + if k == envMaxExecution { + return "3s" + } + return "" + } + f.state(`{"schema":1,"current":"1.2.0"}`) + f.version("1.2.0") + f.b.Run(scanArgs(f.layout.DataRoot)) + if got := f.sp.calls[0].deadline; got != 3*time.Second+runDeadlineSlack { + t.Fatalf("spawn deadline = %s", got) + } +} + +func TestMain_Dispatch(t *testing.T) { + f := newFixture(t) + if code := f.b.Main(nil); code != exitUsage { + t.Fatalf("no verb = %d", code) + } + if code := f.b.Main([]string{"bogus"}); code != exitUsage { + t.Fatalf("bogus verb = %d", code) + } +} + +func TestIsBootstrapBinary(t *testing.T) { + for in, want := range map[string]bool{ + `C:\Program Files\StepSecurity\stepsecurity-dev-machine-guard-bootstrap.exe`: true, + `C:\x\STEPSECURITY-DEV-MACHINE-GUARD-BOOTSTRAP.EXE`: true, + `C:\x\versions\1.2.0\task.exe`: false, + `/tmp/stepsecurity-dev-machine-guard-task.exe`: false, + } { + if got := IsBootstrapBinary(in); got != want { + t.Errorf("%s = %v", in, got) + } + } +} + +// --- update --- + +func (f *fixture) updaterRan() string { + f.t.Helper() + if len(f.sp.calls) != 1 { + f.t.Fatalf("spawned %d times; log:\n%s", len(f.sp.calls), f.out) + } + c := f.sp.calls[0] + if filepath.Base(c.path) != VersionedAgentBinary || strings.Join(c.args, " ") != "self-update --system" { + f.t.Fatalf("call = %+v", c) + } + rel, _ := filepath.Rel(f.layout.InstallRoot, filepath.Dir(c.path)) + return filepath.ToSlash(rel) +} + +func TestUpdate_Selection(t *testing.T) { + cases := []struct { + name string + state *string + versions []string + want string + }{ + {"fresh install no state", nil, nil, "seed/1.0.0"}, + {"corrupt state", ptr(`nope`), []string{"1.2.0"}, "seed/1.0.0"}, + {"confirmed current", ptr(`{"schema":1,"current":"1.2.0","previous":"1.1.0"}`), []string{"1.2.0", "1.1.0"}, "versions/1.2.0"}, + {"probation uses previous", ptr(`{"schema":1,"current":"1.2.0","previous":"1.1.0","probation":{"version":"1.2.0"}}`), []string{"1.2.0", "1.1.0"}, "versions/1.1.0"}, + {"probation without previous uses seed", ptr(`{"schema":1,"current":"1.2.0","probation":{"version":"1.2.0"}}`), []string{"1.2.0"}, "seed/1.0.0"}, + {"current dir missing", ptr(`{"schema":1,"current":"1.2.0","previous":"1.1.0"}`), []string{"1.1.0"}, "seed/1.0.0"}, + {"two failures keep current", ptr(`{"schema":1,"current":"1.2.0","updater":{"version":"1.2.0","consecutive_failures":2}}`), []string{"1.2.0"}, "versions/1.2.0"}, + {"three failures use seed", ptr(`{"schema":1,"current":"1.2.0","updater":{"version":"1.2.0","consecutive_failures":3}}`), []string{"1.2.0"}, "seed/1.0.0"}, + {"failures of another version ignored", ptr(`{"schema":1,"current":"1.3.0","updater":{"version":"1.2.0","consecutive_failures":5}}`), []string{"1.3.0"}, "versions/1.3.0"}, + {"newer schema reads current", ptr(`{"schema":4,"current":"1.2.0"}`), []string{"1.2.0"}, "versions/1.2.0"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := newFixture(t) + if tc.state != nil { + f.state(*tc.state) + } + for _, v := range tc.versions { + f.version(v) + } + f.seed("1.0.0") + if code := f.b.Update(); code != 0 { + t.Fatalf("exit = %d", code) + } + if got := f.updaterRan(); got != tc.want { + t.Fatalf("updater = %s, want %s", got, tc.want) + } + if f.sp.calls[0].deadline != defaultUpdateDeadline { + t.Fatalf("deadline = %s", f.sp.calls[0].deadline) + } + }) + } +} + +func TestUpdate_RequiresSystem(t *testing.T) { + f := newFixture(t) + f.b.IsSystem = func() bool { return false } + if code := f.b.Update(); code == 0 || len(f.sp.calls) != 0 { + t.Fatalf("code=%d calls=%d", code, len(f.sp.calls)) + } +} + +func TestUpdate_NoUpdaterExit70(t *testing.T) { + f := newFixture(t) + if code := f.b.Update(); code != ExitCannotStart { + t.Fatalf("exit = %d", code) + } +} + +func TestUpdate_RecordsConsecutiveFailures(t *testing.T) { + f := newFixture(t) + f.state(`{"schema":1,"current":"1.2.0","future":"kept"}`) + f.version("1.2.0") + f.seed("1.0.0") + for i, res := range []SpawnResult{ + {Started: true, ExitCode: 1}, + {Started: true, Killed: true}, + {Err: errors.New("gone")}, // start failure counts, then seed runs + } { + f.sp.calls = nil + f.sp.results = []SpawnResult{res, {Started: true}} + f.b.Update() + if got := f.load().Updater; got.Version != "1.2.0" || got.ConsecutiveFailures != i+1 { + t.Fatalf("after failure %d: %+v", i+1, got) + } + } + // Threshold reached: the seed runs and the record is left alone. + f.sp.calls = nil + f.sp.results = nil + f.b.Update() + if got := f.updaterRan(); got != "seed/1.0.0" { + t.Fatalf("updater = %s", got) + } + if got := f.load().Updater; got.ConsecutiveFailures != 3 { + t.Fatalf("seed run changed the record: %+v", got) + } + // A new current resets selection; success clears the counter. + f.state(`{"schema":1,"current":"1.3.0","updater":{"version":"1.2.0","consecutive_failures":3}}`) + f.version("1.3.0") + f.sp.calls = nil + f.b.Update() + if got := f.load().Updater; got.Version != "1.3.0" || got.ConsecutiveFailures != 0 { + t.Fatalf("after success: %+v", got) + } +} + +func TestUpdate_KeepsUnknownStateFields(t *testing.T) { + f := newFixture(t) + f.state(`{"schema":1,"current":"1.2.0","future":"kept"}`) + f.version("1.2.0") + f.sp.results = []SpawnResult{{Started: true, ExitCode: 4}} + if code := f.b.Update(); code != 4 { + t.Fatalf("exit = %d", code) + } + data, _ := os.ReadFile(f.layout.StateFile()) + if !strings.Contains(string(data), `"future"`) { + t.Fatalf("unknown field dropped: %s", data) + } +} + +func TestUpdate_LockHeldSkipsRecord(t *testing.T) { + f := newFixture(t) + f.state(`{"schema":1,"current":"1.2.0"}`) + f.version("1.2.0") + l, err := updatestate.AcquireLock(f.layout.LockFile()) + if err != nil { + t.Fatal(err) + } + defer l.Release() + f.sp.results = []SpawnResult{{Started: true, ExitCode: 1}} + f.b.Update() + if got := f.load().Updater; got.ConsecutiveFailures != 0 { + t.Fatalf("recorded under a held lock: %+v", got) + } +} + +// --- adopt-seed --- + +func (f *fixture) adopt(seedDir string) { + f.t.Helper() + if code := f.b.AdoptSeed([]string{"--seed-dir", seedDir}); code != 0 { + f.t.Fatalf("adopt-seed exit = %d", code) + } +} + +func TestAdoptSeed_FreshInstall(t *testing.T) { + f := newFixture(t) + if err := os.RemoveAll(f.layout.VersionsDir()); err != nil { + t.Fatal(err) + } + seed := f.seed("1.5.0") + f.adopt(seed + string(filepath.Separator)) + dst := filepath.Join(f.layout.VersionsDir(), "1.5.0") + for _, n := range []string{VersionedTaskBinary, VersionedAgentBinary, "dmg-linux"} { + got, err := os.ReadFile(filepath.Join(dst, n)) + want, _ := os.ReadFile(filepath.Join(seed, n)) + if err != nil || !bytes.Equal(got, want) { + t.Fatalf("%s not copied: %v", n, err) + } + } + if st := f.load(); st.Current != "1.5.0" || st.Previous != "" { + t.Fatalf("state = %+v", st) + } + entries, _ := os.ReadDir(f.layout.VersionsDir()) + if len(entries) != 1 { + t.Fatalf("leftovers in versions: %v", entries) + } +} + +func TestAdoptSeed_StateRules(t *testing.T) { + cases := []struct { + name string + state string + versions []string + wantCurrent string + wantPrevious string + wantUnchanged bool + }{ + {"seed newer upgrades", `{"schema":1,"current":"1.4.0","probation":{"version":"1.4.0"}}`, []string{"1.4.0"}, "1.5.0", "1.4.0", false}, + {"seed older never downgrades", `{"schema":1,"current":"1.6.0","previous":"1.5.0"}`, []string{"1.6.0"}, "1.6.0", "1.5.0", true}, + {"seed equal keeps", `{"schema":1,"current":"1.5.0","previous":"1.4.0"}`, nil, "1.5.0", "1.4.0", true}, + {"current missing repaired", `{"schema":1,"current":"1.6.0","previous":"1.4.0","probation":{"version":"1.6.0"}}`, nil, "1.5.0", "1.4.0", false}, + {"current incomplete repaired", `{"schema":1,"current":"1.6.0"}`, nil, "1.5.0", "", false}, + {"quarantined seed not adopted", `{"schema":1,"current":"1.4.0","quarantine":{"1.5.0":{"reason":"bad_runs","at":"2026-01-01T00:00:00Z"}}}`, []string{"1.4.0"}, "1.4.0", "", true}, + {"corrupt state replaced", `{"schema":1,"current":"zz"}`, nil, "1.5.0", "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := newFixture(t) + f.state(tc.state) + for _, v := range tc.versions { + f.version(v) + } + if tc.name == "current incomplete repaired" { + f.version("1.6.0", VersionedAgentBinary) + } + seed := f.seed("1.5.0") + f.adopt(seed) + st := f.load() + if st.Current != tc.wantCurrent || st.Previous != tc.wantPrevious { + t.Fatalf("state = %+v", st) + } + if !tc.wantUnchanged && st.Probation != nil { + t.Fatalf("probation kept: %+v", st.Probation) + } + if !updatestate.VersionDirUsable(filepath.Join(f.layout.VersionsDir(), "1.5.0")) { + t.Fatal("seed not copied") + } + }) + } +} + +func TestAdoptSeed_IncompleteSeedVersionReplaced(t *testing.T) { + f := newFixture(t) + f.version("1.5.0", VersionedTaskBinary) + f.adopt(f.seed("1.5.0")) + if !updatestate.VersionDirUsable(filepath.Join(f.layout.VersionsDir(), "1.5.0")) { + t.Fatal("incomplete version dir not replaced") + } +} + +func TestAdoptSeed_NewerSchemaUntouched(t *testing.T) { + f := newFixture(t) + body := `{"schema":2,"current":"1.4.0"}` + f.state(body) + f.version("1.4.0") + f.adopt(f.seed("1.5.0")) + if got, _ := os.ReadFile(f.layout.StateFile()); string(got) != body { + t.Fatalf("schema-2 state rewritten: %s", got) + } +} + +func TestAdoptSeed_NoOpCases(t *testing.T) { + t.Run("lock held", func(t *testing.T) { + f := newFixture(t) + l, err := updatestate.AcquireLock(f.layout.LockFile()) + if err != nil { + t.Fatal(err) + } + defer l.Release() + f.adopt(f.seed("1.5.0")) + if _, err := os.Stat(f.layout.StateFile()); !os.IsNotExist(err) { + t.Fatal("state written without the lock") + } + if !strings.Contains(f.out.String(), "lock not taken") { + t.Fatalf("log:\n%s", f.out) + } + }) + t.Run("not system", func(t *testing.T) { + f := newFixture(t) + f.b.IsSystem = func() bool { return false } + f.adopt(f.seed("1.5.0")) + if _, err := os.Stat(f.layout.StateFile()); !os.IsNotExist(err) { + t.Fatal("state written") + } + }) + t.Run("seed outside install root", func(t *testing.T) { + f := newFixture(t) + other := filepath.Join(t.TempDir(), "seed", "1.5.0") + mkVersionDir(t, other) + f.adopt(other) + if _, err := os.Stat(f.layout.StateFile()); !os.IsNotExist(err) { + t.Fatal("adopted a foreign seed dir") + } + }) + t.Run("incomplete seed", func(t *testing.T) { + f := newFixture(t) + d := filepath.Join(f.layout.InstallRoot, "seed", "1.5.0") + mkVersionDir(t, d, VersionedAgentBinary) + f.adopt(d) + if _, err := os.Stat(f.layout.StateFile()); !os.IsNotExist(err) { + t.Fatal("adopted an incomplete seed") + } + }) + t.Run("missing flag", func(t *testing.T) { + f := newFixture(t) + if code := f.b.AdoptSeed(nil); code != 0 { + t.Fatalf("exit = %d", code) + } + }) +} + +func TestChildEnv_ReplacesOurVars(t *testing.T) { + f := newFixture(t) + env := f.b.childEnv(map[string]string{EnvRunRecord: "r", EnvBootstrapVersion: "1.2.0"}) + joined := strings.Join(env, "\n") + if strings.Contains(joined, "stale") || !strings.Contains(joined, "STEPSEC_RUN_RECORD=r") || strings.Contains(joined, EnvBootstrapFallback) { + t.Fatalf("env = %v", env) + } +} diff --git a/cmd/stepsecurity-dev-machine-guard-task/main_test.go b/internal/launcher/job_windows_test.go similarity index 88% rename from cmd/stepsecurity-dev-machine-guard-task/main_test.go rename to internal/launcher/job_windows_test.go index 847e7a36..0a0e5f5e 100644 --- a/cmd/stepsecurity-dev-machine-guard-task/main_test.go +++ b/internal/launcher/job_windows_test.go @@ -1,6 +1,6 @@ //go:build windows -package main +package launcher import ( "testing" @@ -10,9 +10,9 @@ import ( ) func TestNewKillOnCloseJob_AllowsBreakaway(t *testing.T) { - job, err := newKillOnCloseJob() + job, err := NewKillOnCloseJob() if err != nil { - t.Fatalf("newKillOnCloseJob: %v", err) + t.Fatalf("NewKillOnCloseJob: %v", err) } defer func() { _ = windows.CloseHandle(job) }() diff --git a/internal/launcher/launcher.go b/internal/launcher/launcher.go index 7c9f77a4..7bc69822 100644 --- a/internal/launcher/launcher.go +++ b/internal/launcher/launcher.go @@ -23,6 +23,13 @@ const ( // ExecFlag opts into the generic-target launch mode used by the // PowerShell loader. See ResolveTarget for the contract. ExecFlag = "--exec" + + // VersionedAgentBinary is the agent's name inside a self-update version + // directory (versions\\ and seed\\), where the launcher sits beside + // it as VersionedTaskBinary. Default mode falls back to it when the + // legacy AgentBinary sibling is absent. + VersionedAgentBinary = "agent.exe" + VersionedTaskBinary = "task.exe" ) // ResolveTarget returns the child executable's absolute path and the @@ -39,8 +46,9 @@ const ( // send-telemetry` under the launcher's no-console envelope. // // - Default (legacy / MSI) mode. argv does not begin with --exec; the -// child is the sibling AgentBinary in the launcher's own directory, -// and all of argv is forwarded to it. Preserves byte-for-byte +// child is the sibling AgentBinary in the launcher's own directory +// (or, when that is absent, the sibling VersionedAgentBinary of a +// self-update version directory), and all of argv is forwarded to it. Preserves byte-for-byte // compatibility with the launcher's pre-1.11.5 behaviour, which is // what the MSI install layout's scheduled task action uses. // @@ -74,8 +82,14 @@ func ResolveTarget(argv []string) (string, []string, error) { if err != nil { return "", nil, err } - agent := filepath.Join(filepath.Dir(me), AgentBinary) - if _, err := os.Stat(agent); err != nil { + dir := filepath.Dir(me) + agent := filepath.Join(dir, AgentBinary) + _, err = os.Stat(agent) + if err != nil { + versioned := filepath.Join(dir, VersionedAgentBinary) + if _, verr := os.Stat(versioned); verr == nil { + return versioned, argv, nil + } return "", nil, err } return agent, argv, nil diff --git a/internal/launcher/spawn.go b/internal/launcher/spawn.go new file mode 100644 index 00000000..b0e1d35b --- /dev/null +++ b/internal/launcher/spawn.go @@ -0,0 +1,52 @@ +package launcher + +import ( + "errors" + "os/exec" + "time" +) + +// SpawnResult is the outcome of one child run. +type SpawnResult struct { + Started bool // false: the child never started (Err says why) + ExitCode int // valid when Started + Killed bool // the deadline fired and the child's tree was terminated + Err error // start error, or a wait error with no exit status +} + +// Spawner starts path with args and env, calls onStart once the child is +// running, and waits for it. A positive deadline terminates the child's whole +// process tree when it expires. +type Spawner func(path string, args, env []string, deadline time.Duration, onStart func()) SpawnResult + +// waitWithDeadline waits for a started cmd, calling kill once if deadline +// (when > 0) expires first. +func waitWithDeadline(cmd *exec.Cmd, deadline time.Duration, kill func()) SpawnResult { + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + var timeout <-chan time.Time + if deadline > 0 { + t := time.NewTimer(deadline) + defer t.Stop() + timeout = t.C + } + res := SpawnResult{Started: true} + var err error + select { + case err = <-done: + case <-timeout: + res.Killed = true + kill() + err = <-done + } + var ee *exec.ExitError + switch { + case err == nil: + case errors.As(err, &ee): + res.ExitCode = ee.ExitCode() + default: + res.ExitCode = 1 + res.Err = err + } + return res +} diff --git a/internal/launcher/spawn_other.go b/internal/launcher/spawn_other.go new file mode 100644 index 00000000..1f476380 --- /dev/null +++ b/internal/launcher/spawn_other.go @@ -0,0 +1,22 @@ +//go:build !windows + +package launcher + +import ( + "os/exec" + "time" +) + +// SpawnHidden runs the child directly; off Windows there is no console or +// job object to manage, and only the direct child is killed on deadline. +func SpawnHidden(path string, args, env []string, deadline time.Duration, onStart func()) SpawnResult { + cmd := exec.Command(path, args...) + cmd.Env = env + if err := cmd.Start(); err != nil { + return SpawnResult{Err: err} + } + if onStart != nil { + onStart() + } + return waitWithDeadline(cmd, deadline, func() { _ = cmd.Process.Kill() }) +} diff --git a/internal/launcher/spawn_other_test.go b/internal/launcher/spawn_other_test.go new file mode 100644 index 00000000..95b3b947 --- /dev/null +++ b/internal/launcher/spawn_other_test.go @@ -0,0 +1,24 @@ +//go:build !windows + +package launcher + +import ( + "testing" + "time" +) + +func TestSpawnHidden_ExitCodeAndDeadline(t *testing.T) { + started := false + res := SpawnHidden("/bin/sh", []string{"-c", "exit 3"}, nil, 0, func() { started = true }) + if !res.Started || !started || res.ExitCode != 3 || res.Killed { + t.Fatalf("exit 3: %+v", res) + } + res = SpawnHidden("/bin/sh", []string{"-c", "sleep 30"}, nil, 200*time.Millisecond, nil) + if !res.Started || !res.Killed { + t.Fatalf("deadline: %+v", res) + } + res = SpawnHidden("/nonexistent/task.exe", nil, nil, 0, nil) + if res.Started || res.Err == nil { + t.Fatalf("missing binary: %+v", res) + } +} diff --git a/internal/launcher/spawn_windows.go b/internal/launcher/spawn_windows.go new file mode 100644 index 00000000..59aa22ad --- /dev/null +++ b/internal/launcher/spawn_windows.go @@ -0,0 +1,86 @@ +//go:build windows + +package launcher + +import ( + "os/exec" + "syscall" + "time" + "unsafe" + + "golang.org/x/sys/windows" +) + +// CreateNoWindow is CREATE_NO_WINDOW: the console-subsystem child gets no +// console, so nothing flashes under the INTERACTIVE scan task. +const CreateNoWindow uint32 = 0x08000000 + +// SpawnHidden starts the child under CREATE_NO_WINDOW inside a kill-on-close +// job, so the child's whole tree dies with this process (Stop-ScheduledTask +// and the task's ExecutionTimeLimit only terminate the action's PID) and a +// deadline terminates the whole tree, nested launcher job included. +func SpawnHidden(path string, args, env []string, deadline time.Duration, onStart func()) SpawnResult { + cmd := exec.Command(path, args...) + cmd.Env = env + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: CreateNoWindow} + if err := cmd.Start(); err != nil { + return SpawnResult{Err: err} + } + job, jerr := NewKillOnCloseJob() + if jerr == nil { + if aerr := AssignToJob(job, cmd.Process.Pid); aerr != nil { + _ = windows.CloseHandle(job) + job = 0 + } + } else { + job = 0 + } + if job != 0 { + defer func() { _ = windows.CloseHandle(job) }() + } + if onStart != nil { + onStart() + } + return waitWithDeadline(cmd, deadline, func() { + if job != 0 { + _ = windows.TerminateJobObject(job, uint32(ExitKilled)) + return + } + _ = cmd.Process.Kill() + }) +} + +// NewKillOnCloseJob creates a job whose processes die when its last handle +// closes. BREAKAWAY_OK lets the agent's CREATE_BREAKAWAY_FROM_JOB spawns +// (WSL guest scans) outlive the run instead of being killed. +func NewKillOnCloseJob() (windows.Handle, error) { + job, err := windows.CreateJobObject(nil, nil) + if err != nil { + return 0, err + } + info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{ + BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{ + LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | windows.JOB_OBJECT_LIMIT_BREAKAWAY_OK, + }, + } + if _, err := windows.SetInformationJobObject( + job, + windows.JobObjectExtendedLimitInformation, + uintptr(unsafe.Pointer(&info)), + uint32(unsafe.Sizeof(info)), + ); err != nil { + _ = windows.CloseHandle(job) + return 0, err + } + return job, nil +} + +// AssignToJob puts the process pid into job. +func AssignToJob(job windows.Handle, pid int) error { + h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(pid)) // #nosec G115 -- pid from os.Process + if err != nil { + return err + } + defer func() { _ = windows.CloseHandle(h) }() + return windows.AssignProcessToJobObject(job, h) +} diff --git a/internal/updatestate/pointers.go b/internal/updatestate/pointers.go new file mode 100644 index 00000000..b68a457c --- /dev/null +++ b/internal/updatestate/pointers.go @@ -0,0 +1,290 @@ +package updatestate + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" +) + +// Pointers is the frozen bootstrap read contract of state.json: the fields +// every bootstrap, including the first one ever shipped, must keep reading. +// Probation and Updater are only filled for schema 1; a higher schema is read +// as current/previous only and never as corrupt. +type Pointers struct { + Schema int + Current string + Previous string + Probation string // probation version, "" when none (schema 1 only) + Updater Updater +} + +// LoadPointers reads state.json for the bootstrap. Schema 1 must pass the +// full Validate (so the bootstrap and the updater agree on what is corrupt). +// A higher schema only needs a strict current; an invalid previous is +// dropped. The (p, corrupt, err) contract matches Load. +func LoadPointers(path string) (p *Pointers, corrupt bool, err error) { + data, err := readBounded(path, maxStateSize) + switch { + case errors.Is(err, os.ErrNotExist): + return nil, false, nil + case errors.Is(err, errUnreadable): + return nil, true, nil + case err != nil: + return nil, false, err + } + p, derr := decodePointers(data) + if derr != nil { + return nil, true, nil + } + return p, false, nil +} + +func decodePointers(data []byte) (*Pointers, error) { + var head struct { + Schema int `json:"schema"` + Current string `json:"current"` + Previous string `json:"previous"` + } + if err := json.Unmarshal(data, &head); err != nil { + return nil, err + } + switch { + case head.Schema == SchemaVersion: + s, err := decodeState(data) + if err != nil { + return nil, err + } + p := &Pointers{Schema: s.Schema, Current: s.Current, Previous: s.Previous, Updater: s.Updater} + if s.Probation != nil { + p.Probation = s.Probation.Version + } + return p, nil + case head.Schema > SchemaVersion: + if !validVersion(head.Current) { + return nil, fmt.Errorf("invalid current %q", head.Current) + } + p := &Pointers{Schema: head.Schema, Current: head.Current} + if validVersion(head.Previous) { + p.Previous = head.Previous + } + return p, nil + default: + return nil, fmt.Errorf("unsupported schema %d", head.Schema) + } +} + +// ErrStateNotPatchable means PatchCheckpointed found no valid schema-1 state. +var ErrStateNotPatchable = errors.New("updatestate: no valid schema-1 state to patch") + +// PatchCheckpointed loads a valid schema-1 state.json, applies fn and writes +// it back atomically like SaveCheckpointed. Only the top-level fields fn +// changed are rewritten; every other field keeps its original bytes, so an +// older bootstrap never strips what a newer agent added (also inside nested +// objects such as quarantine entries). A missing, corrupt or higher-schema +// file returns ErrStateNotPatchable and is left untouched; so does a fn that +// changes nothing. Call it with the update lock held. +func PatchCheckpointed(path, prefix string, fn func(*State) error) error { + data, err := readBounded(path, maxStateSize) + if err != nil { + if errors.Is(err, os.ErrNotExist) || errors.Is(err, errUnreadable) { + return ErrStateNotPatchable + } + return err + } + st, err := decodeState(data) + if err != nil { + return ErrStateNotPatchable + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return ErrStateNotPatchable + } + before, err := patchFieldMap(st) + if err != nil { + return err + } + if err := fn(st); err != nil { + return err + } + st.Schema = SchemaVersion + if err := st.Validate(); err != nil { + return fmt.Errorf("updatestate: refusing to save invalid state: %w", err) + } + after, err := patchFieldMap(st) + if err != nil { + return err + } + changed := false + for k, v := range after { + if !bytes.Equal(before[k], v) { + raw[k] = v + changed = true + } + } + if !changed { + return nil + } + out, err := json.MarshalIndent(raw, "", " ") + if err != nil { + return err + } + var afterTemp, afterRename func() error + if prefix != "" { + afterTemp = func() error { return checkpoint(prefix + ".state-temp") } + afterRename = func() error { return checkpoint(prefix + ".state-renamed") } + } + return writeAtomic(path, append(out, '\n'), afterTemp, afterRename) +} + +func patchFieldMap(st *State) (map[string]json.RawMessage, error) { + b, err := json.Marshal(st) + if err != nil { + return nil, err + } + var m map[string]json.RawMessage + err = json.Unmarshal(b, &m) + return m, err +} + +// Executables every launchable version directory (versions\\ and +// seed\\) must contain. "Usable" is file presence only; integrity is the +// updater's manifest check, not the bootstrap's. +const ( + RunnableAgentName = "agent.exe" + RunnableTaskName = "task.exe" +) + +// Fallback reasons for the bootstrap's EnvBootstrapFallback. Reasons about a +// named version carry it after a colon (current_missing:1.20.0) so the agent +// can report update_pointer_invalid with update_from = that version. +const ( + FallbackStateMissing = "state_missing" + FallbackStateCorrupt = "state_corrupt" + FallbackStateUnreadable = "state_unreadable" + FallbackCurrentMissing = "current_missing" + FallbackCurrentIncomplete = "current_incomplete" + FallbackStartFailed = "start_failed" + FallbackUpdaterFailed = "updater_failed" + FallbackNoUpdater = "no_confirmed_updater" + FallbackNothingRunnable = "no_runnable_version" +) + +// VersionDirUsable reports whether dir holds both RunnableTaskName and +// RunnableAgentName as non-empty regular files. +func VersionDirUsable(dir string) bool { + for _, name := range []string{RunnableTaskName, RunnableAgentName} { + fi, err := os.Lstat(filepath.Join(dir, name)) + if err != nil || !fi.Mode().IsRegular() || fi.Size() == 0 { + return false + } + } + return true +} + +// FindSeed returns the newest usable seed\\ version. +func (l Layout) FindSeed() (Version, bool) { + entries, err := os.ReadDir(filepath.Join(l.InstallRoot, "seed")) + if err != nil { + return Version{}, false + } + var best Version + found := false + for _, e := range entries { + if !e.IsDir() { + continue + } + v, err := ParseVersion(e.Name()) + if err != nil || !VersionDirUsable(l.SeedDir(v)) { + continue + } + if !found || Compare(v, best) > 0 { + best, found = v, true + } + } + return best, found +} + +// RunChoice is one launchable directory in the bootstrap's run order. +type RunChoice struct { + Version Version + Dir string + Reason string // why this is not the state's current; "" when it is +} + +// LoadReason maps a LoadPointers/Load result to a fallback reason, "" when +// a state was read. +func LoadReason(found bool, corrupt bool, err error) string { + switch { + case err != nil: + return FallbackStateUnreadable + case corrupt: + return FallbackStateCorrupt + case !found: + return FallbackStateMissing + } + return "" +} + +// CheckVersionDir validates versions\\ and returns its version, or the +// fallback reason (current_missing / current_incomplete) it is unusable. +func (l Layout) CheckVersionDir(s string) (Version, string) { + v, err := ParseVersion(s) + if err != nil { + return Version{}, FallbackCurrentMissing + ":" + s + } + dir := l.VersionDir(v) + if _, err := os.Lstat(dir); err != nil { + return Version{}, FallbackCurrentMissing + ":" + s + } + if !VersionDirUsable(dir) { + return Version{}, FallbackCurrentIncomplete + ":" + s + } + return v, "" +} + +// ResolveRunChain orders current → previous → seed, skipping unusable +// directories. p is the LoadPointers result (nil when missing or corrupt); +// stateReason is LoadReason of that load. Every choice after the state's +// current carries the reason current was not used. +func ResolveRunChain(l Layout, p *Pointers, stateReason string) []RunChoice { + var out []RunChoice + seen := map[Version]bool{} + reason := stateReason + if p != nil { + if v, why := l.CheckVersionDir(p.Current); why == "" { + out = append(out, RunChoice{Version: v, Dir: l.VersionDir(v)}) + seen[v] = true + } else { + reason = why + } + if p.Previous != "" && p.Previous != p.Current { + if v, why := l.CheckVersionDir(p.Previous); why == "" && !seen[v] { + out = append(out, RunChoice{Version: v, Dir: l.VersionDir(v), Reason: reason}) + seen[v] = true + } + } + } + if v, ok := l.FindSeed(); ok { + out = append(out, RunChoice{Version: v, Dir: l.SeedDir(v), Reason: reason}) + } + return out +} + +// ResolveRunVersion is the version a scan launched now would run for st +// (nil = missing or corrupt state), with the fallback reason ("" when it is +// st.Current). With nothing runnable it returns the zero Version and +// FallbackNothingRunnable. +func ResolveRunVersion(l Layout, st *State) (Version, string) { + var p *Pointers + if st != nil { + p = &Pointers{Schema: st.Schema, Current: st.Current, Previous: st.Previous} + } + chain := ResolveRunChain(l, p, LoadReason(st != nil, false, nil)) + if len(chain) == 0 { + return Version{}, FallbackNothingRunnable + } + return chain[0].Version, chain[0].Reason +} diff --git a/internal/updatestate/pointers_test.go b/internal/updatestate/pointers_test.go new file mode 100644 index 00000000..1a7f7855 --- /dev/null +++ b/internal/updatestate/pointers_test.go @@ -0,0 +1,182 @@ +package updatestate + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeStateFile(t *testing.T, body string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "state.json") + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return p +} + +func TestLoadPointers(t *testing.T) { + cases := []struct { + name string + body string + wantCorr bool + want *Pointers + }{ + {"schema1", `{"schema":1,"current":"1.2.3","previous":"1.2.2","probation":{"version":"1.2.3"},"updater":{"version":"1.2.2","consecutive_failures":3}}`, + false, &Pointers{Schema: 1, Current: "1.2.3", Previous: "1.2.2", Probation: "1.2.3", Updater: Updater{Version: "1.2.2", ConsecutiveFailures: 3}}}, + {"schema1 extra fields", `{"schema":1,"current":"1.2.3","future":{"x":1}}`, false, &Pointers{Schema: 1, Current: "1.2.3"}}, + {"higher schema reads pointers only", `{"schema":7,"current":"2.0.0","previous":"bogus","probation":"???","updater":5}`, + false, &Pointers{Schema: 7, Current: "2.0.0"}}, + {"higher schema bad current", `{"schema":2,"current":"..\\x"}`, true, nil}, + {"schema0", `{"schema":0,"current":"1.2.3"}`, true, nil}, + {"traversal", `{"schema":1,"current":"..\\x"}`, true, nil}, + {"garbage", `not json`, true, nil}, + {"empty", ``, true, nil}, + {"schema1 invalid counter", `{"schema":1,"current":"1.2.3","switch_deferrals":-1}`, true, nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p, corrupt, err := LoadPointers(writeStateFile(t, tc.body)) + if err != nil { + t.Fatalf("err = %v", err) + } + if corrupt != tc.wantCorr { + t.Fatalf("corrupt = %v, want %v", corrupt, tc.wantCorr) + } + if tc.want == nil { + if p != nil { + t.Fatalf("p = %+v, want nil", p) + } + return + } + if *p != *tc.want { + t.Fatalf("p = %+v, want %+v", *p, *tc.want) + } + }) + } +} + +func TestLoadPointers_MissingAndHuge(t *testing.T) { + p, corrupt, err := LoadPointers(filepath.Join(t.TempDir(), "nope.json")) + if p != nil || corrupt || err != nil { + t.Fatalf("missing: %v %v %v", p, corrupt, err) + } + huge := `{"schema":1,"current":"1.2.3","pad":"` + strings.Repeat("a", maxStateSize) + `"}` + p, corrupt, err = LoadPointers(writeStateFile(t, huge)) + if p != nil || !corrupt || err != nil { + t.Fatalf("huge: %v %v %v", p, corrupt, err) + } +} + +func TestPatchCheckpointed_KeepsUnknownFields(t *testing.T) { + path := writeStateFile(t, `{"schema":1,"current":"1.2.3","future_field":{"a":[1,2]},"updater":{"version":"1.2.3","consecutive_failures":1}}`) + err := PatchCheckpointed(path, "", func(s *State) error { + s.Updater.ConsecutiveFailures++ + return nil + }) + if err != nil { + t.Fatal(err) + } + st, corrupt, err := Load(path) + if err != nil || corrupt { + t.Fatalf("load: %v %v", corrupt, err) + } + if st.Updater.ConsecutiveFailures != 2 { + t.Fatalf("failures = %d", st.Updater.ConsecutiveFailures) + } + data, _ := os.ReadFile(path) + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatal(err) + } + if string(raw["future_field"]) == "" { + t.Fatalf("future_field dropped: %s", data) + } +} + +func TestPatchCheckpointed_RefusesNonPatchable(t *testing.T) { + for _, body := range []string{"", "garbage", `{"schema":2,"current":"1.2.3"}`} { + path := writeStateFile(t, body) + err := PatchCheckpointed(path, "", func(*State) error { return nil }) + if !errors.Is(err, ErrStateNotPatchable) { + t.Fatalf("%q: err = %v", body, err) + } + got, _ := os.ReadFile(path) + if string(got) != body { + t.Fatalf("%q: file changed to %q", body, got) + } + } + err := PatchCheckpointed(filepath.Join(t.TempDir(), "missing.json"), "", func(*State) error { return nil }) + if !errors.Is(err, ErrStateNotPatchable) { + t.Fatalf("missing: err = %v", err) + } +} + +func TestPatchCheckpointed_RejectsInvalidResult(t *testing.T) { + path := writeStateFile(t, `{"schema":1,"current":"1.2.3"}`) + err := PatchCheckpointed(path, "", func(s *State) error { s.Current = "nope"; return nil }) + if err == nil { + t.Fatal("expected refusal") + } +} + +func TestPatchCheckpointed_KeepsNestedUnknownFieldsOfUntouchedKeys(t *testing.T) { + path := writeStateFile(t, `{"schema":1,"current":"1.2.3","quarantine":{"1.2.4":{"reason":"bad_runs","at":"2026-01-01T00:00:00Z","until":"2026-01-08T00:00:00Z"}}}`) + if err := PatchCheckpointed(path, "", func(s *State) error { s.Updater.ConsecutiveFailures = 1; return nil }); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(path) + if !strings.Contains(string(data), `"until"`) { + t.Fatalf("nested quarantine field dropped: %s", data) + } +} + +func TestPatchCheckpointed_NoChangeNoWrite(t *testing.T) { + body := `{"schema":1,"current":"1.2.3"}` + path := writeStateFile(t, body) + if err := PatchCheckpointed(path, "", func(*State) error { return nil }); err != nil { + t.Fatal(err) + } + if got, _ := os.ReadFile(path); string(got) != body { + t.Fatalf("rewritten: %s", got) + } +} + +func TestResolveRunVersion(t *testing.T) { + l := Layout{InstallRoot: t.TempDir(), DataRoot: t.TempDir()} + mk := func(dir string, files ...string) { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + for _, f := range files { + if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0o755); err != nil { + t.Fatal(err) + } + } + } + if v, why := ResolveRunVersion(l, nil); why != FallbackNothingRunnable || v != (Version{}) { + t.Fatalf("empty: %v %q", v, why) + } + mk(filepath.Join(l.InstallRoot, "seed", "1.0.0"), RunnableAgentName, RunnableTaskName) + mk(filepath.Join(l.InstallRoot, "seed", "0.9.0"), RunnableAgentName, RunnableTaskName) + mk(filepath.Join(l.InstallRoot, "seed", "2.0.0"), RunnableAgentName) // incomplete: ignored + if v, why := ResolveRunVersion(l, nil); v.String() != "1.0.0" || why != FallbackStateMissing { + t.Fatalf("missing state: %v %q", v, why) + } + st := &State{Schema: 1, Current: "1.2.0", Previous: "1.1.0"} + mk(l.VersionDir(Version{1, 1, 0}), RunnableAgentName, RunnableTaskName) + if v, why := ResolveRunVersion(l, st); v.String() != "1.1.0" || why != FallbackCurrentMissing+":1.2.0" { + t.Fatalf("previous: %v %q", v, why) + } + mk(l.VersionDir(Version{1, 2, 0}), RunnableTaskName) + if _, why := ResolveRunVersion(l, st); why != FallbackCurrentIncomplete+":1.2.0" { + t.Fatalf("incomplete: %q", why) + } + mk(l.VersionDir(Version{1, 2, 0}), RunnableAgentName) + if v, why := ResolveRunVersion(l, st); v.String() != "1.2.0" || why != "" { + t.Fatalf("current: %v %q", v, why) + } +} From 5717b6810bfeff7b3403d75de2b768c38ef6e367 Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Mon, 19 Oct 2026 18:13:35 +0530 Subject: [PATCH 06/34] feat(authenticode): verify timestamped signatures and compare the signer with our own WinVerifyTrust (revocation off) on Windows plus a portable PKCS#7 read of the PE signature for the leaf subject and countersignature timestamp. SamePublisher pins candidates to the running updater's own signer subject; an unsigned dev build refuses updates unless a selfupdate_testkeys build pins a subject. --- .github/workflows/tests.yml | 7 +- internal/authenticode/authenticode.go | 86 ++++++++ internal/authenticode/pkcs7.go | 176 ++++++++++++++++ internal/authenticode/pkcs7_test.go | 207 +++++++++++++++++++ internal/authenticode/testkeys_off.go | 5 + internal/authenticode/testkeys_on.go | 14 ++ internal/authenticode/verify_other.go | 5 + internal/authenticode/verify_other_test.go | 17 ++ internal/authenticode/verify_windows.go | 47 +++++ internal/authenticode/verify_windows_test.go | 182 ++++++++++++++++ internal/selfupdate/release.go | 2 +- internal/selfupdate/sshsig_test.go | 10 - internal/updatestate/acl_other.go | 1 + internal/updatestate/runs.go | 1 + 14 files changed, 746 insertions(+), 14 deletions(-) create mode 100644 internal/authenticode/authenticode.go create mode 100644 internal/authenticode/pkcs7.go create mode 100644 internal/authenticode/pkcs7_test.go create mode 100644 internal/authenticode/testkeys_off.go create mode 100644 internal/authenticode/testkeys_on.go create mode 100644 internal/authenticode/verify_other.go create mode 100644 internal/authenticode/verify_other_test.go create mode 100644 internal/authenticode/verify_windows.go create mode 100644 internal/authenticode/verify_windows_test.go diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 30d40429..ac433672 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -113,7 +113,7 @@ jobs: go-version-file: go.mod - run: make test - name: Test self-update fault injection (selfupdate_faults tag) - run: go test -race -count=1 -tags selfupdate_faults,selfupdate_testkeys ./internal/faultinject/ ./internal/updatestate/ ./internal/selfupdate/ + run: go test -race -count=1 -tags selfupdate_faults,selfupdate_testkeys ./internal/faultinject/ ./internal/updatestate/ ./internal/selfupdate/ ./internal/authenticode/ # These packages have Windows-only code and Windows-specific behavior whose # tests the macOS test job can only cross-compile, not run: @@ -130,6 +130,7 @@ jobs: # depends on # - updatestate: MoveFileEx state replace, LockFileEx, protected DACLs # - selfupdate: native arch (IsWow64Process2), WinHTTP machine proxy + # - authenticode: WinVerifyTrust against a test-signed copy (elevated runner) # - detector TestReal_*: the Kiro CLI registry reader against a scratch # HKCU key, and skills junction folding over real junctions (the rest of # the detector suite assumes a POSIX host, hence the -run filter) @@ -148,12 +149,12 @@ jobs: - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version-file: go.mod - - run: go test -race -count=1 ./internal/devicepolicy/ ./internal/secureuserfile/ ./internal/detector/credentials/ ./internal/safepath/ ./internal/executor/ ./internal/updatestate/ ./internal/selfupdate/ + - run: go test -race -count=1 ./internal/devicepolicy/ ./internal/secureuserfile/ ./internal/detector/credentials/ ./internal/safepath/ ./internal/executor/ ./internal/updatestate/ ./internal/selfupdate/ ./internal/authenticode/ - run: go test -race -count=1 -run '^TestReal_' ./internal/detector/ - name: Test scheduled scan console suppression run: go test -race -count=1 -run 'Test(IsTaskRegistered|DetectInvocationMethod_Windows|HideWindow)' ./internal/schtasks/ ./internal/telemetry/ ./internal/winproc/ - name: Test self-update fault injection (selfupdate_faults tag) - run: go test -race -count=1 -tags selfupdate_faults,selfupdate_testkeys ./internal/faultinject/ ./internal/updatestate/ ./internal/selfupdate/ + run: go test -race -count=1 -tags selfupdate_faults,selfupdate_testkeys ./internal/faultinject/ ./internal/updatestate/ ./internal/selfupdate/ ./internal/authenticode/ smoke: name: Smoke Tests diff --git a/internal/authenticode/authenticode.go b/internal/authenticode/authenticode.go new file mode 100644 index 00000000..23f4fab6 --- /dev/null +++ b/internal/authenticode/authenticode.go @@ -0,0 +1,86 @@ +// Package authenticode checks Windows code signatures for the self-updater: +// the candidate must carry a valid, timestamped Authenticode signature whose +// leaf signer subject equals the running updater's own signer subject, so +// no publisher is hard-coded and a Trusted Signing certificate rotation +// (same subject, new key) keeps working. +// +// Trust (chain, digest) comes from WinVerifyTrust on Windows. The signer +// subject and the timestamp are read from the PE's PKCS#7 blob in Go, which +// is portable and testable everywhere. +package authenticode + +import ( + "errors" + "fmt" +) + +var ( + // ErrUnsigned means the file has no Authenticode signature. + ErrUnsigned = errors.New("authenticode: file is not signed") + // ErrNotTimestamped means the signature has no countersignature + // timestamp, so it stops validating when the signing cert expires. + ErrNotTimestamped = errors.New("authenticode: signature is not timestamped") + // ErrUntrusted means WinVerifyTrust rejected the signature. + ErrUntrusted = errors.New("authenticode: signature not trusted") + // ErrPublisherMismatch means the candidate's signer differs from ours. + ErrPublisherMismatch = errors.New("authenticode: publisher differs from the running updater") + // ErrNoPublisher means the running updater has no signer to compare + // against (an unsigned dev build) and no test pin is configured. + ErrNoPublisher = errors.New("authenticode: running updater is unsigned; refusing updates") + // ErrUnsupported is returned off Windows, where WinVerifyTrust is absent. + ErrUnsupported = errors.New("authenticode: verification is only supported on Windows") +) + +// Signature describes a file's primary Authenticode signature. +type Signature struct { + Subject string // leaf signer subject, RFC 2253 form + Timestamped bool +} + +// Verify checks path with WinVerifyTrust (revocation off) and requires a +// timestamped signature. +func Verify(path string) (*Signature, error) { + if err := winVerifyTrust(path); err != nil { + return nil, err + } + sig, err := Inspect(path) + if err != nil { + return nil, err + } + if !sig.Timestamped { + return nil, fmt.Errorf("%w: %s", ErrNotTimestamped, path) + } + return sig, nil +} + +// SamePublisher verifies candidate and checks that its leaf subject equals +// the subject of self (the running updater). A selfupdate_testkeys build may +// pin the expected subject instead (DMG_TEST_AUTHENTICODE_SUBJECT). An +// unsigned self with no pin refuses every candidate. +func SamePublisher(candidate, self string) error { + return samePublisher(candidate, self, Verify, Inspect, testPinnedSubject()) +} + +func samePublisher(candidate, self string, verify, inspect func(string) (*Signature, error), pin string) error { + got, err := verify(candidate) + if err != nil { + return err + } + want := pin + if want == "" { + // Our own image only supplies the expected subject; it is already + // running, so re-checking its trust adds nothing. + own, err := inspect(self) + switch { + case errors.Is(err, ErrUnsigned): + return ErrNoPublisher + case err != nil: + return fmt.Errorf("authenticode: read own signature: %w", err) + } + want = own.Subject + } + if want == "" || got.Subject != want { + return fmt.Errorf("%w: %q, want %q", ErrPublisherMismatch, got.Subject, want) + } + return nil +} diff --git a/internal/authenticode/pkcs7.go b/internal/authenticode/pkcs7.go new file mode 100644 index 00000000..52d70cc5 --- /dev/null +++ b/internal/authenticode/pkcs7.go @@ -0,0 +1,176 @@ +package authenticode + +import ( + "bytes" + "crypto/x509" + "debug/pe" + "encoding/asn1" + "encoding/binary" + "errors" + "fmt" + "io" + "math/big" + "os" +) + +const ( + maxSigBlob = 1 << 20 + winCertTypePKCSSigned = 0x0002 + imageDirectoryEntrySec = 4 +) + +var ( + oidSignedData = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 2} + oidCounterSignature = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 6} + oidRFC3161CounterSign = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 311, 3, 3, 1} + errMalformedSignedData = errors.New("authenticode: malformed PKCS#7 signature") +) + +// Inspect reads the primary signature of a PE file without checking trust: +// the leaf signer subject and whether it carries a timestamp. +func Inspect(path string) (*Signature, error) { + blob, err := peSignature(path) + if err != nil { + return nil, err + } + return parseSignedData(blob) +} + +// peSignature returns the PKCS#7 SignedData of the first WIN_CERTIFICATE in +// the PE security directory. +func peSignature(path string) ([]byte, error) { + // #nosec G304 -- the updater's own staged or running executable. + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + pf, err := pe.NewFile(f) + if err != nil { + return nil, fmt.Errorf("authenticode: %s is not a PE file: %w", path, err) + } + var dir pe.DataDirectory + switch oh := pf.OptionalHeader.(type) { + case *pe.OptionalHeader32: + if oh.NumberOfRvaAndSizes > imageDirectoryEntrySec { + dir = oh.DataDirectory[imageDirectoryEntrySec] + } + case *pe.OptionalHeader64: + if oh.NumberOfRvaAndSizes > imageDirectoryEntrySec { + dir = oh.DataDirectory[imageDirectoryEntrySec] + } + } + if dir.VirtualAddress == 0 || dir.Size < 8 { + return nil, ErrUnsigned + } + if dir.Size > maxSigBlob { + return nil, errMalformedSignedData + } + buf := make([]byte, dir.Size) + // The security directory's "VirtualAddress" is a file offset. + if _, err := f.ReadAt(buf, int64(dir.VirtualAddress)); err != nil && !errors.Is(err, io.EOF) { + return nil, err + } + length := binary.LittleEndian.Uint32(buf[0:4]) + certType := binary.LittleEndian.Uint16(buf[6:8]) + if certType != winCertTypePKCSSigned || length < 8 || length > dir.Size { + return nil, errMalformedSignedData + } + return buf[8:length], nil +} + +type contentInfo struct { + ContentType asn1.ObjectIdentifier + Content asn1.RawValue `asn1:"explicit,optional,tag:0"` +} + +type signedData struct { + Version int + DigestAlgorithms asn1.RawValue + ContentInfo asn1.RawValue + Certificates asn1.RawValue `asn1:"optional,tag:0"` + CRLs asn1.RawValue `asn1:"optional,tag:1"` + SignerInfos asn1.RawValue +} + +type signerInfo struct { + Version int + SID asn1.RawValue + DigestAlgorithm asn1.RawValue + SignedAttrs asn1.RawValue `asn1:"optional,tag:0"` + SignatureAlgorithm asn1.RawValue + Signature []byte + UnsignedAttrs asn1.RawValue `asn1:"optional,tag:1"` +} + +type issuerAndSerial struct { + Issuer asn1.RawValue + Serial *big.Int +} + +type attribute struct { + Type asn1.ObjectIdentifier + Values asn1.RawValue +} + +// parseSignedData extracts the first signer's certificate subject and the +// presence of a (legacy or RFC 3161) countersignature timestamp. +func parseSignedData(der []byte) (*Signature, error) { + var ci contentInfo + if rest, err := asn1.Unmarshal(der, &ci); err != nil || len(bytes.TrimRight(rest, "\x00")) != 0 { + return nil, errMalformedSignedData + } + if !ci.ContentType.Equal(oidSignedData) { + return nil, errMalformedSignedData + } + var sd signedData + if _, err := asn1.Unmarshal(ci.Content.Bytes, &sd); err != nil { + return nil, errMalformedSignedData + } + certs, err := x509.ParseCertificates(sd.Certificates.Bytes) + if err != nil || len(certs) == 0 { + return nil, errMalformedSignedData + } + var si signerInfo + if _, err := asn1.Unmarshal(sd.SignerInfos.Bytes, &si); err != nil { + return nil, errMalformedSignedData + } + leaf := findSigner(certs, si.SID) + if leaf == nil { + return nil, fmt.Errorf("%w: signer certificate not embedded", errMalformedSignedData) + } + sig := &Signature{Subject: leaf.Subject.String()} + rest := si.UnsignedAttrs.Bytes + for len(rest) > 0 { + var a attribute + var err error + if rest, err = asn1.Unmarshal(rest, &a); err != nil { + return nil, errMalformedSignedData + } + if a.Type.Equal(oidCounterSignature) || a.Type.Equal(oidRFC3161CounterSign) { + sig.Timestamped = true + } + } + return sig, nil +} + +func findSigner(certs []*x509.Certificate, sid asn1.RawValue) *x509.Certificate { + if sid.Class == asn1.ClassContextSpecific && sid.Tag == 0 { + for _, c := range certs { + if bytes.Equal(c.SubjectKeyId, sid.Bytes) { + return c + } + } + return nil + } + var ias issuerAndSerial + if _, err := asn1.Unmarshal(sid.FullBytes, &ias); err != nil || ias.Serial == nil { + return nil + } + for _, c := range certs { + if bytes.Equal(c.RawIssuer, ias.Issuer.FullBytes) && c.SerialNumber.Cmp(ias.Serial) == 0 { + return c + } + } + return nil +} diff --git a/internal/authenticode/pkcs7_test.go b/internal/authenticode/pkcs7_test.go new file mode 100644 index 00000000..75dfcf4c --- /dev/null +++ b/internal/authenticode/pkcs7_test.go @@ -0,0 +1,207 @@ +package authenticode + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/binary" + "errors" + "math/big" + "os" + "path/filepath" + "testing" + "time" +) + +func makeCert(t *testing.T, cn string, serial int64) *x509.Certificate { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(serial), + Subject: pkix.Name{CommonName: cn, Organization: []string{"StepSecurity Test"}}, + NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + c, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + return c +} + +func rawSet(t *testing.T, elems ...[]byte) asn1.RawValue { + t.Helper() + var b []byte + for _, e := range elems { + b = append(b, e...) + } + return asn1.RawValue{Class: asn1.ClassUniversal, Tag: asn1.TagSet, IsCompound: true, Bytes: b} +} + +func mustMarshal(t *testing.T, v any) []byte { + t.Helper() + b, err := asn1.Marshal(v) + if err != nil { + t.Fatal(err) + } + return b +} + +// buildSignedData returns a PKCS#7 ContentInfo whose first signer is signer +// (by issuer+serial), embedding certs, optionally with a timestamp attribute. +func buildSignedData(t *testing.T, signer *x509.Certificate, certs []*x509.Certificate, tsOID asn1.ObjectIdentifier) []byte { + t.Helper() + sid := mustMarshal(t, issuerAndSerial{Issuer: asn1.RawValue{FullBytes: signer.RawIssuer}, Serial: signer.SerialNumber}) + algo := mustMarshal(t, pkix.AlgorithmIdentifier{Algorithm: asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 1}}) + si := signerInfo{ + Version: 1, + SID: asn1.RawValue{FullBytes: sid}, + DigestAlgorithm: asn1.RawValue{FullBytes: algo}, + SignatureAlgorithm: asn1.RawValue{FullBytes: algo}, + Signature: []byte{1, 2, 3}, + } + if tsOID != nil { + attr := mustMarshal(t, attribute{Type: tsOID, Values: rawSet(t, mustMarshal(t, 1))}) + si.UnsignedAttrs = asn1.RawValue{Class: asn1.ClassContextSpecific, Tag: 1, IsCompound: true, Bytes: attr} + } + var certBytes []byte + for _, c := range certs { + certBytes = append(certBytes, c.Raw...) + } + sd := signedData{ + Version: 1, + DigestAlgorithms: rawSet(t, algo), + ContentInfo: asn1.RawValue{FullBytes: mustMarshal(t, contentInfo{ContentType: asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 311, 2, 1, 4}})}, + Certificates: asn1.RawValue{Class: asn1.ClassContextSpecific, Tag: 0, IsCompound: true, Bytes: certBytes}, + SignerInfos: rawSet(t, mustMarshal(t, si)), + } + sdDER := mustMarshal(t, sd) + return mustMarshal(t, contentInfo{ContentType: oidSignedData, Content: asn1.RawValue{FullBytes: wrapExplicit0(sdDER)}}) +} + +func wrapExplicit0(inner []byte) []byte { + b, _ := asn1.Marshal(asn1.RawValue{Class: asn1.ClassContextSpecific, Tag: 0, IsCompound: true, Bytes: inner}) + return b +} + +// writePE writes a minimal PE32+ image, with sig (if non-nil) as its +// WIN_CERTIFICATE in the security directory. +func writePE(t *testing.T, sig []byte) string { + t.Helper() + const lfanew = 0x40 + img := make([]byte, lfanew+4+20+240) + copy(img, "MZ") + binary.LittleEndian.PutUint32(img[0x3c:], lfanew) + copy(img[lfanew:], "PE\x00\x00") + coff := img[lfanew+4:] + binary.LittleEndian.PutUint16(coff[0:], 0x8664) // Machine + binary.LittleEndian.PutUint16(coff[16:], 240) // SizeOfOptionalHeader + opt := img[lfanew+24:] + binary.LittleEndian.PutUint16(opt[0:], 0x20b) // PE32+ + binary.LittleEndian.PutUint32(opt[108:], 16) // NumberOfRvaAndSizes + if sig != nil { + off := len(img) + wc := make([]byte, 8+len(sig)) + binary.LittleEndian.PutUint32(wc[0:], uint32(len(wc))) + binary.LittleEndian.PutUint16(wc[4:], 0x0200) + binary.LittleEndian.PutUint16(wc[6:], winCertTypePKCSSigned) + copy(wc[8:], sig) + for len(wc)%8 != 0 { + wc = append(wc, 0) + } + dd := opt[112+imageDirectoryEntrySec*8:] + binary.LittleEndian.PutUint32(dd[0:], uint32(off)) + binary.LittleEndian.PutUint32(dd[4:], uint32(len(wc))) + img = append(img, wc...) + } + p := filepath.Join(t.TempDir(), "x.exe") + if err := os.WriteFile(p, img, 0o755); err != nil { + t.Fatal(err) + } + return p +} + +func TestInspectSubjectAndTimestamp(t *testing.T) { + leaf := makeCert(t, "StepSecurity Inc", 7) + other := makeCert(t, "Intermediate", 8) + for _, tc := range []struct { + name string + oid asn1.ObjectIdentifier + ts bool + }{ + {"rfc3161", oidRFC3161CounterSign, true}, + {"legacy countersignature", oidCounterSignature, true}, + {"no timestamp", nil, false}, + {"unrelated unsigned attr", asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 311, 2, 4, 1}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + p := writePE(t, buildSignedData(t, leaf, []*x509.Certificate{other, leaf}, tc.oid)) + sig, err := Inspect(p) + if err != nil { + t.Fatal(err) + } + if sig.Subject != leaf.Subject.String() || sig.Timestamped != tc.ts { + t.Fatalf("sig = %+v, want subject %q ts=%v", sig, leaf.Subject.String(), tc.ts) + } + }) + } +} + +func TestInspectUnsignedAndMalformed(t *testing.T) { + if _, err := Inspect(writePE(t, nil)); !errors.Is(err, ErrUnsigned) { + t.Fatalf("unsigned PE: %v", err) + } + if _, err := Inspect(writePE(t, []byte("garbage"))); err == nil { + t.Fatal("garbage signature accepted") + } + leaf := makeCert(t, "Leaf", 1) + notEmbedded := makeCert(t, "Other", 2) + if _, err := Inspect(writePE(t, buildSignedData(t, leaf, []*x509.Certificate{notEmbedded}, nil))); err == nil { + t.Fatal("signer missing from certificates accepted") + } + txt := filepath.Join(t.TempDir(), "a.txt") + _ = os.WriteFile(txt, []byte("not a PE"), 0o644) + if _, err := Inspect(txt); err == nil { + t.Fatal("non-PE accepted") + } +} + +func TestSamePublisher(t *testing.T) { + sig := func(subject string, err error) func(string) (*Signature, error) { + return func(string) (*Signature, error) { + if err != nil { + return nil, err + } + return &Signature{Subject: subject, Timestamped: true}, nil + } + } + a := "CN=StepSecurity Inc" + if err := samePublisher("c", "s", sig(a, nil), sig(a, nil), ""); err != nil { + t.Fatalf("same subject: %v", err) + } + if err := samePublisher("c", "s", sig("CN=Evil", nil), sig(a, nil), ""); !errors.Is(err, ErrPublisherMismatch) { + t.Fatalf("other subject: %v", err) + } + if err := samePublisher("c", "s", sig(a, nil), sig("", ErrUnsigned), ""); !errors.Is(err, ErrNoPublisher) { + t.Fatalf("unsigned self: %v", err) + } + if err := samePublisher("c", "s", sig(a, nil), sig("", ErrUnsigned), a); err != nil { + t.Fatalf("unsigned self with pin: %v", err) + } + if err := samePublisher("c", "s", sig(a, nil), sig(a, nil), "CN=Pinned"); !errors.Is(err, ErrPublisherMismatch) { + t.Fatalf("pin must win over own subject: %v", err) + } + if err := samePublisher("c", "s", sig("", ErrNotTimestamped), sig(a, nil), ""); !errors.Is(err, ErrNotTimestamped) { + t.Fatalf("candidate error not propagated: %v", err) + } +} diff --git a/internal/authenticode/testkeys_off.go b/internal/authenticode/testkeys_off.go new file mode 100644 index 00000000..06ce9047 --- /dev/null +++ b/internal/authenticode/testkeys_off.go @@ -0,0 +1,5 @@ +//go:build !selfupdate_testkeys + +package authenticode + +func testPinnedSubject() string { return "" } diff --git a/internal/authenticode/testkeys_on.go b/internal/authenticode/testkeys_on.go new file mode 100644 index 00000000..ea22ce06 --- /dev/null +++ b/internal/authenticode/testkeys_on.go @@ -0,0 +1,14 @@ +//go:build selfupdate_testkeys + +package authenticode + +import ( + "os" + "strings" +) + +// testPinnedSubject is the expected publisher subject for test-signed +// fixtures (spec Contracts: test keys). +func testPinnedSubject() string { + return strings.TrimSpace(os.Getenv("DMG_TEST_AUTHENTICODE_SUBJECT")) +} diff --git a/internal/authenticode/verify_other.go b/internal/authenticode/verify_other.go new file mode 100644 index 00000000..fa4fa860 --- /dev/null +++ b/internal/authenticode/verify_other.go @@ -0,0 +1,5 @@ +//go:build !windows + +package authenticode + +func winVerifyTrust(string) error { return ErrUnsupported } diff --git a/internal/authenticode/verify_other_test.go b/internal/authenticode/verify_other_test.go new file mode 100644 index 00000000..0326bb33 --- /dev/null +++ b/internal/authenticode/verify_other_test.go @@ -0,0 +1,17 @@ +//go:build !windows + +package authenticode + +import ( + "errors" + "testing" +) + +func TestVerifyUnsupportedOffWindows(t *testing.T) { + if _, err := Verify("/bin/sh"); !errors.Is(err, ErrUnsupported) { + t.Fatalf("Verify = %v, want ErrUnsupported", err) + } + if err := SamePublisher("/bin/sh", "/bin/sh"); !errors.Is(err, ErrUnsupported) { + t.Fatalf("SamePublisher = %v, want ErrUnsupported", err) + } +} diff --git a/internal/authenticode/verify_windows.go b/internal/authenticode/verify_windows.go new file mode 100644 index 00000000..3a711e73 --- /dev/null +++ b/internal/authenticode/verify_windows.go @@ -0,0 +1,47 @@ +//go:build windows + +package authenticode + +import ( + "errors" + "fmt" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + trustENoSignature = 0x800B0100 // TRUST_E_NOSIGNATURE + trustESubjectFormUnk = 0x800B0003 // TRUST_E_SUBJECT_FORM_UNKNOWN +) + +// winVerifyTrust runs WINTRUST_ACTION_GENERIC_VERIFY_V2 on path with no UI +// and no revocation checks (offline devices must not fail). Chain building +// may still fetch missing roots through Windows root auto-update. +func winVerifyTrust(path string) error { + p, err := windows.UTF16PtrFromString(path) + if err != nil { + return err + } + file := &windows.WinTrustFileInfo{FilePath: p} + file.Size = uint32(unsafe.Sizeof(*file)) + data := &windows.WinTrustData{ + UIChoice: windows.WTD_UI_NONE, + RevocationChecks: windows.WTD_REVOKE_NONE, + UnionChoice: windows.WTD_CHOICE_FILE, + StateAction: windows.WTD_STATEACTION_VERIFY, + FileOrCatalogOrBlobOrSgnrOrCert: unsafe.Pointer(file), + } + data.Size = uint32(unsafe.Sizeof(*data)) + verr := windows.WinVerifyTrustEx(windows.InvalidHWND, &windows.WINTRUST_ACTION_GENERIC_VERIFY_V2, data) + data.StateAction = windows.WTD_STATEACTION_CLOSE + _ = windows.WinVerifyTrustEx(windows.InvalidHWND, &windows.WINTRUST_ACTION_GENERIC_VERIFY_V2, data) + if verr == nil { + return nil + } + var errno windows.Errno + if errors.As(verr, &errno) && (uint32(errno) == trustENoSignature || uint32(errno) == trustESubjectFormUnk) { + return fmt.Errorf("%w: %s", ErrUnsigned, path) + } + return fmt.Errorf("%w: %s: %w", ErrUntrusted, path, verr) +} diff --git a/internal/authenticode/verify_windows_test.go b/internal/authenticode/verify_windows_test.go new file mode 100644 index 00000000..626d3b16 --- /dev/null +++ b/internal/authenticode/verify_windows_test.go @@ -0,0 +1,182 @@ +//go:build windows + +package authenticode + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "golang.org/x/sys/windows" +) + +// testCert is a self-signed code-signing cert in LocalMachine\My, trusted +// through LocalMachine\Root and TrustedPublisher for the test's duration. +type testCert struct { + thumb string + subject string +} + +func powershell(t *testing.T, script string) string { + t.Helper() + out, err := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", + "$ErrorActionPreference='Stop'; "+script).CombinedOutput() + if err != nil { + t.Fatalf("powershell: %v\n%s\nscript: %s", err, out, script) + } + return strings.TrimSpace(string(out)) +} + +func newTestCert(t *testing.T, cn string) *testCert { + t.Helper() + subject := fmt.Sprintf("CN=%s %d", cn, time.Now().UnixNano()) + thumb := powershell(t, fmt.Sprintf(` +$c = New-SelfSignedCertificate -Type CodeSigningCert -Subject '%s' -CertStoreLocation Cert:\LocalMachine\My -NotAfter (Get-Date).AddDays(2) +$f = Join-Path $env:TEMP ($c.Thumbprint + '.cer') +Export-Certificate -Cert $c -FilePath $f | Out-Null +Import-Certificate -FilePath $f -CertStoreLocation Cert:\LocalMachine\Root | Out-Null +Import-Certificate -FilePath $f -CertStoreLocation Cert:\LocalMachine\TrustedPublisher | Out-Null +Remove-Item $f +$c.Thumbprint`, subject)) + t.Cleanup(func() { + for _, store := range []string{"My", "Root", "TrustedPublisher"} { + _ = exec.Command("powershell.exe", "-NoProfile", "-Command", + fmt.Sprintf(`Remove-Item -Force -ErrorAction SilentlyContinue Cert:\LocalMachine\%s\%s`, store, thumb)).Run() + } + }) + return &testCert{thumb: thumb, subject: subject} +} + +// sign signs path; tsa empty means no timestamp. It reports false when the +// timestamp server could not be reached. +func (c *testCert) sign(t *testing.T, path, tsa string) bool { + t.Helper() + ts := "" + if tsa != "" { + ts = "-TimestampServer '" + tsa + "'" + } + out, err := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", fmt.Sprintf(` +$c = Get-Item Cert:\LocalMachine\My\%s +$r = Set-AuthenticodeSignature -FilePath '%s' -Certificate $c -HashAlgorithm SHA256 %s +$r.Status`, c.thumb, path, ts)).CombinedOutput() + status := strings.TrimSpace(string(out)) + if err != nil || status != "Valid" { + if tsa != "" { + t.Logf("timestamped signing unavailable (%v): %s", err, status) + return false + } + t.Fatalf("signing %s: %v: %s", path, err, status) + } + return true +} + +func copyExe(t *testing.T) string { + t.Helper() + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + in, err := os.Open(self) + if err != nil { + t.Fatal(err) + } + defer in.Close() + p := filepath.Join(t.TempDir(), "candidate.exe") + out, err := os.Create(p) + if err != nil { + t.Fatal(err) + } + if _, err := io.Copy(out, in); err != nil { + t.Fatal(err) + } + if err := out.Close(); err != nil { + t.Fatal(err) + } + return p +} + +func requireElevated(t *testing.T) { + t.Helper() + if !windows.GetCurrentProcessToken().IsElevated() { + t.Skip("needs an elevated token to trust a test certificate machine-wide") + } +} + +func TestVerifyUnsignedWindows(t *testing.T) { + if _, err := Verify(copyExe(t)); !errors.Is(err, ErrUnsigned) { + t.Fatalf("unsigned: %v, want ErrUnsigned", err) + } + // The test binary is unsigned, so without a pin no update is allowed. + if testPinnedSubject() == "" { + self, _ := os.Executable() + if err := samePublisher(copyExe(t), self, func(string) (*Signature, error) { return &Signature{Subject: "CN=x"}, nil }, Inspect, ""); !errors.Is(err, ErrNoPublisher) { + t.Fatalf("unsigned self: %v, want ErrNoPublisher", err) + } + } +} + +func TestVerifySignedWindows(t *testing.T) { + requireElevated(t) + a := newTestCert(t, "DMG Test Publisher A") + + noTS := copyExe(t) + a.sign(t, noTS, "") + if _, err := Verify(noTS); !errors.Is(err, ErrNotTimestamped) { + t.Fatalf("untimestamped: %v, want ErrNotTimestamped", err) + } + if sig, err := Inspect(noTS); err != nil || sig.Subject != a.subject { + t.Fatalf("Inspect = %+v, %v; want subject %q", sig, err, a.subject) + } + + // A flipped byte after signing breaks the digest. + tampered := copyExe(t) + a.sign(t, tampered, "") + f, err := os.OpenFile(tampered, os.O_RDWR, 0) + if err != nil { + t.Fatal(err) + } + b := make([]byte, 1) + _, _ = f.ReadAt(b, 4096) + b[0] ^= 0xff + _, _ = f.WriteAt(b, 4096) + _ = f.Close() + if err := winVerifyTrust(tampered); !errors.Is(err, ErrUntrusted) { + t.Fatalf("tampered: %v, want ErrUntrusted", err) + } + + const tsa = "http://timestamp.digicert.com" + good := copyExe(t) + if !a.sign(t, good, tsa) { + t.Skip("timestamp server unreachable; timestamped cases not run") + } + sig, err := Verify(good) + if err != nil { + t.Fatalf("timestamped signed: %v", err) + } + if sig.Subject != a.subject || !sig.Timestamped { + t.Fatalf("sig = %+v", sig) + } + self := copyExe(t) + a.sign(t, self, tsa) + if err := SamePublisher(good, self); err != nil && testPinnedSubject() == "" { + t.Fatalf("same publisher: %v", err) + } + + bCert := newTestCert(t, "DMG Test Publisher B") + wrong := copyExe(t) + if !bCert.sign(t, wrong, tsa) { + t.Skip("timestamp server unreachable") + } + if err := SamePublisher(wrong, self); !errors.Is(err, ErrPublisherMismatch) { + t.Fatalf("wrong publisher: %v, want ErrPublisherMismatch", err) + } + if err := samePublisher(good, copyExe(t), Verify, Inspect, a.subject); err != nil { + t.Fatalf("pinned subject: %v", err) + } +} diff --git a/internal/selfupdate/release.go b/internal/selfupdate/release.go index 91dbbb79..efa17fe1 100644 --- a/internal/selfupdate/release.go +++ b/internal/selfupdate/release.go @@ -274,7 +274,7 @@ func (c *Client) downloadURL(ctx context.Context, assetURL, dst, want string) er if resp.ContentLength > maxAssetBytes { return fmt.Errorf("%w: %s exceeds the size limit", ErrDownload, assetURL) } - // #nosec G304 -- dst is inside the updater's own staging directory. + // #nosec G302 G304 -- dst is inside the updater's own staging directory; release executables are 0755. f, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o755) if err != nil { return err diff --git a/internal/selfupdate/sshsig_test.go b/internal/selfupdate/sshsig_test.go index dfd91e34..fd5471d6 100644 --- a/internal/selfupdate/sshsig_test.go +++ b/internal/selfupdate/sshsig_test.go @@ -18,16 +18,6 @@ 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-----` ) diff --git a/internal/updatestate/acl_other.go b/internal/updatestate/acl_other.go index 3c90de1f..3d525af5 100644 --- a/internal/updatestate/acl_other.go +++ b/internal/updatestate/acl_other.go @@ -11,6 +11,7 @@ func EnsureProtectedDir(path string) (fixed bool, err error) { if err != nil || exists { return false, err } + // #nosec G301 -- versions\ and state\ are world-readable by design (Users RX). if err := os.Mkdir(path, 0o755); err != nil && !os.IsExist(err) { return false, err } diff --git a/internal/updatestate/runs.go b/internal/updatestate/runs.go index cdaf1758..3d67a59c 100644 --- a/internal/updatestate/runs.go +++ b/internal/updatestate/runs.go @@ -73,6 +73,7 @@ func writeRuns(path string, recs []RunRecord) error { if err != nil { return err } + // #nosec G301 -- runs\ is shared by the user scan and the SYSTEM updater. if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } From d3144aeda4775ba4424d7a5ac7cebd6e6fe4f7c6 Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Mon, 19 Oct 2026 18:14:44 +0530 Subject: [PATCH 07/34] feat(schtasks): add the SYSTEM update task and bootstrap scan actions On an MSI bootstrap install, install points both scan tasks at `bootstrap.exe run send-telemetry` and (re)creates the SYSTEM task "StepSecurity Dev Machine Guard (Update)" from a full task XML: hourly with a 10 min random delay plus at boot after 15 min, StartWhenAvailable, battery allowed, 30 min limit, IgnoreNew. The registration is verified. Uninstall of a bootstrap install deletes all three tasks, waits up to 60 s for the scan mutex, terminates DMG processes under the install root, and removes versions\, state\ and RunningVersion. Non-bootstrap uninstalls keep their behaviour and never touch the update task. --- .github/workflows/tests.yml | 3 +- internal/schtasks/schtasks.go | 44 +++- internal/schtasks/selfupdate.go | 248 ++++++++++++++++++++ internal/schtasks/selfupdate_other.go | 13 ++ internal/schtasks/selfupdate_test.go | 287 ++++++++++++++++++++++++ internal/schtasks/selfupdate_windows.go | 113 ++++++++++ 6 files changed, 701 insertions(+), 7 deletions(-) create mode 100644 internal/schtasks/selfupdate.go create mode 100644 internal/schtasks/selfupdate_other.go create mode 100644 internal/schtasks/selfupdate_test.go create mode 100644 internal/schtasks/selfupdate_windows.go diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d412b6b6..73905fa6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -155,10 +155,11 @@ jobs: run: go test -race -count=1 -run 'Test(IsTaskRegistered|DetectInvocationMethod_Windows|HideWindow)' ./internal/schtasks/ ./internal/telemetry/ ./internal/winproc/ - name: Test self-update fault injection (selfupdate_faults tag) run: go test -race -count=1 -tags selfupdate_faults ./internal/faultinject/ ./internal/updatestate/ - - name: Test launcher bootstrap mode (job objects, bootstrap matrix) + - name: Test launcher bootstrap mode and self-update tasks run: | go test -race -count=1 ./internal/launcher/ go test -race -count=1 -tags selfupdate_faults ./internal/launcher/ + go test -race -count=1 -run 'Test(UpdateTask|ResolveBootstrapRoot|BuildCreateArgs_Bootstrap|InstallUpdateTask|Uninstall_)' ./internal/schtasks/ smoke: name: Smoke Tests diff --git a/internal/schtasks/schtasks.go b/internal/schtasks/schtasks.go index d4152cda..782f71f3 100644 --- a/internal/schtasks/schtasks.go +++ b/internal/schtasks/schtasks.go @@ -14,6 +14,7 @@ import ( "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/launcher" "github.com/step-security/dev-machine-guard/internal/paths" "github.com/step-security/dev-machine-guard/internal/progress" ) @@ -61,7 +62,7 @@ func Install(exec executor.Executor, log *progress.Logger, maxExec time.Duration log.Progress("Previous installation removed. Installing new version...") } - binaryPath, err := os.Executable() + binaryPath, err := executablePath() if err != nil { return fmt.Errorf("determining binary path: %w", err) } @@ -125,6 +126,15 @@ func Install(exec executor.Executor, log *progress.Logger, maxExec time.Duration applyBatterySettings(ctx, exec, log, logonTaskName, timeLimit) } + // Self-update (MSI bootstrap layout): the SYSTEM update task. Best-effort + // like the logon task — scans don't depend on it, and failing here would + // roll back the whole MSI. msi-smoke asserts it exists. + if root, ok := resolveBootstrapRoot(exec, binaryPath); ok && exec.IsRoot() { + if err := installUpdateTask(ctx, exec, log, filepath.Join(root, launcher.BootstrapBinary), time.Now()); err != nil { + log.Warn("could not create the self-update task %q: %v — scans are unaffected", updateTaskName, err) + } + } + // Turn on Task Scheduler's operational history so every fire / miss / // failure is recorded in Event Viewer — the primary way to debug why a // scheduled scan didn't run. Best-effort (needs admin). @@ -163,10 +173,18 @@ func RunNow(exec executor.Executor, log *progress.Logger) error { return nil } -// Uninstall removes the scheduled task. +// Uninstall removes the scheduled task. For an MSI bootstrap install it also +// removes the update task, stops running DMG processes and deletes the +// self-update state (see uninstallBootstrap). func Uninstall(exec executor.Executor, log *progress.Logger) error { ctx := context.Background() + if self, err := executablePath(); err == nil { + if root, ok := resolveBootstrapRoot(exec, self); ok { + return uninstallBootstrap(ctx, exec, log, root) + } + } + if !isConfigured(ctx, exec) { log.Progress("Agent is not currently configured for periodic execution") return nil @@ -209,10 +227,17 @@ func isConfigured(ctx context.Context, exec executor.Executor) bool { // (ad-hoc deploys of just the agent .exe), we fall back to the agent // directly — subprocess flashes are still suppressed via winproc, only // the agent's own console-flash mitigation is forfeited. +// +// An MSI bootstrap install (stepsecurity-dev-machine-guard-bootstrap.exe in +// the install root) wins: the action becomes `bootstrap.exe run +// send-telemetry …`, which resolves the self-update version at each run. func resolveTaskBinary(exec executor.Executor, agentPath string) string { - launcher := filepath.Join(filepath.Dir(agentPath), launcherName) - if exec.FileExists(launcher) { - return launcher + if root, ok := resolveBootstrapRoot(exec, agentPath); ok { + return filepath.Join(root, launcher.BootstrapBinary) + } + launcherPath := filepath.Join(filepath.Dir(agentPath), launcherName) + if exec.FileExists(launcherPath) { + return launcherPath } return agentPath } @@ -223,8 +248,15 @@ func resolveTaskBinary(exec executor.Executor, agentPath string) string { // wrapper) — env config and log routing both live in the binary now // (--install-dir + filelog), and the wrapper was the source of a visible // cmd.exe flash on every fire. +// +// When binaryPath is the bootstrap, the agent arguments go behind its `run` +// verb. func buildCreateArgs(name, binaryPath, stepHome string, schedule []string, isAdmin bool) []string { - taskCmd := fmt.Sprintf(`"%s" send-telemetry --install-dir="%s"`, binaryPath, stepHome) + verb := "" + if launcher.IsBootstrapBinary(binaryPath) { + verb = "run " + } + taskCmd := fmt.Sprintf(`"%s" %ssend-telemetry --install-dir="%s"`, binaryPath, verb, stepHome) args := []string{"/create", "/tn", name, "/tr", taskCmd} args = append(args, schedule...) args = append(args, "/f") diff --git a/internal/schtasks/selfupdate.go b/internal/schtasks/selfupdate.go new file mode 100644 index 00000000..7b34d916 --- /dev/null +++ b/internal/schtasks/selfupdate.go @@ -0,0 +1,248 @@ +package schtasks + +import ( + "context" + "encoding/xml" + "errors" + "fmt" + "html" + "os" + "path/filepath" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/launcher" + "github.com/step-security/dev-machine-guard/internal/progress" + "github.com/step-security/dev-machine-guard/internal/updatestate" +) + +// updateTaskName is the SYSTEM self-update task of MSI bootstrap installs. +// It has its own name so the loader's same-name task handling (M-2) never +// touches it. +const updateTaskName = taskName + " (Update)" + +// UpdateTaskName is exported for diagnostics (schedinfo-style queries). +const UpdateTaskName = updateTaskName + +// Update task settings (spec §2). +const ( + updateInterval = "PT1H" + updateRandomDelay = "PT10M" + updateBootDelay = "PT15M" + updateExecutionLimit = "PT30M" +) + +// stopProcessesWait bounds how long uninstall waits for a running scan to +// release the instance mutex before terminating DMG processes. +const stopProcessesWait = 60 * time.Second + +// executablePath is swapped by tests. +var executablePath = os.Executable + +// resolveBootstrapRoot returns the install root of an MSI bootstrap install +// that agentPath belongs to: the agent's own directory (legacy top-level +// layout) or, for seed\\agent.exe and versions\\agent.exe, two levels +// up. ok is false when no bootstrap sits in that root. +func resolveBootstrapRoot(exec executor.Executor, agentPath string) (string, bool) { + dir := filepath.Dir(agentPath) + candidates := []string{dir} + parent := filepath.Dir(dir) + if b := strings.ToLower(filepath.Base(parent)); b == "seed" || b == "versions" { + candidates = append(candidates, filepath.Dir(parent)) + } + for _, root := range candidates { + if exec.FileExists(filepath.Join(root, launcher.BootstrapBinary)) { + return root, true + } + } + return "", false +} + +// updateTaskXML renders the full task definition. Only a full XML import can +// express the SYSTEM principal with two triggers, the random and boot delays +// and IgnoreNew in one registration. +func updateTaskXML(bootstrapPath string, start time.Time) string { + var cmd strings.Builder + _ = xml.EscapeText(&cmd, []byte(bootstrapPath)) + return ` + + + StepSecurity + StepSecurity Dev Machine Guard self-update (SYSTEM). + \` + updateTaskName + ` + + + + + ` + updateInterval + ` + false + + ` + start.Format("2006-01-02T15:04:05") + ` + true + ` + updateRandomDelay + ` + + + true + ` + updateBootDelay + ` + + + + + S-1-5-18 + HighestAvailable + + + + IgnoreNew + false + false + true + true + false + + false + false + + true + true + false + false + false + ` + updateExecutionLimit + ` + 7 + + + + ` + cmd.String() + ` + update + + + +` +} + +// updateTaskMismatches lists the required update-task settings xml lacks. +func updateTaskMismatches(taskXML, bootstrapPath string) []string { + var bad []string + for _, kv := range [][2]string{ + {"UserId", "S-1-5-18"}, + {"MultipleInstancesPolicy", "IgnoreNew"}, + {"DisallowStartIfOnBatteries", "false"}, + {"StopIfGoingOnBatteries", "false"}, + {"StartWhenAvailable", "true"}, + {"ExecutionTimeLimit", updateExecutionLimit}, + {"Arguments", "update"}, + } { + if got, ok := xmlElementValue(taskXML, kv[0]); !ok || got != kv[1] { + // Task Scheduler may render the SYSTEM principal by name. + if kv[0] == "UserId" && strings.EqualFold(got, "SYSTEM") { + continue + } + bad = append(bad, fmt.Sprintf("%s=%q (want %q)", kv[0], got, kv[1])) + } + } + if got, _ := xmlElementValue(taskXML, "Command"); !strings.EqualFold(strings.Trim(html.UnescapeString(got), `"`), bootstrapPath) { + bad = append(bad, fmt.Sprintf("Command=%q (want %q)", got, bootstrapPath)) + } + return bad +} + +// installUpdateTask (re)creates the SYSTEM update task from full XML and +// verifies the registration. +func installUpdateTask(ctx context.Context, exec executor.Executor, log *progress.Logger, bootstrapPath string, now time.Time) error { + if err := importTaskXML(ctx, exec, updateTaskName, updateTaskXML(bootstrapPath, now)); err != nil { + return err + } + out, stderr, code, err := exec.Run(ctx, "schtasks", "/query", "/tn", updateTaskName, "/xml") + if err != nil || code != 0 { + return fmt.Errorf("verifying task (exit %d, %v): %s", code, err, strings.TrimSpace(stderr)) + } + if bad := updateTaskMismatches(decodeTaskXML(out), bootstrapPath); len(bad) > 0 { + return fmt.Errorf("task registered with wrong settings: %s", strings.Join(bad, ", ")) + } + log.Progress(" Task: %s", updateTaskName) + return nil +} + +// importTaskXML registers name from a full task XML (UTF-16LE + BOM temp +// file, `schtasks /create /xml … /f`). +func importTaskXML(ctx context.Context, exec executor.Executor, name, taskXML string) error { + tmp, err := os.CreateTemp("", "stepsec-task-*.xml") + if err != nil { + return fmt.Errorf("creating temp task XML: %w", err) + } + tmpPath := tmp.Name() + defer func() { _ = os.Remove(tmpPath) }() + _, werr := tmp.Write(encodeTaskXMLUTF16(taskXML)) + if cerr := tmp.Close(); werr == nil { + werr = cerr + } + if werr != nil { + return fmt.Errorf("writing temp task XML: %w", werr) + } + _, stderr, code, err := exec.Run(ctx, "schtasks", "/create", "/tn", name, "/xml", tmpPath, "/f") + if err != nil { + return fmt.Errorf("schtasks /create /xml: %w", err) + } + if code != 0 { + return fmt.Errorf("schtasks /create /xml (exit code %d): %s", code, strings.TrimSpace(stderr)) + } + return nil +} + +// deleteTaskIfPresent deletes name when it is registered. A missing task is +// not an error; a failed delete of a present task is. +func deleteTaskIfPresent(ctx context.Context, exec executor.Executor, log *progress.Logger, name string) error { + if _, _, code, _ := exec.Run(ctx, "schtasks", "/query", "/tn", name); code != 0 { + log.Debug("task %q not registered; nothing to delete", name) + return nil + } + _, stderr, code, err := exec.Run(ctx, "schtasks", "/delete", "/tn", name, "/f") + if err != nil { + return fmt.Errorf("deleting task %q: %w", name, err) + } + if code != 0 { + return fmt.Errorf("deleting task %q (exit code %d): %s", name, code, strings.TrimSpace(stderr)) + } + log.Progress("Removed scheduled task: %s", name) + return nil +} + +// Platform hooks, swapped by tests: stop DMG processes under root (waiting +// for the instance mutex first) and delete the RunningVersion value. +var ( + stopDMGProcesses = stopDMGProcessesPlatform + deleteRunningVerReg = deleteRunningVersionPlatform +) + +// uninstallBootstrap is the MSI bootstrap uninstall (spec §5): delete all 3 +// tasks, stop running DMG processes, then remove versions\, state\ and the +// RunningVersion value. ProgramData is left alone (M-8 is out of scope). +// Every step runs even if an earlier one failed; the errors are joined. +func uninstallBootstrap(ctx context.Context, exec executor.Executor, log *progress.Logger, root string) error { + var errs []error + for _, name := range []string{taskName, logonTaskName, updateTaskName} { + if err := deleteTaskIfPresent(ctx, exec, log, name); err != nil { + errs = append(errs, err) + } + } + if err := stopDMGProcesses(root, stopProcessesWait, log); err != nil { + log.Warn("stopping running DMG processes: %v", err) + } + layout := updatestate.Layout{InstallRoot: root} + for _, dir := range []string{layout.VersionsDir(), layout.StateDir()} { + if err := os.RemoveAll(dir); err != nil { + errs = append(errs, fmt.Errorf("removing %s: %w", dir, err)) + } else { + log.Debug("removed %s", dir) + } + } + if err := deleteRunningVerReg(); err != nil { + errs = append(errs, fmt.Errorf("deleting RunningVersion: %w", err)) + } + if len(errs) == 0 { + log.Progress("Windows Task Scheduler configuration and self-update state removed successfully") + } + return errors.Join(errs...) +} diff --git a/internal/schtasks/selfupdate_other.go b/internal/schtasks/selfupdate_other.go new file mode 100644 index 00000000..468398af --- /dev/null +++ b/internal/schtasks/selfupdate_other.go @@ -0,0 +1,13 @@ +//go:build !windows + +package schtasks + +import ( + "time" + + "github.com/step-security/dev-machine-guard/internal/progress" +) + +func stopDMGProcessesPlatform(string, time.Duration, *progress.Logger) error { return nil } + +func deleteRunningVersionPlatform() error { return nil } diff --git a/internal/schtasks/selfupdate_test.go b/internal/schtasks/selfupdate_test.go new file mode 100644 index 00000000..e6c7aea0 --- /dev/null +++ b/internal/schtasks/selfupdate_test.go @@ -0,0 +1,287 @@ +package schtasks + +import ( + "context" + "encoding/xml" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/launcher" + "github.com/step-security/dev-machine-guard/internal/progress" +) + +const testBootstrap = `C:\Program Files\StepSecurity\stepsecurity-dev-machine-guard-bootstrap.exe` + +func TestUpdateTaskXML_WellFormedAndComplete(t *testing.T) { + start := time.Date(2026, 10, 19, 13, 4, 5, 0, time.Local) + doc := updateTaskXML(`C:\Program Files\A&B\`+launcher.BootstrapBinary, start) + dec := xml.NewDecoder(strings.NewReader(strings.Replace(doc, `encoding="UTF-16"`, `encoding="UTF-8"`, 1))) + for { + _, err := dec.Token() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + t.Fatalf("not well-formed: %v\n%s", err, doc) + } + } + for _, want := range []string{ + `S-1-5-18`, + `HighestAvailable`, + `PT1H`, + `PT10M`, + ``, + `PT15M`, + `2026-10-19T13:04:05`, + `IgnoreNew`, + `false`, + `false`, + `true`, + `PT30M`, + `C:\Program Files\A&B\stepsecurity-dev-machine-guard-bootstrap.exe`, + `update`, + `\StepSecurity Dev Machine Guard (Update)`, + } { + if !strings.Contains(doc, want) { + t.Errorf("missing %s", want) + } + } + // The XML must round-trip through the UTF-16 import encoding. + if got := decodeTaskXML(string(encodeTaskXMLUTF16(doc))); got != doc { + t.Fatal("UTF-16 round trip changed the XML") + } + if bad := updateTaskMismatches(doc, `C:\Program Files\A&B\`+launcher.BootstrapBinary); len(bad) != 0 { + t.Fatalf("own XML mismatches: %v", bad) + } +} + +func TestUpdateTaskMismatches(t *testing.T) { + doc := updateTaskXML(testBootstrap, time.Now()) + if bad := updateTaskMismatches(strings.Replace(doc, "S-1-5-18", "SYSTEM", 1), testBootstrap); len(bad) != 0 { + t.Fatalf("SYSTEM by name rejected: %v", bad) + } + broken := strings.Replace(doc, "IgnoreNew", "Parallel", 1) + broken = strings.Replace(broken, "true", "false", 1) + if bad := updateTaskMismatches(broken, `C:\other.exe`); len(bad) != 3 { + t.Fatalf("mismatches = %v", bad) + } +} + +func TestResolveBootstrapRoot(t *testing.T) { + root := filepath.Join("/", "Program Files", "StepSecurity") + mock := executor.NewMock() + mock.SetFile(filepath.Join(root, launcher.BootstrapBinary), []byte{}) + for _, agent := range []string{ + filepath.Join(root, "seed", "1.2.3", "agent.exe"), + filepath.Join(root, "versions", "1.2.4", "agent.exe"), + filepath.Join(root, "stepsecurity-dev-machine-guard.exe"), + } { + if got, ok := resolveBootstrapRoot(mock, agent); !ok || got != root { + t.Errorf("%s: root=%q ok=%v", agent, got, ok) + } + } + for _, agent := range []string{ + filepath.Join(root, "other", "1.2.3", "agent.exe"), + filepath.Join("/", "elsewhere", "stepsecurity-dev-machine-guard.exe"), + } { + if _, ok := resolveBootstrapRoot(mock, agent); ok { + t.Errorf("%s: unexpected bootstrap root", agent) + } + } + if got := resolveTaskBinary(mock, filepath.Join(root, "seed", "1.2.3", "agent.exe")); got != filepath.Join(root, launcher.BootstrapBinary) { + t.Errorf("task binary = %q", got) + } +} + +func TestBuildCreateArgs_BootstrapRunVerb(t *testing.T) { + args := buildCreateArgs(taskName, testBootstrap, `C:\ProgramData\StepSecurity`, hourlySchedule(4), true) + want := `"` + testBootstrap + `" run send-telemetry --install-dir="C:\ProgramData\StepSecurity"` + if got := trArg(t, args); got != want { + t.Fatalf("/tr = %q, want %q", got, want) + } + if len(want) > 261 { + t.Fatalf("/tr is %d chars; schtasks caps it at 261", len(want)) + } + logon := buildCreateArgs(logonTaskName, testBootstrap, `C:\ProgramData\StepSecurity`, []string{"/sc", "ONLOGON"}, true) + if got := trArg(t, logon); got != want { + t.Fatalf("logon /tr = %q", got) + } + legacy := buildCreateArgs(taskName, `C:\x\stepsecurity-dev-machine-guard-task.exe`, `C:\d`, hourlySchedule(4), true) + if got := trArg(t, legacy); strings.Contains(got, " run ") { + t.Fatalf("legacy action got the run verb: %q", got) + } +} + +// updateTaskExec answers schtasks calls from a table and records them. +type updateTaskExec struct { + *executor.Mock + calls []string + present map[string]bool + queryXML string + failOn string + files map[string][]byte // captured /xml imports +} + +func (e *updateTaskExec) Run(_ context.Context, name string, args ...string) (string, string, int, error) { + line := strings.Join(append([]string{name}, args...), " ") + e.calls = append(e.calls, line) + if e.failOn != "" && strings.Contains(line, e.failOn) { + return "", "boom", 1, nil + } + if name != "schtasks" || len(args) < 3 { + return "", "", 0, nil + } + task := args[2] + switch args[0] { + case "/query": + if !e.present[task] { + return "", "ERROR: not found", 1, nil + } + if len(args) > 3 && args[3] == "/xml" { + return e.queryXML, "", 0, nil + } + case "/delete": + delete(e.present, task) + case "/create": + if len(args) > 4 && args[3] == "/xml" { + data, _ := os.ReadFile(args[4]) + e.files[task] = data + } + e.present[task] = true + } + return "", "", 0, nil +} + +func newUpdateTaskExec() *updateTaskExec { + return &updateTaskExec{Mock: executor.NewMock(), present: map[string]bool{}, files: map[string][]byte{}} +} + +func TestInstallUpdateTask_ImportsAndVerifies(t *testing.T) { + e := newUpdateTaskExec() + now := time.Date(2026, 10, 19, 1, 2, 3, 0, time.Local) + e.queryXML = string(encodeTaskXMLUTF16(updateTaskXML(testBootstrap, now))) + log := progress.NewLogger(progress.LevelInfo) + if err := installUpdateTask(context.Background(), e, log, testBootstrap, now); err != nil { + t.Fatal(err) + } + got := decodeTaskXML(string(e.files[updateTaskName])) + if got != updateTaskXML(testBootstrap, now) { + t.Fatalf("imported XML differs:\n%s", got) + } + if !strings.HasPrefix(string(e.files[updateTaskName]), "\xff\xfe") { + t.Fatal("import file is not UTF-16LE with BOM") + } + // Idempotent: a second install overwrites (/f) and verifies again. + if err := installUpdateTask(context.Background(), e, log, testBootstrap, now); err != nil { + t.Fatal(err) + } + for _, c := range e.calls { + if strings.HasPrefix(c, "schtasks /create") && !strings.HasSuffix(c, "/f") { + t.Fatalf("create without /f: %s", c) + } + } +} + +func TestInstallUpdateTask_VerifyFailure(t *testing.T) { + e := newUpdateTaskExec() + e.queryXML = strings.Replace(updateTaskXML(testBootstrap, time.Now()), "IgnoreNew", "Queue", 1) + if err := installUpdateTask(context.Background(), e, newTestLogger(), testBootstrap, time.Now()); err == nil || !strings.Contains(err.Error(), "MultipleInstancesPolicy") { + t.Fatalf("err = %v", err) + } + e = newUpdateTaskExec() + e.failOn = "/create" + if err := installUpdateTask(context.Background(), e, newTestLogger(), testBootstrap, time.Now()); err == nil { + t.Fatal("expected create failure") + } +} + +func stubPlatformHooks(t *testing.T) (stopped *string, regDeleted *bool) { + t.Helper() + var s string + var r bool + oldStop, oldReg, oldExe := stopDMGProcesses, deleteRunningVerReg, executablePath + stopDMGProcesses = func(root string, wait time.Duration, _ *progress.Logger) error { + if wait != stopProcessesWait { + t.Errorf("wait = %s", wait) + } + s = root + return nil + } + deleteRunningVerReg = func() error { r = true; return nil } + t.Cleanup(func() { stopDMGProcesses, deleteRunningVerReg, executablePath = oldStop, oldReg, oldExe }) + return &s, &r +} + +func TestUninstall_BootstrapRemovesEverything(t *testing.T) { + root := t.TempDir() + for _, d := range []string{"versions/1.2.3", "state", "seed/1.2.3"} { + if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(root, "state", "state.json"), []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + stopped, reg := stubPlatformHooks(t) + executablePath = func() (string, error) { return filepath.Join(root, "seed", "1.2.3", "agent.exe"), nil } + + e := newUpdateTaskExec() + e.SetFile(filepath.Join(root, launcher.BootstrapBinary), []byte{}) + e.present[taskName] = true + e.present[updateTaskName] = true // logon task already gone: not an error + if err := Uninstall(e, newTestLogger()); err != nil { + t.Fatal(err) + } + if len(e.present) != 0 { + t.Fatalf("tasks left: %v", e.present) + } + if *stopped != root || !*reg { + t.Fatalf("stopped=%q reg=%v", *stopped, *reg) + } + for _, d := range []string{"versions", "state"} { + if _, err := os.Stat(filepath.Join(root, d)); !os.IsNotExist(err) { + t.Fatalf("%s not removed", d) + } + } + if _, err := os.Stat(filepath.Join(root, "seed")); err != nil { + t.Fatal("seed is MSI-owned and must be left to the MSI") + } +} + +func TestUninstall_BootstrapContinuesPastFailures(t *testing.T) { + root := t.TempDir() + stopDone, _ := stubPlatformHooks(t) + executablePath = func() (string, error) { return filepath.Join(root, "seed", "1.2.3", "agent.exe"), nil } + e := newUpdateTaskExec() + e.SetFile(filepath.Join(root, launcher.BootstrapBinary), []byte{}) + e.present[taskName] = true + e.present[updateTaskName] = true + e.failOn = "/delete /tn " + taskName + " /f" + err := Uninstall(e, newTestLogger()) + if err == nil { + t.Fatal("expected the failed delete to be reported") + } + if e.present[updateTaskName] || *stopDone != root { + t.Fatal("later steps skipped after a failure") + } +} + +func TestUninstall_LegacyLeavesUpdateTask(t *testing.T) { + stubPlatformHooks(t) + executablePath = func() (string, error) { return filepath.Join(t.TempDir(), "stepsecurity-dev-machine-guard.exe"), nil } + e := newUpdateTaskExec() + e.present[taskName] = true + e.present[updateTaskName] = true + if err := Uninstall(e, newTestLogger()); err != nil { + t.Fatal(err) + } + if !e.present[updateTaskName] { + t.Fatal("a non-bootstrap uninstall deleted the MSI's update task (M-2)") + } +} diff --git a/internal/schtasks/selfupdate_windows.go b/internal/schtasks/selfupdate_windows.go new file mode 100644 index 00000000..f045643a --- /dev/null +++ b/internal/schtasks/selfupdate_windows.go @@ -0,0 +1,113 @@ +//go:build windows + +package schtasks + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "time" + "unsafe" + + "github.com/step-security/dev-machine-guard/internal/progress" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" +) + +// instanceMutex is the scan singleton (internal/lock). +const instanceMutex = `Global\StepSecurityDevMachineGuard` + +// stopDMGProcessesPlatform waits up to wait for a running scan to release the +// instance mutex, then terminates every process (other than this one) whose +// image lives under root: launchers, agents and bootstraps from versions\, +// seed\ or the root itself. Fixes the files-in-use reboot of M-18. +func stopDMGProcessesPlatform(root string, wait time.Duration, log *progress.Logger) error { + if waitForInstanceMutex(wait) { + log.Debug("instance mutex free; no scan running") + } else { + log.Warn("a scan still holds the instance mutex after %s; terminating DMG processes", wait) + } + return terminateUnder(root, log) +} + +// waitForInstanceMutex reports whether the mutex is absent or was acquired +// (and released) within wait. Ownership is per thread, so the wait and the +// release run on one locked OS thread. +func waitForInstanceMutex(wait time.Duration) bool { + res := make(chan bool, 1) + go func() { + runtime.LockOSThread() // never unlocked: the thread exits with the goroutine + name, _ := windows.UTF16PtrFromString(instanceMutex) + h, err := windows.OpenMutex(windows.SYNCHRONIZE|windows.MUTEX_MODIFY_STATE, false, name) + if err != nil { + res <- errors.Is(err, windows.ERROR_FILE_NOT_FOUND) + return + } + defer func() { _ = windows.CloseHandle(h) }() + ev, _ := windows.WaitForSingleObject(h, uint32(wait/time.Millisecond)) // #nosec G115 -- bounded constant + if ev == windows.WAIT_OBJECT_0 || ev == windows.WAIT_ABANDONED { + _ = windows.ReleaseMutex(h) + res <- true + return + } + res <- false + }() + return <-res +} + +func terminateUnder(root string, log *progress.Logger) error { + prefix := strings.ToLower(filepath.Clean(root)) + `\` + snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return fmt.Errorf("process snapshot: %w", err) + } + defer func() { _ = windows.CloseHandle(snap) }() + self := uint32(os.Getpid()) // #nosec G115 -- a pid fits in uint32 + var pe windows.ProcessEntry32 + pe.Size = uint32(unsafe.Sizeof(pe)) + var errs []error + for err = windows.Process32First(snap, &pe); err == nil; err = windows.Process32Next(snap, &pe) { + if pe.ProcessID == self || pe.ProcessID == 0 { + continue + } + h, oerr := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.PROCESS_TERMINATE|windows.SYNCHRONIZE, false, pe.ProcessID) + if oerr != nil { + continue + } + buf := make([]uint16, windows.MAX_LONG_PATH) + n := uint32(len(buf)) + if qerr := windows.QueryFullProcessImageName(h, 0, &buf[0], &n); qerr == nil { + img := windows.UTF16ToString(buf[:n]) + if strings.HasPrefix(strings.ToLower(img), prefix) { + if terr := windows.TerminateProcess(h, 1); terr != nil { + errs = append(errs, fmt.Errorf("terminating %s (pid %d): %w", img, pe.ProcessID, terr)) + } else { + _, _ = windows.WaitForSingleObject(h, 5000) + log.Progress("Stopped running DMG process %s (pid %d)", img, pe.ProcessID) + } + } + } + _ = windows.CloseHandle(h) + } + return errors.Join(errs...) +} + +// deleteRunningVersionPlatform removes HKLM\Software\StepSecurity\RunningVersion +// (updater-owned). AgentVersion is MSI-owned and left to the MSI. +func deleteRunningVersionPlatform() error { + k, err := registry.OpenKey(registry.LOCAL_MACHINE, `Software\StepSecurity`, registry.SET_VALUE|registry.WOW64_64KEY) + if err != nil { + if errors.Is(err, registry.ErrNotExist) { + return nil + } + return err + } + defer k.Close() + if err := k.DeleteValue("RunningVersion"); err != nil && !errors.Is(err, registry.ErrNotExist) { + return err + } + return nil +} From 5effb3e34cced2b45af8a8dd1087e366525a98c4 Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Mon, 19 Oct 2026 18:19:17 +0530 Subject: [PATCH 08/34] feat(msi): ship the bootstrap and seed, adopt-seed on every install Product.wxs installs stepsecurity-dev-machine-guard-bootstrap.exe and seed\\{agent.exe, task.exe, dmg-linux} in place of the top-level agent and launcher. A deferred SYSTEM adopt-seed CA (Return=ignore) runs on install, upgrade and repair; the install CA now runs on repair too (M-7); the uninstall CA is skipped during a major upgrade and can't block removal (M-6, M-19). AUTOUPDATE (default 1) is passed to configure, which gains --auto-update= and writes auto_update into the machine config. msi-smoke asserts the layout, the versions/state ACLs, all three tasks, a bootstrap run, a real SYSTEM update tick, repair, an N to N+1 upgrade without downgrade, and a clean uninstall. --- .github/workflows/msi-smoke.yml | 352 ++++++++++++--------- cmd/stepsecurity-dev-machine-guard/main.go | 1 + internal/cli/cli.go | 10 + internal/cli/cli_test.go | 18 ++ internal/config/config.go | 11 + internal/config/config_nonint_test.go | 31 ++ packaging/windows/Product.wxs | 181 +++++++---- packaging/windows/README.md | 89 +++++- packaging/windows/intune/install.cmd | 3 +- 9 files changed, 488 insertions(+), 208 deletions(-) diff --git a/.github/workflows/msi-smoke.yml b/.github/workflows/msi-smoke.yml index 5b986e27..71655db5 100644 --- a/.github/workflows/msi-smoke.yml +++ b/.github/workflows/msi-smoke.yml @@ -1,7 +1,8 @@ name: MSI Smoke Test -# Runs the full Windows MSI flow (build → install → verify → uninstall) -# on a fresh windows-latest runner. Catches breakage in the WiX manifest, +# Runs the full Windows MSI flow (build → install → verify → bootstrap run + +# SYSTEM update tick → repair → N→N+1 upgrade → uninstall) on a fresh +# windows-latest runner. Catches breakage in the WiX manifest, # the deferred-CA wiring, the configure --non-interactive path, or the # icacls hardening — all of which only surface on a real install. # @@ -44,9 +45,9 @@ concurrency: jobs: msi-smoke: - name: Build, install, verify, uninstall + name: Build, install, verify, repair, upgrade, uninstall runs-on: windows-latest - timeout-minutes: 15 + timeout-minutes: 25 steps: - name: Harden the runner (Audit all outbound calls) @@ -115,6 +116,23 @@ jobs: -out "dist\dmg-smoke.msi" Get-Item "dist\dmg-smoke.msi" | Select-Object Name, Length + # N+1: the same payload under the next patch version, for the + # major-upgrade path (seed\, no downgrade of a newer current). + $p = $version.Split('.') + $next = "$($p[0]).$($p[1]).$([int]$p[2] + 1)" + wix build packaging/windows/Product.wxs ` + -arch x64 ` + -ext WixToolset.Util.wixext ` + -d Arch=x64 ` + -d "Version=$next" ` + -d "BinaryPath=$PWD\dmg-smoke.exe" ` + -d "LauncherPath=$PWD\dmg-smoke-task.exe" ` + -d "LinuxBinaryPath=$PWD\dmg-smoke-linux" ` + -out "dist\dmg-smoke-next.msi" + "MSI_VERSION=$version" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "MSI_NEXT_VERSION=$next" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "INSTALLED_MSI=dist\dmg-smoke.msi" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + - name: Install MSI with synthetic tenant config shell: pwsh run: | @@ -138,175 +156,219 @@ jobs: exit 1 } - - name: Verify install artifacts + - name: Write assertion helpers shell: pwsh run: | - $bin = "C:\Program Files\StepSecurity\stepsecurity-dev-machine-guard.exe" - $launcher = "C:\Program Files\StepSecurity\stepsecurity-dev-machine-guard-task.exe" - $cfg = "C:\ProgramData\StepSecurity\config.json" - $task = "StepSecurity Dev Machine Guard" - - $checks = @() - - # 1a. Agent binary on disk - if (Test-Path $bin) { - Write-Host "[OK] Agent present at $bin" - $checks += $true - } else { - Write-Host "::error::Agent missing: $bin" - $checks += $false + # Shared by the verify steps below (dot-sourced). + @' + $script:fail = $false + $root = "C:\Program Files\StepSecurity" + $boot = "$root\stepsecurity-dev-machine-guard-bootstrap.exe" + $data = "C:\ProgramData\StepSecurity" + $tasks = @("StepSecurity Dev Machine Guard", "StepSecurity Dev Machine Guard (Logon)", "StepSecurity Dev Machine Guard (Update)") + function Check($ok, $msg) { + if ($ok) { Write-Host "[OK] $msg" } else { Write-Host "::error::$msg"; $script:fail = $true } } - - # 1b. Launcher binary on disk - if (Test-Path $launcher) { - Write-Host "[OK] Launcher present at $launcher" - $checks += $true - } else { - Write-Host "::error::Launcher missing: $launcher" - $checks += $false - } - - # 1c. The Linux agent, beside the Windows one. "Beside" is the whole - # requirement: the WSL scan phase resolves this binary relative to - # its own executable, so a component that installs anywhere else - # ships fine and is silently inert. The ELF header is checked because - # Windows never executes this file, so nothing else here would notice - # if the wrong architecture were packaged. - $linuxAgent = "C:\Program Files\StepSecurity\dmg-linux" - if (Test-Path $linuxAgent) { - $hdr = [byte[]]::new(20) - $fs = [System.IO.File]::OpenRead($linuxAgent) - try { $null = $fs.Read($hdr, 0, 20) } finally { $fs.Dispose() } - # ELF magic, EI_CLASS=2 (64-bit), e_machine 0x3E (x86-64) at offset 18. - $machine = $hdr[18] + ($hdr[19] -shl 8) - if ($hdr[0] -eq 0x7F -and $hdr[1] -eq 0x45 -and $hdr[2] -eq 0x4C -and $hdr[3] -eq 0x46 ` - -and $hdr[4] -eq 2 -and $machine -eq 0x3E) { - Write-Host "[OK] dmg-linux present beside the agent, 64-bit x86-64 ELF" - $checks += $true - } else { - Write-Host "::error::dmg-linux is not a 64-bit x86-64 ELF (class=$($hdr[4]) machine=0x$('{0:X2}' -f $machine))" - $checks += $false + function Done { if ($script:fail) { exit 1 }; exit 0 } + function State { Get-Content "$root\state\state.json" -Raw | ConvertFrom-Json } + function TaskXml($name) { [xml](schtasks /query /tn $name /xml | Out-String) } + function UsableDir($d) { (Test-Path "$d\agent.exe") -and (Test-Path "$d\task.exe") -and (Test-Path "$d\dmg-linux") } + function AssertTasks { + foreach ($t in $tasks) { + schtasks /query /tn $t 2>&1 | Out-Null + Check ($LASTEXITCODE -eq 0) "task registered: $t" } - } else { - Write-Host "::error::dmg-linux missing: $linuxAgent" - $checks += $false - } - - # 2. Machine-wide config exists and contains our values - if (Test-Path $cfg) { - $content = Get-Content $cfg -Raw - if ($content -match '"customer_id"\s*:\s*"ci-smoke"' -and - $content -match '"api_key"\s*:\s*"ci-test-fake-api-key"') { - Write-Host "[OK] Config written with expected values" - $checks += $true - } else { - Write-Host "::error::Config content unexpected:" - Write-Host $content - $checks += $false + foreach ($t in $tasks[0..1]) { + $x = TaskXml $t + $exec = $x.Task.Actions.Exec + Check ($exec.Command.Trim('"') -eq $boot) "$t action runs the bootstrap ($($exec.Command))" + Check ($exec.Arguments -match '^run send-telemetry --install-dir=') "$t arguments: $($exec.Arguments)" + Check ($x.Task.Principals.Principal.GroupId -eq 'S-1-5-4' -or $x.Task.Principals.Principal.UserId -eq 'S-1-5-4') "$t principal is INTERACTIVE" } - } else { - Write-Host "::error::Config missing: $cfg" - $checks += $false + $u = TaskXml $tasks[2] + $exec = $u.Task.Actions.Exec + Check ($exec.Command.Trim('"') -eq $boot -and $exec.Arguments -eq 'update') "update task action: $($exec.Command) $($exec.Arguments)" + $uid = $u.Task.Principals.Principal.UserId + Check ($uid -eq 'S-1-5-18' -or $uid -match 'SYSTEM') "update task principal SYSTEM ($uid)" + $st = $u.Task.Settings + Check ($st.MultipleInstancesPolicy -eq 'IgnoreNew') "update task IgnoreNew" + Check ($st.ExecutionTimeLimit -eq 'PT30M') "update task ExecutionTimeLimit PT30M" + Check ($st.StartWhenAvailable -eq 'true') "update task StartWhenAvailable" + Check ($st.DisallowStartIfOnBatteries -eq 'false' -and $st.StopIfGoingOnBatteries -eq 'false') "update task runs on battery" + Check ($u.Task.Triggers.TimeTrigger.Repetition.Interval -eq 'PT1H' -and $u.Task.Triggers.TimeTrigger.RandomDelay -eq 'PT10M') "update task hourly + 10 min random delay" + Check ($u.Task.Triggers.BootTrigger.Delay -eq 'PT15M') "update task at boot + 15 min" + } + function AssertProtected($dir) { + $acl = Get-Acl $dir + Check ($acl.Owner -match 'SYSTEM') "$dir owner SYSTEM ($($acl.Owner))" + Check $acl.AreAccessRulesProtected "$dir DACL protected (inheritance off)" + $rules = $acl.Access | ForEach-Object { "$($_.IdentityReference)=$($_.FileSystemRights)" } + Check ($rules -contains 'NT AUTHORITY\SYSTEM=FullControl') "$dir SYSTEM full" + Check ($rules -contains 'BUILTIN\Administrators=FullControl') "$dir Administrators full" + Check ($rules -contains 'BUILTIN\Users=ReadAndExecute, Synchronize') "$dir Users read/execute ($($rules -join '; '))" + Check (-not ($acl.Access | Where-Object { $_.IsInherited })) "$dir has no inherited ACEs" } + '@ | Set-Content -Path "$env:RUNNER_TEMP\dmg-assert.ps1" + + - name: Verify install artifacts + shell: pwsh + run: | + . "$env:RUNNER_TEMP\dmg-assert.ps1" + $ver = $env:MSI_VERSION + $seed = "$root\seed\$ver" + $cfg = "$data\config.json" + + # 1. Layout: bootstrap + seed; the legacy top-level agent/launcher are gone. + Check (Test-Path $boot) "bootstrap present" + Check (UsableDir $seed) "seed\$ver has agent.exe, task.exe, dmg-linux" + Check (-not (Test-Path "$root\stepsecurity-dev-machine-guard.exe")) "no top-level agent" + Check (-not (Test-Path "$root\stepsecurity-dev-machine-guard-task.exe")) "no top-level launcher" + + # 1b. dmg-linux must sit beside agent.exe (the WSL scan phase looks next + # to its own executable) and be a 64-bit x86-64 ELF. + $hdr = [byte[]]::new(20) + $fs = [System.IO.File]::OpenRead("$seed\dmg-linux") + try { $null = $fs.Read($hdr, 0, 20) } finally { $fs.Dispose() } + $machine = $hdr[18] + ($hdr[19] -shl 8) + Check ($hdr[0] -eq 0x7F -and $hdr[1] -eq 0x45 -and $hdr[2] -eq 0x4C -and $hdr[3] -eq 0x46 -and $hdr[4] -eq 2 -and $machine -eq 0x3E) "dmg-linux is a 64-bit x86-64 ELF" + + # 2. adopt-seed: versions\ copied, state.json current = seed, + # versions\ and state\ protected. + Check (UsableDir "$root\versions\$ver") "versions\$ver adopted from the seed" + $state = State + Check ($state.schema -eq 1 -and $state.current -eq $ver) "state.json current = $ver (got $($state.current))" + AssertProtected "$root\versions" + AssertProtected "$root\state" + + # 3. AgentVersion (Intune detection) = MSI version; no RunningVersion yet. + $av = (Get-ItemProperty -Path 'HKLM:\Software\StepSecurity' -Name AgentVersion -ErrorAction SilentlyContinue).AgentVersion + Check ($av -eq $ver) "AgentVersion = $ver ($av)" - # 3. ACL hardening on config.json — Users should be Read only, - # Administrators + SYSTEM should be Full Control, inheritance disabled. + # 4. Machine-wide config with our values; AUTOUPDATE defaults on. + $content = Get-Content $cfg -Raw + Check ($content -match '"customer_id"\s*:\s*"ci-smoke"' -and $content -match '"api_key"\s*:\s*"ci-test-fake-api-key"') "config written with expected values" + Check ($content -match '"auto_update"\s*:\s*true') "config auto_update true (AUTOUPDATE default)" + + # 5. config.json ACL hardening: Users R, Admins + SYSTEM F. $acl = & icacls $cfg | Out-String - $aclOk = $true - if ($acl -notmatch 'BUILTIN\\Users:\(R\)') { $aclOk = $false; Write-Host "::error::Users(R) ACE missing" } - if ($acl -notmatch 'BUILTIN\\Administrators:\(F\)') { $aclOk = $false; Write-Host "::error::Administrators(F) ACE missing" } - if ($acl -notmatch 'NT AUTHORITY\\SYSTEM:\(F\)') { $aclOk = $false; Write-Host "::error::SYSTEM(F) ACE missing" } - if ($aclOk) { - Write-Host "[OK] config.json ACL hardened (Users:R, Admins:F, SYSTEM:F)" - $checks += $true - } else { - Write-Host "::error::ACL not as expected:" - Write-Host $acl - $checks += $false - } + Check ($acl -match 'BUILTIN\\Users:\(R\)' -and $acl -match 'BUILTIN\\Administrators:\(F\)' -and $acl -match 'NT AUTHORITY\\SYSTEM:\(F\)') "config.json ACL hardened" - # 4. Scheduled task registered, runs as INTERACTIVE, and its - # action points at the launcher (not the agent directly). - $taskInfo = schtasks /query /tn $task /v /fo LIST 2>&1 - if ($LASTEXITCODE -eq 0) { - $runAs = ($taskInfo | Select-String -Pattern '^\s*Run As User:').ToString() - if ($runAs -match 'INTERACTIVE') { - Write-Host "[OK] Scheduled task registered, Run As User: INTERACTIVE" - $checks += $true - } else { - Write-Host "::error::Run As User wrong: $runAs" - $checks += $false - } + # 6. All three tasks, actions through the bootstrap. + AssertTasks + Done - $action = ($taskInfo | Select-String -Pattern '^\s*Task To Run:').ToString() - if ($action -match 'stepsecurity-dev-machine-guard-task\.exe') { - Write-Host "[OK] Task action points at GUI launcher" - $checks += $true - } else { - Write-Host "::error::Task action does not point at launcher: $action" - $checks += $false - } - } else { - Write-Host "::error::schtasks /query failed: $taskInfo" - $checks += $false - } + - name: Bootstrap run and SYSTEM update tick + shell: pwsh + run: | + . "$env:RUNNER_TEMP\dmg-assert.ps1" + $ver = $env:MSI_VERSION - if ($checks -contains $false) { - Write-Host "::error::One or more install verifications failed" - exit 1 + # run: the bootstrap resolves current and launches versions\\task.exe + # (GUI subsystem, hence Start-Process -Wait) and records the run. + $p = Start-Process -FilePath $boot -ArgumentList @("run", "version", "--install-dir=$data") -Wait -PassThru + Check ($p.ExitCode -eq 0) "bootstrap run exit 0 ($($p.ExitCode))" + $runs = Get-Content "$data\runs\$ver.json" -Raw | ConvertFrom-Json + $last = $runs.records[-1] + Check ($last.version -eq $ver -and $last.exit_code -eq 0) "run record finalized: $($runs | ConvertTo-Json -Compress)" + + # update: fire the real SYSTEM task and wait for it to finish. Until the + # agent ships self-update the agent exits non-zero; either way the + # bootstrap records the updater result in state.json as SYSTEM. + schtasks /run /tn $tasks[2] | Out-Null + $deadline = (Get-Date).AddMinutes(4) + do { + Start-Sleep -Seconds 3 + $info = Get-ScheduledTask -TaskName $tasks[2] | Get-ScheduledTaskInfo + $state = (Get-ScheduledTask -TaskName $tasks[2]).State + } while (($state -eq 'Running' -or $info.LastRunTime -lt (Get-Date).AddYears(-1)) -and (Get-Date) -lt $deadline) + Write-Host "update task: state=$state LastTaskResult=$($info.LastTaskResult)" + Check ($state -ne 'Running') "update task finished" + $s = State + Check ($s.updater.version -eq $ver) "state.updater recorded by the bootstrap: $($s.updater | ConvertTo-Json -Compress)" + Get-Content "$root\state\update.log" -ErrorAction SilentlyContinue | Select-Object -Last 20 + Done + + - name: Repair keeps current and restores tasks (M-7) + shell: pwsh + run: | + . "$env:RUNNER_TEMP\dmg-assert.ps1" + $ver = $env:MSI_VERSION + + # Simulate a self-updated device: a newer versions\ dir as current. + Copy-Item -Recurse "$root\versions\$ver" "$root\versions\99.0.0" + $s = State + $s.current = "99.0.0"; $s.previous = $ver + $s | ConvertTo-Json -Depth 10 | Set-Content -Path "$root\state\state.json" -Encoding utf8NoBOM + # And a deleted scan task, which the repair must restore. + schtasks /delete /tn $tasks[0] /f | Out-Null + + $p = Start-Process -FilePath "msiexec.exe" -ArgumentList @("/fa", $env:INSTALLED_MSI, "/qn", "/l*v", "dist\dmg-repair.log") -Wait -PassThru + Check ($p.ExitCode -eq 0) "msiexec /fa exit 0 ($($p.ExitCode))" + $s = State + Check ($s.current -eq "99.0.0" -and $s.previous -eq $ver) "repair kept current 99.0.0 (got $($s.current)/$($s.previous))" + AssertTasks + Done + + - name: Major upgrade N to N+1 keeps tasks and never downgrades + shell: pwsh + run: | + . "$env:RUNNER_TEMP\dmg-assert.ps1" + $next = $env:MSI_NEXT_VERSION + $p = Start-Process -FilePath "msiexec.exe" -ArgumentList @( + "/i", "dist\dmg-smoke-next.msi", "/qn", "/l*v", "dist\dmg-upgrade.log" + ) -Wait -PassThru + Check ($p.ExitCode -eq 0) "upgrade msiexec exit 0 ($($p.ExitCode))" + if ($p.ExitCode -eq 0) { + "INSTALLED_MSI=dist\dmg-smoke-next.msi" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 } - Write-Host "All install-time verifications passed." + Check (UsableDir "$root\seed\$next") "seed\$next installed" + Check (-not (Test-Path "$root\seed\$env:MSI_VERSION")) "old seed removed" + Check (UsableDir "$root\versions\$next") "versions\$next adopted" + $s = State + Check ($s.current -eq "99.0.0") "seed $next < current 99.0.0: no downgrade (got $($s.current))" + $av = (Get-ItemProperty -Path 'HKLM:\Software\StepSecurity' -Name AgentVersion).AgentVersion + Check ($av -eq $next) "AgentVersion = $next ($av)" + # The old product's uninstall CA is skipped on upgrade (M-6). + $ranUninstall = Select-String -Path "dist\dmg-upgrade.log" -Pattern 'Doing action: RunUninstallScheduledTask' -SimpleMatch -Quiet + Check (-not $ranUninstall) "old uninstall CA not run during the upgrade" + AssertTasks + Done - name: Uninstall MSI if: always() shell: pwsh run: | - if (Test-Path "dist\dmg-smoke.msi") { + $msi = $env:INSTALLED_MSI + if ($msi -and (Test-Path $msi)) { $p = Start-Process -FilePath "msiexec.exe" -ArgumentList @( - "/x", "dist\dmg-smoke.msi", "/qn", + "/x", $msi, "/qn", "/l*v", "dist\dmg-uninstall.log" ) -Wait -PassThru - Write-Host "msiexec /x exit: $($p.ExitCode)" + Write-Host "msiexec /x $msi exit: $($p.ExitCode)" + if ($p.ExitCode -ne 0) { exit 1 } } - name: Verify uninstall cleanup shell: pwsh run: | - $task = "StepSecurity Dev Machine Guard" - - # All three shipped binaries should be gone. dmg-linux is 11-12 MB, so - # an uninstall that leaves it behind is the largest single piece of - # litter this package can produce. - foreach ($leftover in @( - "C:\Program Files\StepSecurity\stepsecurity-dev-machine-guard.exe", - "C:\Program Files\StepSecurity\stepsecurity-dev-machine-guard-task.exe", - "C:\Program Files\StepSecurity\dmg-linux" - )) { - if (Test-Path $leftover) { - Write-Host "::error::File still present after uninstall: $leftover" - exit 1 - } + . "$env:RUNNER_TEMP\dmg-assert.ps1" + foreach ($leftover in @($boot, "$root\seed", "$root\versions", "$root\state")) { + Check (-not (Test-Path $leftover)) "removed: $leftover" } - Write-Host "[OK] Agent + launcher + linux agent removed" - - # Scheduled task should be gone. Capture the exit code into a - # local — PowerShell uses $LASTEXITCODE from the last native call - # as the script's exit code, so we MUST clear it (via 'exit 0' - # below) once we've made our decision, otherwise the step is - # marked failed despite all checks passing. - schtasks /query /tn $task 2>&1 | Out-Null - $schtasksExit = $LASTEXITCODE - if ($schtasksExit -eq 0) { - Write-Host "::error::Scheduled task still registered after uninstall" - exit 1 + foreach ($t in $tasks) { + schtasks /query /tn $t 2>&1 | Out-Null + Check ($LASTEXITCODE -ne 0) "task removed: $t" } - Write-Host "[OK] Scheduled task removed" + $reg = Get-ItemProperty -Path 'HKLM:\Software\StepSecurity' -ErrorAction SilentlyContinue + Check (-not $reg.RunningVersion) "RunningVersion removed" + Check (-not $reg.AgentVersion) "AgentVersion removed" + $procs = Get-Process | Where-Object { $_.Path -and $_.Path.StartsWith("$root\", [StringComparison]::OrdinalIgnoreCase) } + Check (-not $procs) "no DMG processes left running" # Note: C:\ProgramData\StepSecurity\config.json intentionally persists - # across uninstall — it's tenant config, not MSI-managed payload. This - # mirrors the documented behavior in docs/deploying-via-sccm.md. - - exit 0 + # across uninstall — it's tenant config, not MSI-managed payload (M-8 + # is out of scope). This mirrors docs/deploying-via-sccm.md. + Done - name: Upload install logs on failure if: failure() @@ -315,5 +377,7 @@ jobs: name: msi-smoke-logs path: | dist/dmg-install.log + dist/dmg-repair.log + dist/dmg-upgrade.log dist/dmg-uninstall.log if-no-files-found: ignore diff --git a/cmd/stepsecurity-dev-machine-guard/main.go b/cmd/stepsecurity-dev-machine-guard/main.go index 0bab30b0..c37225d6 100644 --- a/cmd/stepsecurity-dev-machine-guard/main.go +++ b/cmd/stepsecurity-dev-machine-guard/main.go @@ -238,6 +238,7 @@ func main() { APIEndpoint: cfg.ConfigAPIEndpoint, APIKey: cfg.ConfigAPIKey, ScanFrequency: cfg.ConfigScanFrequency, + AutoUpdate: cfg.ConfigAutoUpdate, } if opts.APIKey == "" { // Env-var fallback keeps the secret off the msiexec command diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 224bc960..5d351493 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -107,6 +107,7 @@ type Config struct { ConfigAPIEndpoint string // --api-endpoint ConfigAPIKey string // --api-key (also accepts env var DMG_API_KEY) ConfigScanFrequency string // --scan-frequency (hours) + ConfigAutoUpdate string // --auto-update=: Windows MSI self-update opt-out ("" = unchanged) // IgnoreTelemetryError opts the `install` subcommand into treating an // initial-telemetry POST failure as a warning rather than a fatal exit. @@ -342,6 +343,14 @@ func Parse(args []string) (*Config, error) { cfg.ConfigScanFrequency = args[i] case strings.HasPrefix(arg, "--scan-frequency="): cfg.ConfigScanFrequency = strings.TrimPrefix(arg, "--scan-frequency=") + case arg == "--auto-update": + i++ + if i >= len(args) { + return nil, fmt.Errorf("--auto-update requires a value (true/false)") + } + cfg.ConfigAutoUpdate = args[i] + case strings.HasPrefix(arg, "--auto-update="): + cfg.ConfigAutoUpdate = strings.TrimPrefix(arg, "--auto-update=") case arg == "--verbose": cfg.Verbose = true case arg == "--override-gate": @@ -639,6 +648,7 @@ Non-interactive configure (for MSI / SCCM / Intune deployments): --api-endpoint URL StepSecurity backend URL --api-key KEY Authentication key (or set DMG_API_KEY env var) --scan-frequency HOURS Scheduled scan frequency + --auto-update=BOOL Windows MSI installs: allow self-update (default true) --ignore-telemetry-error On 'install', treat a failed initial telemetry POST as a warning instead of a fatal exit (use in MSI custom actions to avoid rollback on transient network) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 3bee2c4f..613e618d 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -447,3 +447,21 @@ func TestParseHooks_AcceptsInstallDir(t *testing.T) { // See internal/aiagents/cli/hook_test.go for handler-level tests and // cmd/stepsecurity-dev-machine-guard/main_test.go for the integration // test that asserts the binary always exits 0 on `_hook` invocations. + +func TestParse_AutoUpdate(t *testing.T) { + for _, args := range [][]string{ + {"configure", "--non-interactive", "--auto-update=false"}, + {"configure", "--non-interactive", "--auto-update", "false"}, + } { + cfg, err := Parse(args) + if err != nil { + t.Fatalf("%v: %v", args, err) + } + if cfg.ConfigAutoUpdate != "false" { + t.Errorf("%v: ConfigAutoUpdate = %q", args, cfg.ConfigAutoUpdate) + } + } + if _, err := Parse([]string{"configure", "--auto-update"}); err == nil { + t.Error("missing value accepted") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 893fda9e..fc71d143 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "runtime" + "strconv" "strings" "sync" "time" @@ -94,6 +95,7 @@ type ConfigFile struct { MaxExecutionDuration string `json:"max_execution_duration,omitempty"` UseLegacyNodeScan *bool `json:"use_legacy_node_scan,omitempty"` UseLegacyPythonScan *bool `json:"use_legacy_python_scan,omitempty"` + AutoUpdate *bool `json:"auto_update,omitempty"` } // userConfigDir returns ~/.stepsecurity — the per-user config location. @@ -793,6 +795,7 @@ type NonInteractiveOptions struct { APIKey string // --api-key (or DMG_API_KEY env) ScanFrequency string // --scan-frequency (hours, as string) SearchDirs []string // --search-dirs (re-used from scan flag; empty = unchanged) + AutoUpdate string // --auto-update (strconv.ParseBool; empty = unchanged) } // HasAny reports whether the caller supplied any inline value. Used by @@ -805,6 +808,7 @@ func (o NonInteractiveOptions) HasAny() bool { o.APIEndpoint != "" || o.APIKey != "" || o.ScanFrequency != "" || + o.AutoUpdate != "" || len(o.SearchDirs) > 0 } @@ -847,6 +851,13 @@ func RunConfigureNonInteractive(opts NonInteractiveOptions) error { if len(opts.SearchDirs) > 0 { existing.SearchDirs = opts.SearchDirs } + if opts.AutoUpdate != "" { + v, err := strconv.ParseBool(opts.AutoUpdate) + if err != nil { + return fmt.Errorf("invalid --auto-update %q (want true/false or 1/0)", opts.AutoUpdate) + } + existing.AutoUpdate = &v + } // Validation: an MSI deploy with no creds is almost certainly a bug. // Fail loud so the MSI transaction rolls back instead of silently diff --git a/internal/config/config_nonint_test.go b/internal/config/config_nonint_test.go index 51f164e8..a17d9bde 100644 --- a/internal/config/config_nonint_test.go +++ b/internal/config/config_nonint_test.go @@ -154,3 +154,34 @@ func TestNonInteractiveOptions_HasAny(t *testing.T) { t.Error("FromFile alone should be enough") } } + +func TestRunConfigureNonInteractive_AutoUpdate(t *testing.T) { + withHome(t) + base := NonInteractiveOptions{CustomerID: "c", APIEndpoint: "https://api.example.com", APIKey: "k"} + base.AutoUpdate = "0" + if err := RunConfigureNonInteractive(base); err != nil { + t.Fatal(err) + } + if v := loadExisting().AutoUpdate; v == nil || *v { + t.Fatalf("AutoUpdate after 0 = %v", v) + } + // Empty keeps the stored value. + if err := RunConfigureNonInteractive(NonInteractiveOptions{AutoUpdate: ""}); err != nil { + t.Fatal(err) + } + if v := loadExisting().AutoUpdate; v == nil || *v { + t.Fatalf("AutoUpdate after empty = %v", v) + } + if err := RunConfigureNonInteractive(NonInteractiveOptions{AutoUpdate: "true"}); err != nil { + t.Fatal(err) + } + if v := loadExisting().AutoUpdate; v == nil || !*v { + t.Fatalf("AutoUpdate after true = %v", v) + } + if err := RunConfigureNonInteractive(NonInteractiveOptions{AutoUpdate: "maybe"}); err == nil { + t.Fatal("invalid value accepted") + } + if !(NonInteractiveOptions{AutoUpdate: "false"}).HasAny() { + t.Fatal("HasAny ignores AutoUpdate") + } +} diff --git a/packaging/windows/Product.wxs b/packaging/windows/Product.wxs index a7fb8750..4b8775f8 100644 --- a/packaging/windows/Product.wxs +++ b/packaging/windows/Product.wxs @@ -7,9 +7,11 @@ -d Version= e.g. 1.8.2 -d BinaryPath= path to stepsecurity-dev-machine-guard.exe + (installed as seed\\agent.exe) -d LauncherPath= path to stepsecurity-dev-machine-guard-task.exe - (GUI-subsystem launcher Task Scheduler invokes - so the agent doesn't flash a console window) + (GUI-subsystem launcher; installed twice: as + the bootstrap in the install root and as + seed\\task.exe) -d LinuxBinaryPath= path to the LINUX build of the agent, installed as dmg-linux so a Windows host can scan inside its WSL distributions. Must match the MSI's @@ -17,6 +19,19 @@ WSL2 kernel. Windows never executes it. -d Arch= selects InstallScope + UpgradeCode + Self-update layout (see internal/launcher/bootstrap.go): + + C:\Program Files\StepSecurity\ + stepsecurity-dev-machine-guard-bootstrap.exe MSI (stable entrypoint) + seed\\{agent.exe, task.exe, dmg-linux} MSI (read-only fallback) + versions\, state\ updater-owned, created by + the adopt-seed CA + + The scheduled tasks run the bootstrap, which launches the version that + state.json names. The MSI owns the bootstrap, the seed, AgentVersion and + the three tasks; it never touches versions\ or state\ except on a real + uninstall. + Designed for SCCM/Intune deployment under environments where PowerShell egress is blocked by EDR. Custom actions invoke the .exe directly via msiexec — powershell.exe is never spawned anywhere in install/upgrade/ @@ -68,53 +83,61 @@