mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 10:35:51 +00:00
Merge remote-tracking branch 'origin/main'
Change-source: pulse-maintainer
This commit is contained in:
+20
-5
@@ -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"
|
||||
`
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user