From 9879c3986a77814217808f74ee952f283dd7f13a Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 22 Apr 2026 08:12:35 +0100 Subject: [PATCH] Keep self-update preflight tokens out of argv --- .../v6/internal/subsystems/agent-lifecycle.md | 5 + internal/dockeragent/self_update.go | 65 +++++++++- internal/dockeragent/self_update_test.go | 119 ++++++++++++++++++ 3 files changed, 183 insertions(+), 6 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 6b8d1c12e..8180e88eb 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -1629,6 +1629,11 @@ the matching base64-encoded `X-Signature-SSHSIG`, and `internal/api/unified_agent.go` must only serve published release installers and agent binaries from local or proxied assets that carry the matching detached signature sidecars. +That same self-update pre-flight must keep the live agent token out of process +argv. `internal/dockeragent/self_update.go` may pass a short-lived `0600` +token file into `cmd/pulse-agent/main.go --self-test --token-file`, but it +must not revive `--token ` argument passing that exposes the runtime +credential through `/proc/*/cmdline`. That same unified-agent runtime boundary also owns vendor-aware host identity. When gopsutil reports generic Linux platform fields on NAS appliances, `internal/hostagent/` must prefer canonical platform files such as Synology DSM diff --git a/internal/dockeragent/self_update.go b/internal/dockeragent/self_update.go index d4b6e708a..d93eb5e76 100644 --- a/internal/dockeragent/self_update.go +++ b/internal/dockeragent/self_update.go @@ -31,6 +31,54 @@ const ( var selfUpdateRetrySleepFn = selfUpdateSleepWithContext +func writeSelfTestTokenFile(token string) (string, error) { + trimmed := strings.TrimSpace(token) + if trimmed == "" { + return "", errors.New("self-update pre-flight requires API token") + } + + file, err := osCreateTempFn("", "pulse-agent-selftest-token-*") + if err != nil { + return "", fmt.Errorf("create self-test token file: %w", err) + } + path := file.Name() + cleanupOnError := true + defer func() { + if cleanupOnError { + _ = closeFileFn(file) + _ = osRemoveFn(path) + } + }() + + if _, err := file.WriteString(trimmed); err != nil { + return "", fmt.Errorf("write self-test token file: %w", err) + } + if err := closeFileFn(file); err != nil { + return "", fmt.Errorf("close self-test token file: %w", err) + } + if err := osChmodFn(path, 0o600); err != nil { + return "", fmt.Errorf("chmod self-test token file: %w", err) + } + + cleanupOnError = false + return path, nil +} + +func (a *Agent) selfTestToken(target TargetConfig) string { + if token := strings.TrimSpace(a.cfg.APIToken); token != "" { + return token + } + if token := strings.TrimSpace(target.Token); token != "" { + return token + } + for _, candidate := range a.targets { + if token := strings.TrimSpace(candidate.Token); token != "" { + return token + } + } + return "" +} + // checkForUpdates checks if a newer version is available and performs self-update if needed func (a *Agent) checkForUpdates(ctx context.Context) { if !a.tryStartUpdateCheck() { @@ -484,12 +532,17 @@ func (a *Agent) selfUpdate(ctx context.Context) error { // and force --self-test a.logger.Debug().Msg("Self-update: running pre-flight check on new binary...") - // Construct args for self-test. We need a valid token source for config load to pass. - // Since we are running on the same host, we can pass the token file or env. - // Simplest approach: pass --self-test and --token=dummy (if validation requires it) - // But our config loader checks token presence. - // Let's use the actual token configured to be safe. - checkCmd := execCommandContextFn(ctx, tmpPath, "--self-test", "--token", a.cfg.APIToken) + // Construct args for self-test using an ephemeral token file so the live + // credential never appears in argv. + tokenFilePath, err := writeSelfTestTokenFile(a.selfTestToken(target)) + if err != nil { + return fmt.Errorf("prepare self-test token file: %w", err) + } + defer func() { + _ = osRemoveFn(tokenFilePath) + }() + + checkCmd := execCommandContextFn(ctx, tmpPath, "--self-test", "--token-file", tokenFilePath) if output, err := checkCmd.CombinedOutput(); err != nil { a.logger.Error(). Err(err). diff --git a/internal/dockeragent/self_update_test.go b/internal/dockeragent/self_update_test.go index 59a26e09c..c37ab7b7b 100644 --- a/internal/dockeragent/self_update_test.go +++ b/internal/dockeragent/self_update_test.go @@ -1284,6 +1284,125 @@ func TestSelfUpdate(t *testing.T) { } }) + t.Run("pre-flight self-test keeps token out of argv", func(t *testing.T) { + body := elfBytes() + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(body)), + Header: http.Header{"X-Checksum-Sha256": []string{sha256Hex(body)}}, + }, nil + })} + + agent := &Agent{ + logger: zerolog.Nop(), + cfg: Config{ + APIToken: "live-secret-token", + }, + targets: []TargetConfig{{URL: "http://example.com", Token: "token"}}, + httpClients: map[bool]*http.Client{ + false: client, + }, + } + dir := t.TempDir() + execPath := filepath.Join(dir, "exec") + if err := os.WriteFile(execPath, elfBytes(), 0700); err != nil { + t.Fatalf("write exec: %v", err) + } + swap(t, &osExecutableFn, func() (string, error) { + return execPath, nil + }) + swap(t, &syscallExecFn, func(string, []string, []string) error { + return nil + }) + + var capturedArgs []string + var capturedCmd *exec.Cmd + swap(t, &execCommandContextFn, func(ctx context.Context, name string, arg ...string) *exec.Cmd { + capturedArgs = append([]string{name}, arg...) + if len(arg) != 3 || arg[0] != "--self-test" || arg[1] != "--token-file" { + t.Fatalf("unexpected self-test args: %v", arg) + } + tokenFilePath := arg[2] + content, err := os.ReadFile(tokenFilePath) + if err != nil { + t.Fatalf("read token file: %v", err) + } + if strings.TrimSpace(string(content)) != agent.cfg.APIToken { + t.Fatalf("token file contents = %q, want %q", strings.TrimSpace(string(content)), agent.cfg.APIToken) + } + + cmd := exec.Command("echo", "ok") + capturedCmd = cmd + return cmd + }) + + if err := agent.selfUpdate(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(strings.Join(capturedArgs, " "), agent.cfg.APIToken) { + t.Fatalf("argv leaked API token: %v", capturedArgs) + } + tokenFilePath := capturedArgs[3] + if _, err := os.Stat(tokenFilePath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected temporary token file to be removed, stat err = %v", err) + } + for _, env := range capturedCmd.Env { + if strings.Contains(env, agent.cfg.APIToken) { + t.Fatalf("environment leaked API token: %q", env) + } + } + }) + + t.Run("pre-flight self-test falls back to target token", func(t *testing.T) { + body := elfBytes() + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(body)), + Header: http.Header{"X-Checksum-Sha256": []string{sha256Hex(body)}}, + }, nil + })} + + agent := &Agent{ + logger: zerolog.Nop(), + targets: []TargetConfig{{URL: "http://example.com", Token: "target-secret-token"}}, + httpClients: map[bool]*http.Client{ + false: client, + }, + } + dir := t.TempDir() + execPath := filepath.Join(dir, "exec") + if err := os.WriteFile(execPath, elfBytes(), 0700); err != nil { + t.Fatalf("write exec: %v", err) + } + swap(t, &osExecutableFn, func() (string, error) { + return execPath, nil + }) + swap(t, &syscallExecFn, func(string, []string, []string) error { + return nil + }) + + swap(t, &execCommandContextFn, func(ctx context.Context, name string, arg ...string) *exec.Cmd { + if len(arg) != 3 || arg[0] != "--self-test" || arg[1] != "--token-file" { + t.Fatalf("unexpected self-test args: %v", arg) + } + tokenFilePath := arg[2] + content, err := os.ReadFile(tokenFilePath) + if err != nil { + t.Fatalf("read token file: %v", err) + } + if strings.TrimSpace(string(content)) != agent.targets[0].Token { + t.Fatalf("token file contents = %q, want %q", strings.TrimSpace(string(content)), agent.targets[0].Token) + } + return exec.Command("echo", "ok") + }) + + if err := agent.selfUpdate(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + t.Run("signature verified when trusted keys configured", func(t *testing.T) { privateKey := configureTrustedUpdateKeys(t) body := elfBytes()