Keep self-update preflight tokens out of argv

This commit is contained in:
rcourtman 2026-04-22 08:12:35 +01:00
parent 2806cc6c9e
commit 9879c3986a
3 changed files with 183 additions and 6 deletions

View file

@ -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 <secret>` 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

View file

@ -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).

View file

@ -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()