From 67dce69e738ec02bc584da3dc59ecbdb402d411f Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:26:37 +0100 Subject: [PATCH] Remove safe profile state on uninstall --- .../v6/internal/subsystems/agent-lifecycle.md | 3 +- .../subsystems/deployment-installability.md | 7 ++- scripts/install.sh | 8 +++ scripts/installtests/install_sh_test.go | 57 +++++++++++++++++-- 4 files changed, 66 insertions(+), 9 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index c79d0518d..a102a69af 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -7331,7 +7331,8 @@ as removed, rejects that bearer thereafter, and returns the matching agent ID; the following legacy-migration phase uses a distinct replacement enrollment credential rather than resurrecting the removed one. Final cleanup must commit and verify the replacement binding's removal too, then remove the fixed -root-owned helper state boundary. +root-owned helper and safe-profile transaction state boundaries through exact +path-authorized deletion. The wrapper exercises each runtime in an isolated state root and emits the standalone `secure-runtime-rootless-v1` receipt only after exact socket ownership, daemon rootless attestation, installer pinning, direct telemetry, diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 026a58fa4..1f5bd48a3 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -4426,9 +4426,10 @@ redirect-denying CA/fingerprint lifecycle client before deleting services, credentials, or recovery state. An unreachable or untrusted server is a repair-required uninstall, not permission for local-only credential loss. Once server removal is confirmed, full shell uninstall must also remove the -fixed root-owned privileged-helper activation/staging state. That recursive -cleanup requires an exact installer-established helper lifecycle authority; -uncertain or mismatched paths remain intact for explicit repair. +fixed root-owned privileged-helper activation/staging state and safe-profile +transaction archive. Those recursive cleanups require exact +installer-established helper and profile lifecycle authorities; uncertain or +mismatched paths remain intact for explicit repair. TrueNAS boot recovery must follow the same rule: SCALE and CORE bootstrap scripts may differ only in their service-manager adapter, while binary sync, service-link recreation, and boot-time start flow stay on one installer-owned diff --git a/scripts/install.sh b/scripts/install.sh index 348f370e6..614860ae1 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -195,6 +195,7 @@ TMP_ACTION_RUNNER_BIN="" # leaves these unset and therefore never enters a migration transaction. SAFE_PROFILE_ACTION="" SAFE_PROFILE_STATE_DIR="/var/lib/pulse-agent-profile" +SAFE_PROFILE_STATE_DIR_REMOVAL_AUTHORITY="$SAFE_PROFILE_STATE_DIR" SAFE_PROFILE_CURRENT_FILE="${SAFE_PROFILE_STATE_DIR}/current.env" SAFE_PROFILE_COLLECTOR_UNIT="/etc/systemd/system/${AGENT_NAME}.service" SAFE_PROFILE_TRANSACTION_DIR="" @@ -3206,6 +3207,10 @@ remove_privileged_helper_state_dir() { remove_authorized_runtime_dir "privileged helper state" "$PRIVILEGED_HELPER_STATE_DIR" "${PRIVILEGED_HELPER_STATE_DIR_REMOVAL_AUTHORITY:-}" } +remove_safe_profile_state_dir() { + remove_authorized_runtime_dir "safe-profile state" "$SAFE_PROFILE_STATE_DIR" "${SAFE_PROFILE_STATE_DIR_REMOVAL_AUTHORITY:-}" +} + detect_qnap_data_volume() { local qnap_vol="" local candidate="" @@ -5542,6 +5547,9 @@ if [[ "$UNINSTALL" == "true" ]]; then if ! remove_privileged_helper_state_dir; then log_warn "Retained privileged helper state at ${PRIVILEGED_HELPER_STATE_DIR}; its path was not authorized by the fixed helper lifecycle boundary." fi + if ! remove_safe_profile_state_dir; then + log_warn "Retained safe-profile state at ${SAFE_PROFILE_STATE_DIR}; its path was not authorized by the fixed profile lifecycle boundary." + fi # Remove least-privilege helper artifacts. The pulse-agent system user is # deliberately left behind: deleting accounts can orphan files elsewhere, diff --git a/scripts/installtests/install_sh_test.go b/scripts/installtests/install_sh_test.go index 280928a58..f9b8f049e 100644 --- a/scripts/installtests/install_sh_test.go +++ b/scripts/installtests/install_sh_test.go @@ -4055,7 +4055,53 @@ func TestInstallSHPrivilegedHelperStateRemovalRequiresExactLifecycleAuthority(t } } -func TestInstallSHFullUninstallRemovesPrivilegedHelperStateOnlyAfterServerConfirmation(t *testing.T) { +func TestInstallSHSafeProfileStateRemovalRequiresExactLifecycleAuthority(t *testing.T) { + for _, tc := range []struct { + name string + authority func(string) string + wantRemove bool + }{ + {name: "exact fixed boundary", authority: func(path string) string { return path }, wantRemove: true}, + {name: "mismatched boundary", authority: func(path string) string { return path + "-other" }, wantRemove: false}, + } { + t.Run(tc.name, func(t *testing.T) { + profileState := filepath.Join(t.TempDir(), "pulse-agent-profile") + if err := os.MkdirAll(profileState, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(profileState, "current.env"), []byte("retained-or-removed"), 0o600); err != nil { + t.Fatal(err) + } + script := ` + set -u + SAFE_PROFILE_STATE_DIR="` + profileState + `" + SAFE_PROFILE_STATE_DIR_REMOVAL_AUTHORITY="` + tc.authority(profileState) + `" + log_warn() { :; } +` + extractInstallShellFunction(t, "remove_authorized_runtime_dir") + ` +` + extractInstallShellFunction(t, "remove_safe_profile_state_dir") + ` + if remove_safe_profile_state_dir; then + printf 'removed\n' + else + printf 'retained\n' + fi + ` + out, err := exec.Command("bash", "-c", script).CombinedOutput() + if err != nil { + t.Fatalf("safe-profile-state cleanup harness: %v\n%s", err, out) + } + _, statErr := os.Stat(profileState) + if tc.wantRemove { + if !os.IsNotExist(statErr) || string(out) != "removed\n" { + t.Fatalf("authorized safe-profile state survived: stat=%v output=%q", statErr, out) + } + } else if statErr != nil || string(out) != "retained\n" { + t.Fatalf("unauthorized safe-profile state changed: stat=%v output=%q", statErr, out) + } + }) + } +} + +func TestInstallSHFullUninstallRemovesRuntimeStateOnlyAfterServerConfirmation(t *testing.T) { content, err := os.ReadFile(repoFile("scripts", "install.sh")) if err != nil { t.Fatalf("read install.sh: %v", err) @@ -4063,11 +4109,12 @@ func TestInstallSHFullUninstallRemovesPrivilegedHelperStateOnlyAfterServerConfir script := string(content) confirmation := strings.LastIndex(script, `if ! uninstall_collector_registration; then`) helperCleanup := strings.LastIndex(script, `if ! remove_privileged_helper_state_dir; then`) - if confirmation < 0 || helperCleanup < 0 { - t.Fatalf("full uninstall lifecycle is missing server confirmation or helper-state cleanup: confirmation=%d cleanup=%d", confirmation, helperCleanup) + profileCleanup := strings.LastIndex(script, `if ! remove_safe_profile_state_dir; then`) + if confirmation < 0 || helperCleanup < 0 || profileCleanup < 0 { + t.Fatalf("full uninstall lifecycle is missing server confirmation or fixed-state cleanup: confirmation=%d helper=%d profile=%d", confirmation, helperCleanup, profileCleanup) } - if helperCleanup <= confirmation { - t.Fatal("privileged helper state cleanup can run before authenticated server removal") + if helperCleanup <= confirmation || profileCleanup <= confirmation { + t.Fatal("fixed privileged runtime state cleanup can run before authenticated server removal") } }