From e040851a4c9736a454f50a883dedfd6532078f40 Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Tue, 26 May 2026 10:12:37 -0700 Subject: [PATCH] Style Signed-off-by: Bryan Frimin --- cmd/probo-agent/main.go | 16 ++++++ pkg/deviceagent/agent.go | 61 ++++++++++++++++++++++ pkg/deviceagent/checks/checks_darwin.go | 62 +++++++++++++++++++++++ pkg/deviceagent/checks/registry.go | 2 + pkg/deviceagent/checks/runcmd.go | 4 ++ pkg/deviceagent/checks/shared.go | 3 ++ pkg/deviceagent/client.go | 5 ++ pkg/deviceagent/config.go | 6 +++ pkg/deviceagent/hostinfo.go | 3 ++ pkg/deviceagent/hostinfo_darwin.go | 5 ++ pkg/deviceagent/posture_queue.go | 2 + pkg/deviceagent/posture_queue_test.go | 12 +++++ pkg/deviceagent/service/service_darwin.go | 1 + pkg/deviceagent/update/archive.go | 5 +- pkg/deviceagent/update/asset.go | 1 + pkg/deviceagent/update/copy.go | 4 ++ pkg/deviceagent/update/install_unix.go | 1 + pkg/deviceagent/update/update.go | 15 ++++++ pkg/deviceagent/update/update_test.go | 8 +++ pkg/deviceagent/update/verify.go | 4 ++ 20 files changed, 219 insertions(+), 1 deletion(-) diff --git a/cmd/probo-agent/main.go b/cmd/probo-agent/main.go index 1d295b8ce..5cc83647e 100644 --- a/cmd/probo-agent/main.go +++ b/cmd/probo-agent/main.go @@ -56,6 +56,7 @@ func main() { if errors.Is(err, deviceagent.ErrRestartRequired) { os.Exit(restartExitCode) } + fmt.Fprintf(os.Stderr, "probo-agent: %s\n", err) os.Exit(1) } @@ -79,6 +80,7 @@ func newRootCmd() *cobra.Command { root.AddCommand(newStatusCmd()) root.AddCommand(newCollectCmd()) root.AddCommand(newUpdateCmd()) + return root } @@ -145,10 +147,12 @@ func newInstallCmd() *cobra.Command { } dir := resolveDir(cmd) + ctx, cancel := context.WithTimeout(cmd.Context(), 60*time.Second) defer cancel() agent := deviceagent.New(dir, version, newAgentLogger()) + resp, err := agent.EnrollNewDevice(ctx, strings.TrimRight(serverURL, "/"), enrollmentToken) if err != nil { return fmt.Errorf("enrollment failed: %w", err) @@ -161,6 +165,7 @@ func newInstallCmd() *cobra.Command { if err := persistAutoUpdate(dir, false); err != nil { return fmt.Errorf("cannot persist auto-update preference: %w", err) } + fmt.Println("Auto-update disabled.") } @@ -184,6 +189,7 @@ func newInstallCmd() *cobra.Command { } fmt.Println("Service installed and started.") + return nil }, } @@ -205,6 +211,7 @@ func persistAutoUpdate(dir string, enabled bool) error { } cfg.UpdatesDisabled = !enabled + return deviceagent.SaveConfig(dir, cfg) } @@ -228,6 +235,7 @@ func newUninstallCmd() *cobra.Command { } _ = os.Remove(deviceagent.ConfigPath(dir)) + fmt.Println("Uninstalled.") return nil @@ -248,6 +256,7 @@ func newRunCmd() *cobra.Command { logger := newAgentLogger() agent := deviceagent.New(dir, version, logger) agent.Updater = newUpdater(logger, dir) + err := agent.Run(ctx) if errors.Is(err, context.Canceled) { return nil @@ -264,6 +273,7 @@ func newStatusCmd() *cobra.Command { Short: "Print the agent's local state", RunE: func(cmd *cobra.Command, args []string) error { dir := resolveDir(cmd) + cfg, err := deviceagent.LoadConfig(dir) if err != nil { return err @@ -294,6 +304,7 @@ func newCollectCmd() *cobra.Command { asJSON bool printDir bool ) + cmd := &cobra.Command{ Use: "collect", Short: "Run the posture check set once and print results (no server push)", @@ -329,12 +340,14 @@ func newCollectCmd() *cobra.Command { func newUpdateCmd() *cobra.Command { var checkOnly bool + cmd := &cobra.Command{ Use: "update", Short: "Check GitHub for a newer agent release and install it in place", RunE: func(cmd *cobra.Command, args []string) error { dir := resolveDir(cmd) logger := newAgentLogger() + updater := newUpdater(logger, dir) if updater == nil { return errors.New("cannot resolve current executable path") @@ -349,10 +362,12 @@ func newUpdateCmd() *cobra.Command { fmt.Printf("probo-agent is up to date (version %s).\n", version) return nil } + return fmt.Errorf("cannot check for updates: %w", err) } fmt.Printf("Update available: %s -> %s\n", version, rel.Version) + if checkOnly { return nil } @@ -362,6 +377,7 @@ func newUpdateCmd() *cobra.Command { } fmt.Printf("Installed probo-agent %s. Restart the service to use it.\n", rel.Version) + return nil }, } diff --git a/pkg/deviceagent/agent.go b/pkg/deviceagent/agent.go index 3e00a9409..3be027c2a 100644 --- a/pkg/deviceagent/agent.go +++ b/pkg/deviceagent/agent.go @@ -72,6 +72,7 @@ func New(dir, version string, logger *log.Logger) *Agent { if logger == nil { logger = log.NewLogger(log.WithName("device-agent")) } + return &Agent{ Dir: dir, Version: version, @@ -95,6 +96,7 @@ func (a *Agent) EnrollNewDevice( if serverURL == "" { return nil, errors.New("server URL is required") } + if enrollmentToken == "" { return nil, errors.New("enrollment token is required") } @@ -127,15 +129,18 @@ func (a *Agent) EnrollNewDevice( if err := SaveConfig(a.Dir, cfg); err != nil { return nil, fmt.Errorf("cannot save config: %w", err) } + if err := SaveAPIKey(a.Dir, resp.APIKey); err != nil { return nil, fmt.Errorf("cannot save api key: %w", err) } + if err := clearPendingPostureBatches(a.Dir); err != nil { a.Logger.Warn("cannot clear pending posture queue after enrollment", log.Error(err)) } a.cfg = cfg a.client = NewClient(serverURL, resp.APIKey, a.UserAgent) + return resp, nil } @@ -145,15 +150,19 @@ func (a *Agent) LoadLocalState() error { if err != nil { return err } + key, err := LoadAPIKey(a.Dir) if err != nil { return err } + if cfg.ServerURL == "" { return errors.New("config has no server URL") } + a.cfg = cfg a.client = NewClient(cfg.ServerURL, key, a.UserAgent) + return nil } @@ -185,6 +194,7 @@ func (a *Agent) Run(ctx context.Context) error { heartbeatTicker := time.NewTicker(a.cfg.HeartbeatInterval) defer heartbeatTicker.Stop() + postureTicker := time.NewTicker(a.cfg.PostureInterval) defer postureTicker.Stop() @@ -203,6 +213,7 @@ func (a *Agent) Run(ctx context.Context) error { if heartbeatIntervalChanged { heartbeatTicker.Reset(a.cfg.HeartbeatInterval) } + if postureIntervalChanged { postureTicker.Reset(a.cfg.PostureInterval) } @@ -223,9 +234,11 @@ func (a *Agent) autoUpdateEnabled() bool { if a.cfg == nil { return false } + if a.cfg.UpdatesDisabled { return false } + return a.Updater != nil } @@ -238,6 +251,7 @@ func (a *Agent) newUpdateTicker() (*time.Ticker, <-chan time.Time) { } t := time.NewTicker(a.cfg.UpdateInterval) + return t, t.C } @@ -258,7 +272,9 @@ func (a *Agent) tryAutoUpdate(parent context.Context) bool { a.Logger.DebugCtx(ctx, "no agent update available") return false } + a.Logger.WarnCtx(ctx, "agent update check failed", log.Error(err)) + return false } @@ -281,23 +297,30 @@ func (a *Agent) tryAutoUpdate(parent context.Context) bool { func (a *Agent) CollectOnce(ctx context.Context) []checks.Result { now := time.Now() results := make([]checks.Result, 0) + for _, c := range checks.All() { select { case <-ctx.Done(): return results default: } + checkCtx, cancel := context.WithTimeout(ctx, perCheckTimeout) r := c.Run(checkCtx) + cancel() + if r.ObservedAt.IsZero() { r.ObservedAt = now } + if r.CheckKey == "" { r.CheckKey = c.Key() } + results = append(results, r) } + return results } @@ -308,6 +331,7 @@ func (a *Agent) Unenroll(ctx context.Context) error { return err } } + if err := a.client.Unenroll(ctx); err != nil { a.Logger.WarnCtx( ctx, @@ -315,12 +339,15 @@ func (a *Agent) Unenroll(ctx context.Context) error { log.Error(err), ) } + if err := DeleteAPIKey(a.Dir); err != nil { return err } + if err := clearPendingPostureBatches(a.Dir); err != nil { return err } + return nil } @@ -333,6 +360,7 @@ func (a *Agent) doHeartbeat(ctx context.Context) (bool, bool) { oldPostureInterval := a.cfg.PostureInterval host := a.currentHostInfo(time.Now()) + resp, err := a.client.Heartbeat( ctx, HeartbeatRequest{ @@ -343,9 +371,11 @@ func (a *Agent) doHeartbeat(ctx context.Context) (bool, bool) { ) if err != nil { a.Logger.ErrorCtx(ctx, "heartbeat failed", log.Error(err)) + if IsUnauthorized(err) { a.handleUnauthorized() } + return false, false } @@ -355,15 +385,18 @@ func (a *Agent) doHeartbeat(ctx context.Context) (bool, bool) { a.cfg.HeartbeatInterval = next } } + if resp.PostureSeconds > 0 { next := normalizePostureInterval(time.Duration(resp.PostureSeconds) * time.Second) if next != a.cfg.PostureInterval { a.cfg.PostureInterval = next } } + a.flushQueuedPostures(ctx) heartbeatChanged := a.cfg.HeartbeatInterval != oldHeartbeatInterval + postureChanged := a.cfg.PostureInterval != oldPostureInterval if heartbeatChanged || postureChanged { if err := SaveConfig(a.Dir, a.cfg); err != nil { @@ -384,16 +417,19 @@ func (a *Agent) doPostures(ctx context.Context) { } start := time.Now() + results := a.CollectOnce(ctx) if len(results) == 0 { return } + var ( passCount int failCount int unknownCount int notApplicableCount int ) + for _, r := range results { switch r.Status { case checks.StatusPass: @@ -406,6 +442,7 @@ func (a *Agent) doPostures(ctx context.Context) { notApplicableCount++ } } + a.Logger.InfoCtx( ctx, "posture checks completed", @@ -430,21 +467,27 @@ func (a *Agent) doPostures(ctx context.Context) { }, ) } + a.flushQueuedPostures(ctx) + if a.revoked { return } + if err := a.client.PushPostures(ctx, payload); err != nil { a.Logger.ErrorCtx(ctx, "posture push failed", log.Error(err)) + if IsUnauthorized(err) { a.handleUnauthorized() return } + dropped, enqueueErr := enqueuePendingPostureBatch(a.Dir, payload, a.currentTime()) if enqueueErr != nil { a.Logger.ErrorCtx(ctx, "cannot queue posture batch after failed push", log.Error(enqueueErr)) return } + a.Logger.WarnCtx( ctx, "queued posture batch for retry", @@ -458,6 +501,7 @@ func (a *Agent) flushQueuedPostures(ctx context.Context) { if a.revoked || a.client == nil { return } + now := a.currentTime() if !a.pendingFlushRetryAt.IsZero() && now.Before(a.pendingFlushRetryAt) { return @@ -468,6 +512,7 @@ func (a *Agent) flushQueuedPostures(ctx context.Context) { a.Logger.WarnCtx(ctx, "cannot load pending posture batches", log.Error(err)) return } + if len(batches) == 0 { a.resetPendingFlushRetry() return @@ -479,9 +524,11 @@ func (a *Agent) flushQueuedPostures(ctx context.Context) { a.handleUnauthorized() return } + if saveErr := savePendingPostureBatches(a.Dir, batches[i:]); saveErr != nil { a.Logger.ErrorCtx(ctx, "cannot persist pending posture batches", log.Error(saveErr)) } + retryIn := a.schedulePendingFlushRetry(now) a.Logger.WarnCtx( ctx, @@ -490,6 +537,7 @@ func (a *Agent) flushQueuedPostures(ctx context.Context) { log.Int("remaining_batches", len(batches)-i), log.Duration("retry_in", retryIn), ) + return } } @@ -498,6 +546,7 @@ func (a *Agent) flushQueuedPostures(ctx context.Context) { a.Logger.ErrorCtx(ctx, "cannot clear pending posture batches", log.Error(err)) return } + a.resetPendingFlushRetry() a.Logger.InfoCtx(ctx, "flushed pending posture batches", log.Int("batches", len(batches))) } @@ -508,9 +557,11 @@ func (a *Agent) currentHostInfo(now time.Time) HostInfo { if collector == nil { collector = CollectHostInfo } + a.hostInfo = collector() a.hostInfoCollectedAt = now } + return a.hostInfo } @@ -518,6 +569,7 @@ func (a *Agent) currentTime() time.Time { if a.now != nil { return a.now() } + return time.Now() } @@ -525,9 +577,11 @@ func (a *Agent) randomInt63n(n int64) int64 { if n <= 1 { return 0 } + if a.randInt63n != nil { return a.randInt63n(n) } + return rand.Int63n(n) } @@ -541,9 +595,11 @@ func (a *Agent) schedulePendingFlushRetry(now time.Time) time.Duration { nextBase = pendingFlushBackoffMax } } + a.pendingFlushBackoff = nextBase jitterRange := nextBase / 5 + jitter := time.Duration(0) if jitterRange > 0 { jitter = time.Duration(a.randomInt63n(int64(jitterRange)*2+1)) - jitterRange @@ -551,6 +607,7 @@ func (a *Agent) schedulePendingFlushRetry(now time.Time) time.Duration { retryIn := max(nextBase+jitter, time.Second) a.pendingFlushRetryAt = now.Add(retryIn) + return retryIn } @@ -564,17 +621,21 @@ func (a *Agent) handleUnauthorized() { if a.revoked { return } + a.revoked = true if a.client != nil { a.client.APIKey = "" } a.Logger.Warn("agent API returned 401, wiping local key and requiring re-enrollment") + if err := DeleteAPIKey(a.Dir); err != nil { a.Logger.Error("cannot delete local key after 401", log.Error(err)) } + if err := clearPendingPostureBatches(a.Dir); err != nil { a.Logger.Error("cannot delete pending posture queue after 401", log.Error(err)) } + a.resetPendingFlushRetry() } diff --git a/pkg/deviceagent/checks/checks_darwin.go b/pkg/deviceagent/checks/checks_darwin.go index 1ccf473ae..eccbbdbd9 100644 --- a/pkg/deviceagent/checks/checks_darwin.go +++ b/pkg/deviceagent/checks/checks_darwin.go @@ -43,11 +43,14 @@ func darwinDiskEncryption(ctx context.Context) Result { }, ) } + on := strings.Contains(strings.ToLower(out.Stdout), "filevault is on") + ev := map[string]any{"raw": out.Stdout} if on { return pass(ev) } + return fail(ev) } @@ -61,40 +64,51 @@ func darwinScreenLock(ctx context.Context) Result { "raw_stdout": status.Stdout, "raw_stderr": status.Stderr, } + mode, seconds, ok := darwinScreenLockMode(rawCombined) if ok { ev["mode"] = mode if mode == "seconds" && seconds >= 0 { ev["seconds"] = seconds } + if mode == "immediate" { return pass(ev) } + return fail(ev) } + if status.Err != nil { ev["error"] = status.Err.Error() } } ask, askSource := darwinReadScreenSaverDefault(ctx, "askForPassword") + ev := map[string]any{} if askSource != "" { ev["source"] = askSource } + if ask.Err != nil { if darwinDefaultsMissing(ask) { ev["ask_for_password"] = "0" ev["note"] = "askForPassword is unset or unavailable" + return fail(ev) } + ev["error"] = ask.Err.Error() ev["stderr"] = ask.Stderr + return unknown(ev) } + enabled := strings.TrimSpace(ask.Stdout) == "1" delayCmd, delaySource := darwinReadScreenSaverDefault(ctx, "askForPasswordDelay") + ev["ask_for_password"] = ask.Stdout if delayCmd.Err == nil { ev["ask_for_password_delay"] = delayCmd.Stdout @@ -102,9 +116,11 @@ func darwinScreenLock(ctx context.Context) Result { ev["delay_source"] = delaySource } } + if enabled { return pass(ev) } + return fail(ev) } @@ -113,20 +129,25 @@ func darwinScreenLockMode(raw string) (string, int, bool) { if strings.Contains(lower, "immediate") { return "immediate", 0, true } + if strings.Contains(lower, "off") { return "off", -1, true } + if before, _, ok := strings.Cut(lower, "seconds"); ok { prefix := strings.Fields(before) if len(prefix) == 0 { return "seconds", -1, true } + n, err := strconv.Atoi(prefix[len(prefix)-1]) if err != nil { return "seconds", -1, true } + return "seconds", n, true } + return "", 0, false } @@ -140,14 +161,17 @@ func darwinFirewall(ctx context.Context) Result { ) if out.Err == nil { state := strings.TrimSpace(out.Stdout) + ev := map[string]any{"backend": "defaults", "global_state": state} if state == "1" || state == "2" { return pass(ev) } + return fail(ev) } fallback := RunCommand(ctx, "/usr/libexec/ApplicationFirewall/socketfilterfw", "--getglobalstate") + ev := map[string]any{ "backend": "socketfilterfw", "raw": fallback.Stdout, @@ -157,14 +181,18 @@ func darwinFirewall(ctx context.Context) Result { if fallback.Err != nil { ev["error"] = fallback.Err.Error() ev["stderr"] = fallback.Stderr + return unknown(ev) } + if darwinStateIndicatesEnabled(fallback.Stdout) { return pass(ev) } + if darwinStateIndicatesDisabled(fallback.Stdout) { return fail(ev) } + return unknown(ev) } @@ -173,6 +201,7 @@ func darwinReadScreenSaverDefault(ctx context.Context, key string) (CmdResult, s consoleUser := darwinConsoleUser(ctx) if os.Geteuid() == 0 && consoleUser != "" { var consoleMissing CmdResult + consoleMissingSource := "" if CommandExists("sudo") { @@ -190,9 +219,11 @@ func darwinReadScreenSaverDefault(ctx context.Context, key string) (CmdResult, s if consoleUserCurrentHost.Err == nil { return consoleUserCurrentHost, "console_user_current_host:" + consoleUser } + if !darwinDefaultsMissing(consoleUserCurrentHost) { return consoleUserCurrentHost, "console_user_current_host:" + consoleUser } + if consoleMissingSource == "" { consoleMissing = consoleUserCurrentHost consoleMissingSource = "console_user_current_host:" + consoleUser @@ -211,9 +242,11 @@ func darwinReadScreenSaverDefault(ctx context.Context, key string) (CmdResult, s if consoleUserDomain.Err == nil { return consoleUserDomain, "console_user:" + consoleUser } + if !darwinDefaultsMissing(consoleUserDomain) { return consoleUserDomain, "console_user:" + consoleUser } + if consoleMissingSource == "" { consoleMissing = consoleUserDomain consoleMissingSource = "console_user:" + consoleUser @@ -221,17 +254,21 @@ func darwinReadScreenSaverDefault(ctx context.Context, key string) (CmdResult, s } plistPath := "/Users/" + consoleUser + "/Library/Preferences/com.apple.screensaver.plist" + consoleUserOut := RunCommand(ctx, "defaults", "read", plistPath, key) if consoleUserOut.Err == nil { return consoleUserOut, "console_user_plist:" + consoleUser } + if !darwinDefaultsMissing(consoleUserOut) { return consoleUserOut, "console_user_plist:" + consoleUser } + if consoleMissingSource == "" { consoleMissing = consoleUserOut consoleMissingSource = "console_user_plist:" + consoleUser } + if consoleMissingSource != "" { return consoleMissing, consoleMissingSource } @@ -250,14 +287,17 @@ func darwinReadScreenSaverDefault(ctx context.Context, key string) (CmdResult, s if !darwinDefaultsMissing(currentUser) { return currentUser, "current_user" } + if !darwinDefaultsMissing(currentHost) { return currentHost, "current_user_current_host" } + return currentUser, "current_user" } func darwinDefaultsMissing(out CmdResult) bool { lower := strings.ToLower(out.Stderr + "\n" + out.Stdout) + return strings.Contains(lower, "does not exist") || strings.Contains(lower, "could not find") || strings.Contains(lower, "does not exist in domain") @@ -267,19 +307,23 @@ func darwinConsoleUser(ctx context.Context) string { if sudoUser := strings.TrimSpace(os.Getenv("SUDO_USER")); sudoUser != "" && sudoUser != "root" { return sudoUser } + out := RunCommand(ctx, "stat", "-f", "%Su", "/dev/console") if out.Err != nil { return "" } + user := strings.TrimSpace(out.Stdout) if user == "" || user == "root" || user == "loginwindow" { return "" } + return user } func darwinStateIndicatesEnabled(raw string) bool { lower := strings.ToLower(raw) + return strings.Contains(lower, "enabled") || strings.Contains(lower, "state = 1") || strings.Contains(lower, "state = 2") @@ -300,11 +344,14 @@ func darwinTimeSync(ctx context.Context) Result { }, ) } + on := strings.Contains(strings.ToLower(out.Stdout), "on") + ev := map[string]any{"raw": out.Stdout} if on { return pass(ev) } + return fail(ev) } @@ -313,11 +360,13 @@ func darwinOSVersion(ctx context.Context) Result { if out.Err != nil || out.Stdout == "" { return unknown(map[string]any{"error": "sw_vers failed"}) } + build := RunCommand(ctx, "sw_vers", "-buildVersion") ev := map[string]any{ "product_version": out.Stdout, "build_version": build.Stdout, } + return pass(ev) } @@ -337,10 +386,12 @@ func darwinAutoUpdate(ctx context.Context) Result { if strings.TrimSpace(primary.Stdout) == "1" { return pass(ev) } + return fail(ev) } fallback := RunCommand(ctx, "softwareupdate", "--schedule") + ev := map[string]any{ "backend": "softwareupdate", "raw": fallback.Stdout, @@ -352,6 +403,7 @@ func darwinAutoUpdate(ctx context.Context) Result { needsAdmin(fallback.Stderr) { ev["error"] = errString(fallback.Err) ev["stderr"] = fallback.Stderr + return unknown(ev) } @@ -378,11 +430,14 @@ func darwinPasswordPolicy(ctx context.Context) Result { }, ) } + lower := strings.ToLower(out.Stdout) + ev := map[string]any{"raw_truncated": truncate(out.Stdout, 400)} if strings.Contains(lower, "no account policies") || lower == "" { return fail(ev) } + return pass(ev) } @@ -396,11 +451,14 @@ func darwinRemoteLogin(ctx context.Context) Result { }, ) } + off := strings.Contains(strings.ToLower(out.Stdout), "off") + ev := map[string]any{"raw": out.Stdout} if off { return pass(ev) } + return fail(ev) } @@ -413,7 +471,9 @@ func darwinMalwareProtection(ctx context.Context) Result { if _, err := os.Stat(path); err != nil { continue } + ev := map[string]any{"engine": "XProtect", "plist": path} + version := RunCommand( ctx, "defaults", @@ -424,8 +484,10 @@ func darwinMalwareProtection(ctx context.Context) Result { if version.Err == nil { ev["version"] = version.Stdout } + return pass(ev) } + return fail( map[string]any{ "engine": "XProtect", diff --git a/pkg/deviceagent/checks/registry.go b/pkg/deviceagent/checks/registry.go index cc436a2e7..98db34f6a 100644 --- a/pkg/deviceagent/checks/registry.go +++ b/pkg/deviceagent/checks/registry.go @@ -29,6 +29,7 @@ var ( func Register(key string, run func(context.Context) Result) { registryMu.Lock() defer registryMu.Unlock() + registry = append( registry, funcCheck{ @@ -51,5 +52,6 @@ func All() []Check { return out[i].Key() < out[j].Key() }, ) + return out } diff --git a/pkg/deviceagent/checks/runcmd.go b/pkg/deviceagent/checks/runcmd.go index a366063d8..40e0fdc24 100644 --- a/pkg/deviceagent/checks/runcmd.go +++ b/pkg/deviceagent/checks/runcmd.go @@ -51,10 +51,13 @@ func RunCommand(ctx context.Context, name string, args ...string) CmdResult { } cmd := exec.CommandContext(cmdCtx, resolved, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout cmd.Stderr = &stderr err := cmd.Run() + return CmdResult{ Stdout: strings.TrimSpace(stdout.String()), Stderr: strings.TrimSpace(stderr.String()), @@ -93,6 +96,7 @@ func isExecutableFile(path string) bool { if err != nil || info.IsDir() { return false } + if runtime.GOOS == "windows" { return true } diff --git a/pkg/deviceagent/checks/shared.go b/pkg/deviceagent/checks/shared.go index 774ba8e82..a3caa32b0 100644 --- a/pkg/deviceagent/checks/shared.go +++ b/pkg/deviceagent/checks/shared.go @@ -44,9 +44,11 @@ func (c funcCheck) Run(ctx context.Context) Result { if r.CheckKey == "" { r.CheckKey = c.key } + if r.ObservedAt.IsZero() { r.ObservedAt = time.Now().UTC() } + return r } @@ -71,6 +73,7 @@ func truncate(s string, n int) string { if len(s) <= n { return s } + return s[:n] + "…" } diff --git a/pkg/deviceagent/client.go b/pkg/deviceagent/client.go index 4b9e5578b..ba26c8c5c 100644 --- a/pkg/deviceagent/client.go +++ b/pkg/deviceagent/client.go @@ -173,6 +173,7 @@ func IsUnauthorized(err error) bool { if !errors.As(err, &herr) { return false } + return herr.StatusCode == http.StatusUnauthorized } @@ -186,11 +187,13 @@ func (c *Client) do( url := c.ServerURL + path var body io.Reader + if in != nil { buf, err := json.Marshal(in) if err != nil { return fmt.Errorf("cannot marshal request: %w", err) } + body = bytes.NewReader(buf) } @@ -198,6 +201,7 @@ func (c *Client) do( if err != nil { return fmt.Errorf("cannot build request: %w", err) } + req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", c.UserAgent) @@ -214,6 +218,7 @@ func (c *Client) do( if err != nil { return fmt.Errorf("cannot perform request: %w", err) } + defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= 400 { diff --git a/pkg/deviceagent/config.go b/pkg/deviceagent/config.go index 031e5ab11..c9403f41e 100644 --- a/pkg/deviceagent/config.go +++ b/pkg/deviceagent/config.go @@ -64,6 +64,7 @@ func ConfigPath(dir string) string { if dir == "" { dir = DefaultConfigDir() } + return filepath.Join(dir, ConfigFileName) } @@ -73,11 +74,14 @@ func LoadConfig(dir string) (*Config, error) { if err != nil { return nil, fmt.Errorf("cannot read config: %w", err) } + cfg := &Config{} if err := json.Unmarshal(data, cfg); err != nil { return nil, fmt.Errorf("cannot decode config: %w", err) } + cfg.applyDefaults() + return cfg, nil } @@ -96,12 +100,14 @@ func SaveConfig(dir string, cfg *Config) error { } cfg.applyDefaults() + data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return fmt.Errorf("cannot encode config: %w", err) } path := ConfigPath(dir) + tmp := path + ".tmp" if err := os.WriteFile(tmp, data, 0o600); err != nil { return fmt.Errorf("cannot write config: %w", err) diff --git a/pkg/deviceagent/hostinfo.go b/pkg/deviceagent/hostinfo.go index aa2483407..5110809fb 100644 --- a/pkg/deviceagent/hostinfo.go +++ b/pkg/deviceagent/hostinfo.go @@ -50,6 +50,7 @@ func CollectHostInfo() HostInfo { } info.OSVersion = collectOSVersion() + info.HardwareUUID = collectHardwareUUID() if sn := collectSerialNumber(); sn != "" { info.SerialNumber = &sn @@ -66,6 +67,7 @@ func hashFallbackUUID() string { h.Write([]byte(hostname)) h.Write([]byte{0}) h.Write([]byte(mac)) + return hex.EncodeToString(h.Sum(nil)) } @@ -94,5 +96,6 @@ func firstStableMAC() string { func runQuiet(ctx context.Context, name string, args ...string) (string, error) { cmd := exec.CommandContext(ctx, name, args...) out, err := cmd.Output() + return strings.TrimSpace(string(out)), err } diff --git a/pkg/deviceagent/hostinfo_darwin.go b/pkg/deviceagent/hostinfo_darwin.go index 513261f1b..934798ea4 100644 --- a/pkg/deviceagent/hostinfo_darwin.go +++ b/pkg/deviceagent/hostinfo_darwin.go @@ -34,6 +34,7 @@ func collectOSVersion() string { } out, _ = runQuiet(ctx, "uname", "-sr") + return out } @@ -54,6 +55,7 @@ func collectSerialNumber() string { defer cancel() out, _ := runQuiet(ctx, "/usr/sbin/ioreg", "-d2", "-c", "IOPlatformExpertDevice") + return extractValue(out, "IOPlatformSerialNumber") } @@ -65,6 +67,7 @@ func extractValue(s, key string) string { } rest := s[idx:] + eq := strings.Index(rest, "=") if eq < 0 { return "" @@ -73,8 +76,10 @@ func extractValue(s, key string) string { rest = strings.TrimSpace(rest[eq+1:]) rest = strings.TrimPrefix(rest, "<") rest = strings.TrimPrefix(rest, ">") + if strings.HasPrefix(rest, "\"") { rest = rest[1:] + before, _, ok := strings.Cut(rest, "\"") if !ok { return "" diff --git a/pkg/deviceagent/posture_queue.go b/pkg/deviceagent/posture_queue.go index 1d09de471..b5fac12e4 100644 --- a/pkg/deviceagent/posture_queue.go +++ b/pkg/deviceagent/posture_queue.go @@ -61,6 +61,7 @@ func loadPendingPostureBatches(dir string) ([]pendingPostureBatch, error) { if len(batch.Results) == 0 { continue } + filtered = append(filtered, batch) } @@ -77,6 +78,7 @@ func savePendingPostureBatches(dir string, batches []pendingPostureBatch) error if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("cannot delete pending postures: %w", err) } + return nil } diff --git a/pkg/deviceagent/posture_queue_test.go b/pkg/deviceagent/posture_queue_test.go index a03d84777..30d0c1a5a 100644 --- a/pkg/deviceagent/posture_queue_test.go +++ b/pkg/deviceagent/posture_queue_test.go @@ -46,10 +46,12 @@ func TestPendingPostureQueue_EnqueueTrimsOldestBatches(t *testing.T) { time.Unix(int64(i), 0), ) require.NoError(t, err) + if i < maxPendingPostureBatches { assert.Equal(t, 0, dropped) continue } + assert.Equal(t, 1, dropped) } @@ -83,6 +85,7 @@ func TestAgent_flushQueuedPostures(t *testing.T) { require.NoError(t, err) var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/api/agent/v1/postures", r.URL.Path) calls.Add(1) @@ -121,13 +124,16 @@ func TestAgent_flushQueuedPostures(t *testing.T) { require.NoError(t, err) var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/api/agent/v1/postures", r.URL.Path) + call := calls.Add(1) if call == 2 { http.Error(w, "temporary error", http.StatusServiceUnavailable) return } + w.WriteHeader(http.StatusOK) })) defer srv.Close() @@ -158,6 +164,7 @@ func TestAgent_flushQueuedPostures(t *testing.T) { require.NoError(t, err) var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/api/agent/v1/postures", r.URL.Path) calls.Add(1) @@ -181,6 +188,7 @@ func TestAgent_flushQueuedPostures(t *testing.T) { assert.Equal(t, int32(1), calls.Load()) now = firstRetryAt.Add(time.Second) + a.flushQueuedPostures(context.Background()) assert.Equal(t, int32(2), calls.Load()) assert.Equal(t, pendingFlushBackoffMin*2, a.pendingFlushBackoff) @@ -201,13 +209,16 @@ func TestAgent_flushQueuedPostures(t *testing.T) { require.NoError(t, err) var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/api/agent/v1/postures", r.URL.Path) + call := calls.Add(1) if call == 1 { http.Error(w, "temporary error", http.StatusServiceUnavailable) return } + w.WriteHeader(http.StatusOK) })) defer srv.Close() @@ -224,6 +235,7 @@ func TestAgent_flushQueuedPostures(t *testing.T) { require.True(t, retryAt.After(now)) now = retryAt.Add(time.Second) + a.flushQueuedPostures(context.Background()) assert.Equal(t, int32(2), calls.Load()) assert.Zero(t, a.pendingFlushBackoff) diff --git a/pkg/deviceagent/service/service_darwin.go b/pkg/deviceagent/service/service_darwin.go index cd6202247..87d93f5a5 100644 --- a/pkg/deviceagent/service/service_darwin.go +++ b/pkg/deviceagent/service/service_darwin.go @@ -93,6 +93,7 @@ func Install(cfg Config) error { if err != nil { return fmt.Errorf("cannot write plist (need root?): %w", err) } + defer func() { _ = f.Close() }() if err := tmpl.Execute(f, cfg); err != nil { diff --git a/pkg/deviceagent/update/archive.go b/pkg/deviceagent/update/archive.go index cee0db07a..858805d4f 100644 --- a/pkg/deviceagent/update/archive.go +++ b/pkg/deviceagent/update/archive.go @@ -59,12 +59,14 @@ func extractTarGzFile(archivePath, wantPath, dest string) error { if err != nil { return fmt.Errorf("cannot open archive: %w", err) } + defer func() { _ = f.Close() }() gz, err := gzip.NewReader(f) if err != nil { return fmt.Errorf("cannot read gzip: %w", err) } + defer func() { _ = gz.Close() }() tr := tar.NewReader(gz) @@ -82,7 +84,7 @@ func extractTarGzFile(archivePath, wantPath, dest string) error { continue } - if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA { + if hdr.Typeflag != tar.TypeReg { return fmt.Errorf("update: %s is not a regular file", wantPath) } @@ -97,6 +99,7 @@ func extractZipFile(archivePath, wantPath, dest string) error { if err != nil { return fmt.Errorf("cannot open zip: %w", err) } + defer func() { _ = r.Close() }() for _, f := range r.File { diff --git a/pkg/deviceagent/update/asset.go b/pkg/deviceagent/update/asset.go index 994f0dc23..e32119ad7 100644 --- a/pkg/deviceagent/update/asset.go +++ b/pkg/deviceagent/update/asset.go @@ -54,6 +54,7 @@ func LayoutFor(goos, goarch string) (AssetLayout, error) { binary := "probo-agent" isZip := false ext := "tar.gz" + if goos == "windows" { binary += ".exe" isZip = true diff --git a/pkg/deviceagent/update/copy.go b/pkg/deviceagent/update/copy.go index 80c533230..80c94af9a 100644 --- a/pkg/deviceagent/update/copy.go +++ b/pkg/deviceagent/update/copy.go @@ -26,6 +26,7 @@ func copyFile(src, dst string) error { if err != nil { return fmt.Errorf("cannot open %s: %w", src, err) } + defer func() { _ = in.Close() }() if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { @@ -40,11 +41,14 @@ func copyFile(src, dst string) error { if _, err := io.Copy(out, in); err != nil { _ = out.Close() _ = os.Remove(dst) + return fmt.Errorf("cannot copy to %s: %w", dst, err) } + if err := out.Sync(); err != nil { _ = out.Close() _ = os.Remove(dst) + return fmt.Errorf("cannot fsync %s: %w", dst, err) } diff --git a/pkg/deviceagent/update/install_unix.go b/pkg/deviceagent/update/install_unix.go index a62335474..4bf53e788 100644 --- a/pkg/deviceagent/update/install_unix.go +++ b/pkg/deviceagent/update/install_unix.go @@ -46,6 +46,7 @@ func replaceBinary(dst, src string) error { if err := copyFile(src, staging); err != nil { return err } + if err := os.Chmod(staging, 0o755); err != nil { _ = os.Remove(staging) return fmt.Errorf("cannot chmod staged binary: %w", err) diff --git a/pkg/deviceagent/update/update.go b/pkg/deviceagent/update/update.go index 5e033da03..60d42ea03 100644 --- a/pkg/deviceagent/update/update.go +++ b/pkg/deviceagent/update/update.go @@ -135,6 +135,7 @@ func (j *jsonTimestamp) UnmarshalJSON(b []byte) error { } *j = jsonTimestamp(t) + return nil } @@ -191,6 +192,7 @@ func (u *Updater) CheckLatest(ctx context.Context) (*Release, error) { current := normalizeSemver(u.CurrentVersion) var best *Release + for i := range releases { rel := &releases[i] if rel.Draft || rel.Prerelease { @@ -217,10 +219,12 @@ func (u *Updater) CheckLatest(ctx context.Context) (*Release, error) { if !ok { continue } + checksumURL, ok := findAssetURL(rel.Assets, checksumFileName) if !ok { continue } + bundleURL, ok := findAssetURL(rel.Assets, checksumBundleFileName) if !ok { // Releases without a Sigstore bundle predate the @@ -281,6 +285,7 @@ func (u *Updater) Apply(ctx context.Context, rel *Release) error { if err != nil { return fmt.Errorf("cannot create update workdir: %w", err) } + defer func() { _ = os.RemoveAll(workDir) }() archivePath := filepath.Join(workDir, layout.ArchiveName) @@ -353,6 +358,7 @@ func (u *Updater) resolveVerifier() (Verifier, error) { } u.Verifier = v + return v, nil } @@ -381,6 +387,7 @@ func (u *Updater) listReleases(ctx context.Context) ([]githubRelease, error) { if err != nil { return nil, fmt.Errorf("cannot build releases request: %w", err) } + req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("User-Agent", u.userAgent()) @@ -388,6 +395,7 @@ func (u *Updater) listReleases(ctx context.Context) ([]githubRelease, error) { if err != nil { return nil, fmt.Errorf("cannot fetch releases: %w", err) } + defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= 400 { @@ -408,6 +416,7 @@ func (u *Updater) downloadFile(ctx context.Context, src, dst string) error { if err != nil { return fmt.Errorf("cannot build download request: %w", err) } + req.Header.Set("Accept", "application/octet-stream") req.Header.Set("User-Agent", u.userAgent()) @@ -415,6 +424,7 @@ func (u *Updater) downloadFile(ctx context.Context, src, dst string) error { if err != nil { return fmt.Errorf("cannot fetch %s: %w", src, err) } + defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= 400 { @@ -423,6 +433,7 @@ func (u *Updater) downloadFile(ctx context.Context, src, dst string) error { } tmp := dst + ".part" + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) if err != nil { return fmt.Errorf("cannot create %s: %w", tmp, err) @@ -432,6 +443,7 @@ func (u *Updater) downloadFile(ctx context.Context, src, dst string) error { _ = f.Close() return fmt.Errorf("cannot stream %s: %w", src, err) } + if err := f.Close(); err != nil { return fmt.Errorf("cannot close %s: %w", tmp, err) } @@ -440,6 +452,7 @@ func (u *Updater) downloadFile(ctx context.Context, src, dst string) error { if err != nil { return fmt.Errorf("cannot stat %s: %w", tmp, err) } + if stat.Size() > defaultDownloadLimit { _ = os.Remove(tmp) return fmt.Errorf("download %s exceeds %d bytes", src, defaultDownloadLimit) @@ -537,6 +550,7 @@ func verifyChecksum(archivePath, checksumPath, archiveName string) error { if err != nil { return fmt.Errorf("cannot open archive: %w", err) } + defer func() { _ = f.Close() }() h := sha256.New() @@ -568,6 +582,7 @@ func readChecksum(path, archiveName string) (string, error) { if line == "" { continue } + // `sha256sum` output is ` `; the GNU tool also // supports a single-space separator and a leading `*` flag // for binary mode. Handle both. diff --git a/pkg/deviceagent/update/update_test.go b/pkg/deviceagent/update/update_test.go index 55c5a0ca5..6439b92d0 100644 --- a/pkg/deviceagent/update/update_test.go +++ b/pkg/deviceagent/update/update_test.go @@ -163,6 +163,7 @@ func newFakeReleaseServer(t *testing.T, tag, version string, layout AssetLayout, mux := http.NewServeMux() mux.HandleFunc("/repos/getprobo/probo/releases", func(w http.ResponseWriter, r *http.Request) { base := "http://" + r.Host + assets := []map[string]any{ { "name": layout.ArchiveName, @@ -179,6 +180,7 @@ func newFakeReleaseServer(t *testing.T, tag, version string, layout AssetLayout, "browser_download_url": base + "/download/" + checksumBundleFileName, }) } + body := []map[string]any{ { "tag_name": frs.tag, @@ -187,6 +189,7 @@ func newFakeReleaseServer(t *testing.T, tag, version string, layout AssetLayout, "assets": assets, }, } + w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(body) _ = version @@ -206,6 +209,7 @@ func newFakeReleaseServer(t *testing.T, tag, version string, layout AssetLayout, frs.server = httptest.NewServer(mux) t.Cleanup(frs.server.Close) + return frs } @@ -213,9 +217,11 @@ func (f *fakeReleaseServer) URL() string { return f.server.URL } func buildArchive(t *testing.T, layout AssetLayout, binary []byte) []byte { t.Helper() + if layout.IsZip { return buildZip(t, layout, binary) } + return buildTarGz(t, layout, binary) } @@ -246,6 +252,7 @@ func buildTarGz(t *testing.T, layout AssetLayout, binary []byte) []byte { data, err := os.ReadFile(out) require.NoError(t, err) + return data } @@ -268,6 +275,7 @@ func buildZip(t *testing.T, layout AssetLayout, binary []byte) []byte { data, err := os.ReadFile(out) require.NoError(t, err) + return data } diff --git a/pkg/deviceagent/update/verify.go b/pkg/deviceagent/update/verify.go index eae95fc73..427340513 100644 --- a/pkg/deviceagent/update/verify.go +++ b/pkg/deviceagent/update/verify.go @@ -106,12 +106,15 @@ func NewCosignVerifier(cfg CosignVerifierConfig) (*CosignVerifier, error) { if cfg.Repo == "" { return nil, fmt.Errorf("update: cosign verifier requires Repo") } + if cfg.WorkflowPath == "" { cfg.WorkflowPath = expectedWorkflowPath } + if cfg.TagPrefix == "" { cfg.TagPrefix = DefaultTagPrefix } + if cfg.CacheDir == "" { return nil, fmt.Errorf("update: cosign verifier requires CacheDir") } @@ -169,6 +172,7 @@ func (v *CosignVerifier) Verify(_ context.Context, artifactPath, bundlePath stri if err != nil { return fmt.Errorf("cannot open artifact for verification: %w", err) } + defer func() { _ = artifact.Close() }() policy := verify.NewPolicy(