mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Keep self-update preflight tokens out of argv
This commit is contained in:
parent
2806cc6c9e
commit
9879c3986a
3 changed files with 183 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue