From bfd53cd7bd5570888387c591067aa39918855326 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:05:49 +0100 Subject: [PATCH 1/2] Remove privileged helper state on full uninstall --- .../v6/internal/subsystems/agent-lifecycle.md | 8 ++- .../subsystems/deployment-installability.md | 6 +- scripts/install.sh | 25 ++++++-- .../agent_state_dir_lifecycle_test.go | 1 + scripts/installtests/install_sh_test.go | 64 +++++++++++++++++++ 5 files changed, 97 insertions(+), 7 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 0e0d3e1e8..87bed955a 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -6061,6 +6061,11 @@ the host-removal tombstone and any dedicated token revocation before live teardown. Either persistence failure returns non-success with the live host and retry bearer retained; after success, restart must preserve the removed host state and reject the old bearer. +After that confirmation, full shell uninstall must remove the fixed root-owned +privileged-helper activation/staging state as well as collector state, units, +binaries, sockets, and credentials. Recursive helper-state deletion is allowed +only when the requested path exactly matches the installer-established helper +lifecycle authority; an indeterminate path is retained for repair. The server also refuses teardown-authorizing success when a legacy collector token still belongs to another live host; that credential must first be split or rotated. A crash or response loss after both durable writes is fail-closed @@ -7323,7 +7328,8 @@ teardown succeeds only after the fixture records the exact registered binding 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. +and verify the replacement binding's removal too, then remove the fixed +root-owned helper state boundary. 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 be2a58a20..9e27fb9af 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -273,7 +273,7 @@ teardown contract: it authenticates the exact collector binding, records the removal before returning the matching agent ID, rejects the removed bearer, and provisions a distinct replacement credential for the subsequent legacy migration. The final uninstall must independently remove that replacement -binding before local files or services disappear. +binding before local files, services, or fixed helper state disappear. Its standalone `secure-runtime-rootless-v1` receipt must bind the exact qualification, collector, helper, installer, source-manifest, socket, fresh-install, legacy-migration, restart, fallback, recovery, ambiguity, @@ -4418,6 +4418,10 @@ authenticated exact-agent success response from the canonical no-proxy, 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. 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 a85730a39..348f370e6 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -167,6 +167,7 @@ PRIVILEGED_HELPER_SOCKET_PATH="${PRIVILEGED_HELPER_SOCKET_DIR}/helper.sock" INSTALLER_LIFECYCLE_DIR="/etc/pulse-agent" PRIVILEGED_HELPER_CREDENTIAL_DIR="$INSTALLER_LIFECYCLE_DIR" PRIVILEGED_HELPER_STATE_DIR="/var/lib/pulse-agent-helper" +PRIVILEGED_HELPER_STATE_DIR_REMOVAL_AUTHORITY="$PRIVILEGED_HELPER_STATE_DIR" PRIVILEGED_HELPER_UPDATE_STAGING_DIR="${PRIVILEGED_HELPER_STATE_DIR}/update-staging" PRIVILEGED_HELPER_UPDATE_QUARANTINE_DIR="/var/lib/pulse-agent/update-quarantine" TMP_HELPER_BIN="" @@ -3179,21 +3180,32 @@ discover_state_dir_from_saved_installer() { return 1 } -remove_agent_state_dir() { - local state_dir="${1:-$STATE_DIR}" +remove_authorized_runtime_dir() { + local label="$1" + local state_dir="$2" + local removal_authority="$3" if [[ -z "$state_dir" || "$state_dir" != /* || "$state_dir" == "/" || "$state_dir" == *$'\r'* || "$state_dir" == *$'\n'* ]]; then - log_warn "Refusing to remove invalid agent state directory: ${state_dir:-}" + log_warn "Refusing to remove invalid ${label} directory: ${state_dir:-}" return 1 fi - if [[ -z "${STATE_DIR_REMOVAL_AUTHORITY:-}" || "$state_dir" != "$STATE_DIR_REMOVAL_AUTHORITY" ]]; then - log_warn "Refusing to remove agent state directory without exact trusted lifecycle authority: $state_dir" + if [[ -z "$removal_authority" || "$state_dir" != "$removal_authority" ]]; then + log_warn "Refusing to remove ${label} directory without exact trusted lifecycle authority: $state_dir" return 1 fi rm -rf -- "$state_dir" } +remove_agent_state_dir() { + local state_dir="${1:-$STATE_DIR}" + remove_authorized_runtime_dir "agent state" "$state_dir" "${STATE_DIR_REMOVAL_AUTHORITY:-}" +} + +remove_privileged_helper_state_dir() { + remove_authorized_runtime_dir "privileged helper state" "$PRIVILEGED_HELPER_STATE_DIR" "${PRIVILEGED_HELPER_STATE_DIR_REMOVAL_AUTHORITY:-}" +} + detect_qnap_data_volume() { local qnap_vol="" local candidate="" @@ -5527,6 +5539,9 @@ if [[ "$UNINSTALL" == "true" ]]; then if ! remove_agent_state_dir "$STATE_DIR"; then log_warn "Retained agent state at ${STATE_DIR}; its path was not authorized by explicit or protected lifecycle state." fi + 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 # 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/agent_state_dir_lifecycle_test.go b/scripts/installtests/agent_state_dir_lifecycle_test.go index 24b26b81c..0c325d950 100644 --- a/scripts/installtests/agent_state_dir_lifecycle_test.go +++ b/scripts/installtests/agent_state_dir_lifecycle_test.go @@ -503,6 +503,7 @@ func TestPulseAgentStateDirLifecycleIntegration(t *testing.T) { STATE_DIR="` + stateDir + `" STATE_DIR_REMOVAL_AUTHORITY="$STATE_DIR" log_warn() { :; } +` + extractInstallShellFunction(t, "remove_authorized_runtime_dir") + ` ` + extractInstallShellFunction(t, "remove_agent_state_dir") + ` remove_agent_state_dir "$STATE_DIR" ` diff --git a/scripts/installtests/install_sh_test.go b/scripts/installtests/install_sh_test.go index 2e1078ce8..280928a58 100644 --- a/scripts/installtests/install_sh_test.go +++ b/scripts/installtests/install_sh_test.go @@ -3576,6 +3576,7 @@ ExecStart=/usr/local/bin/pulse-agent --url https://custom.example --token-file ` ` + extractInstallShellFunction(t, "recover_connection_state_from_env_stream") + ` ` + extractInstallShellFunction(t, "split_recovered_shell_words") + ` ` + extractInstallShellFunction(t, "recover_connection_state_from_systemd_unit") + ` +` + extractInstallShellFunction(t, "remove_authorized_runtime_dir") + ` ` + extractInstallShellFunction(t, "remove_agent_state_dir") + ` recover_connection_state_from_systemd_unit printf 'state=%s source=%s url=%s token=%s commands=%s\n' \ @@ -3994,6 +3995,7 @@ func TestInstallSHSavedInstallerTamperAndUntrustedStateFailClosed(t *testing.T) ` + extractLifecycleTrustShellFunctions(t) + ` ` + extractInstallShellFunction(t, "read_connection_state_value") + ` ` + extractInstallShellFunction(t, "discover_state_dir_from_saved_installer") + ` +` + extractInstallShellFunction(t, "remove_authorized_runtime_dir") + ` ` + extractInstallShellFunction(t, "remove_agent_state_dir") + ` discover_state_dir_from_saved_installer "` + attackerInstaller + `" || true STATE_DIR="` + sentinelDir + `" @@ -4007,6 +4009,68 @@ func TestInstallSHSavedInstallerTamperAndUntrustedStateFailClosed(t *testing.T) } } +func TestInstallSHPrivilegedHelperStateRemovalRequiresExactLifecycleAuthority(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) { + helperState := filepath.Join(t.TempDir(), "pulse-agent-helper") + if err := os.MkdirAll(helperState, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(helperState, "activation-state"), []byte("retain-or-remove"), 0o600); err != nil { + t.Fatal(err) + } + script := ` + set -u + PRIVILEGED_HELPER_STATE_DIR="` + helperState + `" + PRIVILEGED_HELPER_STATE_DIR_REMOVAL_AUTHORITY="` + tc.authority(helperState) + `" + log_warn() { :; } +` + extractInstallShellFunction(t, "remove_authorized_runtime_dir") + ` +` + extractInstallShellFunction(t, "remove_privileged_helper_state_dir") + ` + if remove_privileged_helper_state_dir; then + printf 'removed\n' + else + printf 'retained\n' + fi + ` + out, err := exec.Command("bash", "-c", script).CombinedOutput() + if err != nil { + t.Fatalf("helper-state cleanup harness: %v\n%s", err, out) + } + _, statErr := os.Stat(helperState) + if tc.wantRemove { + if !os.IsNotExist(statErr) || string(out) != "removed\n" { + t.Fatalf("authorized helper state survived: stat=%v output=%q", statErr, out) + } + } else if statErr != nil || string(out) != "retained\n" { + t.Fatalf("unauthorized helper state changed: stat=%v output=%q", statErr, out) + } + }) + } +} + +func TestInstallSHFullUninstallRemovesPrivilegedHelperStateOnlyAfterServerConfirmation(t *testing.T) { + content, err := os.ReadFile(repoFile("scripts", "install.sh")) + if err != nil { + t.Fatalf("read install.sh: %v", err) + } + 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) + } + if helperCleanup <= confirmation { + t.Fatal("privileged helper state cleanup can run before authenticated server removal") + } +} + func TestInstallSHLegacyCollectorOwnedTokenReaderFailsClosed(t *testing.T) { root := t.TempDir() stateDir := filepath.Join(root, "collector-state") From 7d7cb448555de3e10ee035ca185b2be54d5f73c4 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:16:30 +0100 Subject: [PATCH 2/2] Canonicalize rootless telemetry parity hashing --- ...ure_runtime_rootless_qualification_test.go | 44 +++++++++++++++---- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/scripts/installtests/secure_runtime_rootless_qualification_test.go b/scripts/installtests/secure_runtime_rootless_qualification_test.go index 26e272105..3d63fd856 100644 --- a/scripts/installtests/secure_runtime_rootless_qualification_test.go +++ b/scripts/installtests/secure_runtime_rootless_qualification_test.go @@ -483,6 +483,12 @@ type rootlessQualReportDigest struct { SecondaryInventoryPresent bool } +type rootlessQualContainerSemantic struct { + Name string + Image string + State string +} + type rootlessQualIdentity struct { RuntimeVersion string `json:"runtime_version"` SocketUID int `json:"socket_uid"` @@ -679,15 +685,23 @@ func rootlessQualRuntimeBaseline(t *testing.T, d rootlessQualDaemon, rootless bo cli := rootlessQualCLI(d, rootless) format := "{{.Names}}|{{.Image}}|{{.State}}" out := rootlessQualCommand(t, 30*time.Second, cli[0], append(cli[1:], "ps", "-a", "--format", format)...) + return rootlessQualBaselineFromPSOutput(out) +} + +func rootlessQualBaselineFromPSOutput(out string) rootlessQualBaseline { lines := strings.Split(strings.TrimSpace(out), "\n") - var normalized []string + var normalized []rootlessQualContainerSemantic for _, line := range lines { - line = strings.TrimSpace(strings.TrimPrefix(line, "/")) - if line != "" { - normalized = append(normalized, line) + parts := strings.SplitN(strings.TrimSpace(line), "|", 3) + if len(parts) == 3 { + normalized = append(normalized, rootlessQualContainerSemantic{ + Name: strings.TrimSpace(strings.TrimPrefix(parts[0], "/")), + Image: strings.TrimSpace(parts[1]), + State: strings.TrimSpace(parts[2]), + }) } } - sort.Strings(normalized) + sort.Slice(normalized, func(i, j int) bool { return normalized[i].Name < normalized[j].Name }) return rootlessQualBaseline{Count: len(normalized), SemanticDigest: rootlessQualHashJSON(normalized)} } @@ -736,19 +750,18 @@ func rootlessQualAssertHelperSummaryOnly(t *testing.T, report agentsdocker.Repor } func rootlessQualDigestReport(report agentsdocker.Report) rootlessQualReportDigest { - type semantic struct{ Name, Image, State string } type stats struct { Name string MemoryLimited bool OOMKnown bool RuntimeDetails bool } - semanticRows := make([]semantic, 0, len(report.Containers)) + semanticRows := make([]rootlessQualContainerSemantic, 0, len(report.Containers)) statsRows := make([]stats, 0, len(report.Containers)) fullFieldsPresent := len(report.Containers) > 0 runningStatsPresent := false for _, item := range report.Containers { - semanticRows = append(semanticRows, semantic{Name: item.Name, Image: item.Image, State: item.State}) + semanticRows = append(semanticRows, rootlessQualContainerSemantic{Name: item.Name, Image: item.Image, State: item.State}) statsRows = append(statsRows, stats{Name: item.Name, MemoryLimited: item.MemoryLimitBytes > 0, OOMKnown: item.OOMKilled != nil, RuntimeDetails: item.StartedAt != nil || item.FinishedAt != nil}) if item.CreatedAt.IsZero() || item.Status == "" || item.OOMKilled == nil || (item.StartedAt == nil && item.FinishedAt == nil) { fullFieldsPresent = false @@ -1240,6 +1253,21 @@ func TestRootlessQualificationReceiptContract(t *testing.T) { } } +func TestRootlessQualificationBaselineUsesReportSemanticShape(t *testing.T) { + baseline := rootlessQualBaselineFromPSOutput(strings.Join([]string{ + "/pulse-rootless-running|pulse-rootless-qualification:v1|running", + "pulse-rootless-exited|pulse-rootless-qualification:v1|exited", + }, "\n")) + report := agentsdocker.Report{Containers: []agentsdocker.Container{ + {Name: "pulse-rootless-exited", Image: "pulse-rootless-qualification:v1", State: "exited"}, + {Name: "pulse-rootless-running", Image: "pulse-rootless-qualification:v1", State: "running"}, + }} + digest := rootlessQualDigestReport(report) + if baseline.Count != digest.Count || baseline.SemanticDigest != digest.SemanticDigest { + t.Fatalf("runtime baseline and report semantics diverged: baseline=%+v report=%+v", baseline, digest) + } +} + func TestRootlessQualificationGoSchemaPassesPythonValidator(t *testing.T) { commit := strings.Repeat("a", 40) digest := strings.Repeat("b", 64)