Merge remote-tracking branch 'origin/main'

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-01 17:24:14 +01:00
6 changed files with 133 additions and 15 deletions
+20 -5
View File
@@ -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:-<empty>}"
log_warn "Refusing to remove invalid ${label} directory: ${state_dir:-<empty>}"
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,
@@ -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"
`
+64
View File
@@ -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")
@@ -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)