From 08f7c5f0d5f85cdb19768b3f57a3579de7428bdb Mon Sep 17 00:00:00 2001 From: Richard Courtman Date: Mon, 31 Aug 2026 00:06:24 +0100 Subject: [PATCH] Harden secure agent runtime boundaries --- .gitignore | 3 + cmd/pulse-agent-runner/main.go | 110 ++- cmd/pulse-agent-runner/main_test.go | 53 +- .../private_value_unix_test.go | 25 + cmd/pulse-agent/collector_lifecycle.go | 113 +++ cmd/pulse-agent/collector_lifecycle_test.go | 116 +++ ...ollector_lifecycle_test_owner_unix_test.go | 10 + ...ector_lifecycle_test_owner_windows_test.go | 5 + cmd/pulse-agent/main.go | 10 + docs/AGENT_SECURITY.md | 35 +- .../internal/AGENT_PRIVILEGE_BOUNDARY_PLAN.md | 60 +- .../v6/internal/subsystems/agent-lifecycle.md | 70 +- .../v6/internal/subsystems/api-contracts.md | 25 +- .../subsystems/deployment-installability.md | 37 +- .../v6/internal/subsystems/registry.json | 23 + .../internal/subsystems/security-privacy.md | 51 +- .../internal/subsystems/storage-recovery.md | 21 +- frontend-modern/public/docs/AGENT_SECURITY.md | 35 +- internal/actionrunner/transport.go | 13 + internal/agentexec/server.go | 233 +++-- internal/agentexec/server_test.go | 2 +- internal/agentexec/server_websocket_test.go | 274 ++++++ internal/agenthelper/server.go | 83 +- internal/agenthelper/server_test.go | 201 ++++ internal/api/action_runner_credentials.go | 111 ++- .../api/action_runner_credentials_test.go | 138 ++- internal/api/agent_exec_token_binding.go | 6 +- internal/api/agenttokens/install.go | 113 ++- internal/api/agenttokens/install_test.go | 333 ++++++- internal/api/collector_authority.go | 9 +- internal/api/collector_authority_test.go | 24 + internal/api/route_inventory_test.go | 1 + internal/api/router_routes_registration.go | 1 + internal/api/security_regression_test.go | 4 +- internal/collectorlifecycle/client.go | 323 +++++++ internal/collectorlifecycle/client_test.go | 360 ++++++++ .../credential_owner_unix.go | 40 + .../credential_owner_unix_test.go | 86 ++ .../credential_owner_windows.go | 42 + .../test_owner_unix_test.go | 10 + .../test_owner_windows_test.go | 5 + internal/hostagent/action_runner_client.go | 150 ++- .../hostagent/action_runner_client_test.go | 315 +++++++ internal/hostagent/commands.go | 36 +- scripts/install.sh | 666 +++++++++++--- scripts/installtests/install_sh_test.go | 623 ++++++++++++- .../safe_profile_migration_test.go | 231 ++++- .../secure_runtime_systemd_lab_test.go | 422 ++++++++- .../canonical_completion_guard_test.py | 3 + .../secure_runtime_attestation_v6.py | 870 ++++++++++++++++++ .../secure_runtime_attestation_v6_test.py | 512 +++++++++++ .../secure_runtime_source_manifest_v6.json | 78 ++ .../release_control/subsystem_lookup_test.py | 1 + 53 files changed, 6744 insertions(+), 377 deletions(-) create mode 100644 cmd/pulse-agent-runner/private_value_unix_test.go create mode 100644 cmd/pulse-agent/collector_lifecycle.go create mode 100644 cmd/pulse-agent/collector_lifecycle_test.go create mode 100644 cmd/pulse-agent/collector_lifecycle_test_owner_unix_test.go create mode 100644 cmd/pulse-agent/collector_lifecycle_test_owner_windows_test.go create mode 100644 internal/collectorlifecycle/client.go create mode 100644 internal/collectorlifecycle/client_test.go create mode 100644 internal/collectorlifecycle/credential_owner_unix.go create mode 100644 internal/collectorlifecycle/credential_owner_unix_test.go create mode 100644 internal/collectorlifecycle/credential_owner_windows.go create mode 100644 internal/collectorlifecycle/test_owner_unix_test.go create mode 100644 internal/collectorlifecycle/test_owner_windows_test.go create mode 100644 scripts/release_control/secure_runtime_attestation_v6.py create mode 100644 scripts/release_control/secure_runtime_attestation_v6_test.py create mode 100644 scripts/release_control/secure_runtime_source_manifest_v6.json diff --git a/.gitignore b/.gitignore index 321ee3a12..79d2efab0 100644 --- a/.gitignore +++ b/.gitignore @@ -265,6 +265,9 @@ scripts/release_control/* !scripts/release_control/record_rc_to_ga_rehearsal.py !scripts/release_control/secure_runtime_attestation.py !scripts/release_control/secure_runtime_attestation_test.py +!scripts/release_control/secure_runtime_attestation_v6.py +!scripts/release_control/secure_runtime_attestation_v6_test.py +!scripts/release_control/secure_runtime_source_manifest_v6.json !scripts/release_control/staged_commit_shape_guard.py !scripts/release_control/staged_commit_shape_guard_test.py !scripts/release_control/internal/ diff --git a/cmd/pulse-agent-runner/main.go b/cmd/pulse-agent-runner/main.go index f00576f2e..e3ed69199 100644 --- a/cmd/pulse-agent-runner/main.go +++ b/cmd/pulse-agent-runner/main.go @@ -3,18 +3,23 @@ package main import ( "context" "errors" + "flag" "fmt" "net" - "net/url" "os" "os/signal" + "os/user" "path/filepath" "runtime" + "strconv" "strings" "syscall" + "time" "github.com/rcourtman/pulse-go-rewrite/internal/actionrunner" + "github.com/rcourtman/pulse-go-rewrite/internal/collectorlifecycle" "github.com/rcourtman/pulse-go-rewrite/internal/dockeragent" + "github.com/rcourtman/pulse-go-rewrite/internal/securityutil" "github.com/rs/zerolog" ) @@ -61,9 +66,18 @@ func loadConfig() (runtimeConfig, error) { } config.Hostname = hostname } - parsed, err := url.Parse(config.PulseURL) - if err != nil || parsed.Host == "" || (parsed.Scheme != "https" && !(config.Insecure && parsed.Scheme == "http")) { - return runtimeConfig{}, errors.New("PULSE_URL must be HTTPS unless PULSE_INSECURE=true explicitly permits HTTP") + if err := actionrunner.ValidatePulseURL(config.PulseURL); err != nil { + return runtimeConfig{}, fmt.Errorf("PULSE_URL must use HTTPS or an exact literal loopback host: %w", err) + } + normalizedURL, err := securityutil.NormalizePulseHTTPBaseURL(config.PulseURL) + if err != nil { + return runtimeConfig{}, fmt.Errorf("PULSE_URL must use HTTPS except for loopback local use: %w", err) + } + if normalizedURL.Scheme == "https" && config.Insecure && config.ServerFingerprint == "" { + if config.CAFile == "" { + return runtimeConfig{}, errors.New("generic insecure HTTPS is forbidden for the action runner; configure a trusted CA or exact server fingerprint") + } + config.Insecure = false } if _, err := readPrivateValue(config.TokenFile, "runner token"); err != nil { return runtimeConfig{}, err @@ -134,11 +148,66 @@ func run() error { return nil } +func runCredentialLifecycleCommand(command string, args []string) error { + flags := flag.NewFlagSet(command, flag.ContinueOnError) + flags.SetOutput(os.Stderr) + pulseURL := flags.String("url", "", "Pulse server URL") + tokenFile := flags.String("token-file", "", "private runner token file") + agentID := flags.String("agent-id", "", "bound runner agent identity") + hostname := flags.String("hostname", "", "bound runner hostname") + caFile := flags.String("cacert", "", "custom CA certificate") + serverFingerprint := flags.String("server-fingerprint", "", "pinned server certificate fingerprint") + insecureLoopback := flags.Bool("insecure-loopback", false, "allow plaintext only for a loopback Pulse URL") + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 0 || strings.TrimSpace(*pulseURL) == "" || strings.TrimSpace(*tokenFile) == "" { + return errors.New("lifecycle command requires --url and --token-file") + } + normalizedHostname := "" + if command == "revoke-credential" { + if !actionrunner.IsValidBoundedID(strings.TrimSpace(*agentID)) { + return errors.New("revoke command requires --agent-id and --hostname") + } + var err error + normalizedHostname, err = normalizeRunnerHostname(*hostname) + if err != nil { + return err + } + } + normalizedURL, err := securityutil.NormalizePulseHTTPBaseURL(*pulseURL) + if err != nil { + return fmt.Errorf("action-runner lifecycle URL: %w", err) + } + if *insecureLoopback && normalizedURL.Scheme != "http" { + return errors.New("--insecure-loopback is valid only for a loopback HTTP URL") + } + token, err := readPrivateValue(*tokenFile, "runner token") + if err != nil { + return err + } + config := actionrunner.CredentialLifecycleConfig{ + PulseURL: normalizedURL.String(), APIToken: token, + InsecureSkipVerify: *insecureLoopback, CACertPath: strings.TrimSpace(*caFile), + ServerFingerprint: strings.TrimSpace(*serverFingerprint), + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + switch command { + case "cancel-pending-credential": + return actionrunner.CancelPendingCredential(ctx, config) + case "revoke-credential": + return actionrunner.RevokeCredential(ctx, config, strings.TrimSpace(*agentID), normalizedHostname) + default: + return fmt.Errorf("unknown action-runner command %q", command) + } +} + func resolveRunnerAgentID(config runtimeConfig) (string, error) { if config.AgentID != "" { return config.AgentID, nil } - agentID, err := readPrivateValue(config.AgentIDFile, "runner agent identity") + agentID, err := collectorlifecycle.ReadAgentIDFile(config.AgentIDFile, dedicatedCollectorUID()) if err != nil { return "", err } @@ -148,6 +217,18 @@ func resolveRunnerAgentID(config runtimeConfig) (string, error) { return agentID, nil } +func dedicatedCollectorUID() *uint64 { + account, err := user.Lookup("pulse-agent") + if err != nil || account == nil { + return nil + } + uid, err := strconv.ParseUint(account.Uid, 10, 32) + if err != nil { + return nil + } + return &uid +} + func normalizeRunnerHostname(value string) (string, error) { hostname := strings.ToLower(strings.TrimSpace(value)) hostname = strings.TrimRight(hostname, ".") @@ -175,22 +256,21 @@ func runnerHostnameLabelByte(value byte) bool { } func readPrivateValue(path, label string) (string, error) { - info, err := os.Lstat(path) + value, err := collectorlifecycle.ReadPrivateValueFile(path, nil) if err != nil { return "", fmt.Errorf("%s file: %w", label, err) } - if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 { - return "", fmt.Errorf("%s file must be regular and inaccessible to group/other", label) - } - value, err := os.ReadFile(path) - if err != nil || strings.TrimSpace(string(value)) == "" { - return "", fmt.Errorf("%s file is unreadable or empty", label) - } - return strings.TrimSpace(string(value)), nil + return value, nil } func main() { - if err := run(); err != nil { + var err error + if len(os.Args) > 1 && (os.Args[1] == "cancel-pending-credential" || os.Args[1] == "revoke-credential") { + err = runCredentialLifecycleCommand(os.Args[1], os.Args[2:]) + } else { + err = run() + } + if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } diff --git a/cmd/pulse-agent-runner/main_test.go b/cmd/pulse-agent-runner/main_test.go index e46c6a0cb..56071f4c7 100644 --- a/cmd/pulse-agent-runner/main_test.go +++ b/cmd/pulse-agent-runner/main_test.go @@ -78,7 +78,7 @@ func TestResolveRunnerAgentIDRejectsInvalidFileIdentity(t *testing.T) { if err := os.WriteFile(path, []byte("bad agent\n"), 0600); err != nil { t.Fatal(err) } - if _, err := resolveRunnerAgentID(runtimeConfig{AgentIDFile: path}); err == nil || !strings.Contains(err.Error(), "valid agent identity") { + if _, err := resolveRunnerAgentID(runtimeConfig{AgentIDFile: path}); err == nil || !strings.Contains(err.Error(), "identity file is invalid") { t.Fatalf("resolveRunnerAgentID error = %v", err) } } @@ -146,3 +146,54 @@ func TestLoadConfigRejectsTokenInArgvEquivalentAndInsecureHTTPByDefault(t *testi t.Fatalf("error = %v", err) } } + +func TestLoadConfigPlaintextPolicyIsLoopbackOnlyEvenWhenInsecure(t *testing.T) { + for _, rawURL := range []string{"http://pulse.example.com:7655", "http://192.168.1.20:7655", "http://agent.localhost:7655"} { + t.Run(rawURL, func(t *testing.T) { + dir := t.TempDir() + t.Setenv("PULSE_URL", rawURL) + t.Setenv("PULSE_AGENT_RUNNER_TOKEN_FILE", filepath.Join(dir, "token")) + t.Setenv("PULSE_AGENT_RUNNER_STATE_DIR", filepath.Join(dir, "state")) + t.Setenv("PULSE_AGENT_RUNNER_HEALTH_FILE", filepath.Join(dir, "state", "health.json")) + t.Setenv("PULSE_AGENT_RUNNER_AGENT_ID", "agent-1") + t.Setenv("PULSE_AGENT_RUNNER_ACTIVATION_NONCE", strings.Repeat("f", 32)) + t.Setenv("PULSE_INSECURE", "true") + if _, err := loadConfig(); err == nil || !strings.Contains(err.Error(), "loopback") { + t.Fatalf("loadConfig(%q) error = %v, want non-loopback plaintext rejection", rawURL, err) + } + }) + } + t.Run("generic insecure HTTPS rejected", func(t *testing.T) { + dir := t.TempDir() + token := filepath.Join(dir, "token") + if err := os.WriteFile(token, []byte("runner-secret\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("PULSE_URL", "https://pulse.example") + t.Setenv("PULSE_AGENT_RUNNER_TOKEN_FILE", token) + t.Setenv("PULSE_AGENT_RUNNER_STATE_DIR", filepath.Join(dir, "state")) + t.Setenv("PULSE_AGENT_RUNNER_HEALTH_FILE", filepath.Join(dir, "state", "health.json")) + t.Setenv("PULSE_AGENT_RUNNER_AGENT_ID", "agent-1") + t.Setenv("PULSE_AGENT_RUNNER_ACTIVATION_NONCE", strings.Repeat("f", 32)) + t.Setenv("PULSE_INSECURE", "true") + if _, err := loadConfig(); err == nil || !strings.Contains(err.Error(), "generic insecure HTTPS") { + t.Fatalf("generic insecure HTTPS error = %v", err) + } + }) + + dir := t.TempDir() + token := filepath.Join(dir, "token") + if err := os.WriteFile(token, []byte("runner-secret\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("PULSE_URL", "http://127.0.0.1:7655") + t.Setenv("PULSE_AGENT_RUNNER_TOKEN_FILE", token) + t.Setenv("PULSE_AGENT_RUNNER_STATE_DIR", filepath.Join(dir, "state")) + t.Setenv("PULSE_AGENT_RUNNER_HEALTH_FILE", filepath.Join(dir, "state", "health.json")) + t.Setenv("PULSE_AGENT_RUNNER_AGENT_ID", "agent-1") + t.Setenv("PULSE_AGENT_RUNNER_ACTIVATION_NONCE", strings.Repeat("f", 32)) + t.Setenv("PULSE_INSECURE", "true") + if _, err := loadConfig(); err != nil { + t.Fatalf("loopback HTTP config rejected: %v", err) + } +} diff --git a/cmd/pulse-agent-runner/private_value_unix_test.go b/cmd/pulse-agent-runner/private_value_unix_test.go new file mode 100644 index 000000000..5f0692c2a --- /dev/null +++ b/cmd/pulse-agent-runner/private_value_unix_test.go @@ -0,0 +1,25 @@ +//go:build !windows + +package main + +import ( + "path/filepath" + "testing" + "time" + + "golang.org/x/sys/unix" +) + +func TestReadPrivateValueRejectsFIFOWithoutBlocking(t *testing.T) { + path := filepath.Join(t.TempDir(), "runner.token") + if err := unix.Mkfifo(path, 0o600); err != nil { + t.Fatal(err) + } + started := time.Now() + if _, err := readPrivateValue(path, "runner token"); err == nil { + t.Fatal("FIFO was accepted as a private runner value") + } + if elapsed := time.Since(started); elapsed > time.Second { + t.Fatalf("FIFO rejection blocked for %s", elapsed) + } +} diff --git a/cmd/pulse-agent/collector_lifecycle.go b/cmd/pulse-agent/collector_lifecycle.go new file mode 100644 index 000000000..cd371b9dd --- /dev/null +++ b/cmd/pulse-agent/collector_lifecycle.go @@ -0,0 +1,113 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "strconv" + "strings" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/collectorlifecycle" +) + +const ( + collectorReduceAuthorityCommand = "collector-reduce-authority" + collectorVerifyRegistrationCommand = "collector-verify-registration" + collectorReadAgentIDCommand = "collector-read-agent-id" +) + +func isCollectorLifecycleCommand(command string) bool { + return command == collectorReduceAuthorityCommand || command == collectorVerifyRegistrationCommand || command == collectorReadAgentIDCommand +} + +func runCollectorLifecycleCommand(ctx context.Context, command string, args []string, stdout, stderr io.Writer) error { + flags := flag.NewFlagSet(command, flag.ContinueOnError) + flags.SetOutput(stderr) + pulseURL := flags.String("url", "", "Pulse server URL") + tokenFile := flags.String("token-file", "", "private collector token file") + agentIDFile := flags.String("agent-id-file", "", "private collector agent identity file") + tokenOwnerUID := flags.String("token-owner-uid", "", "dedicated collector UID allowed to own the token file") + agentID := flags.String("agent-id", "", "collector agent identity") + hostname := flags.String("hostname", "", "collector hostname") + caFile := flags.String("cacert", "", "custom CA certificate bundle") + serverFingerprint := flags.String("server-fingerprint", "", "exact Pulse server leaf certificate SHA-256 fingerprint") + previousLastSeen := flags.String("previous-last-seen", "", "registration timestamp that the replacement must advance") + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 0 { + return errors.New("collector lifecycle command received unexpected positional arguments") + } + var allowedTokenOwnerUID *uint64 + if raw := strings.TrimSpace(*tokenOwnerUID); raw != "" { + parsed, parseErr := strconv.ParseUint(raw, 10, 32) + if parseErr != nil { + return fmt.Errorf("parse --token-owner-uid: %w", parseErr) + } + allowedTokenOwnerUID = &parsed + } + if command == collectorReadAgentIDCommand { + if strings.TrimSpace(*agentIDFile) == "" { + return errors.New("collector-read-agent-id requires --agent-id-file") + } + identity, err := collectorlifecycle.ReadAgentIDFile(*agentIDFile, allowedTokenOwnerUID) + if err != nil { + return err + } + _, err = fmt.Fprintln(stdout, identity) + return err + } + if strings.TrimSpace(*pulseURL) == "" || strings.TrimSpace(*tokenFile) == "" { + return errors.New("collector lifecycle network command requires --url and --token-file") + } + client, err := collectorlifecycle.New(collectorlifecycle.Config{ + PulseURL: *pulseURL, + TokenFile: *tokenFile, + TokenOwnerUID: allowedTokenOwnerUID, + CACertPath: *caFile, + ServerFingerprint: *serverFingerprint, + }) + if err != nil { + return err + } + defer client.Close() + + requestCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + switch command { + case collectorReduceAuthorityCommand: + if strings.TrimSpace(*previousLastSeen) != "" { + return errors.New("--previous-last-seen is valid only for registration verification") + } + return client.ReduceAuthority(requestCtx, *agentID, *hostname) + case collectorVerifyRegistrationCommand: + var previous time.Time + if raw := strings.TrimSpace(*previousLastSeen); raw != "" { + previous, err = time.Parse(time.RFC3339Nano, raw) + if err != nil { + return fmt.Errorf("parse --previous-last-seen: %w", err) + } + } + registration, err := client.VerifyRegistration(requestCtx, *agentID, *hostname, previous) + if err != nil { + return err + } + _, err = fmt.Fprintln(stdout, registration.LastSeen.Format(time.RFC3339Nano)) + return err + default: + return fmt.Errorf("unknown collector lifecycle command %q", command) + } +} + +func collectorLifecycleExitCode(err error) int { + if err == nil { + return 0 + } + if errors.Is(err, collectorlifecycle.ErrCredentialRejected) { + return 2 + } + return 1 +} diff --git a/cmd/pulse-agent/collector_lifecycle_test.go b/cmd/pulse-agent/collector_lifecycle_test.go new file mode 100644 index 000000000..2efbc1715 --- /dev/null +++ b/cmd/pulse-agent/collector_lifecycle_test.go @@ -0,0 +1,116 @@ +package main + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/collectorlifecycle" +) + +func TestCollectorLifecycleCommandReducesAuthorityWithFileBearer(t *testing.T) { + const bearer = "file-only-collector-bearer" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPost || request.URL.Path != "/api/agents/collector/reduce-authority" { + t.Errorf("request = %s %s", request.Method, request.URL.Path) + } + if got := request.Header.Get("Authorization"); got != "Bearer "+bearer { + t.Errorf("Authorization = %q", got) + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + tokenFile := writeCollectorLifecycleToken(t, bearer) + var stdout, stderr bytes.Buffer + err := runCollectorLifecycleCommand(context.Background(), collectorReduceAuthorityCommand, []string{ + "--url", server.URL, + "--token-file", tokenFile, + "--token-owner-uid", collectorLifecycleTestOwnerUID(), + "--agent-id", "agent-1", + "--hostname", "host.local", + }, &stdout, &stderr) + if err != nil { + t.Fatalf("runCollectorLifecycleCommand: %v (stderr %q)", err, stderr.String()) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } +} + +func TestCollectorLifecycleCommandRejectsBearerArgument(t *testing.T) { + const bearer = "must-not-enter-argv" + var stdout, stderr bytes.Buffer + err := runCollectorLifecycleCommand(context.Background(), collectorReduceAuthorityCommand, []string{ + "--url", "http://127.0.0.1:7655", + "--token", bearer, + "--agent-id", "agent-1", + "--hostname", "host.local", + }, &stdout, &stderr) + if err == nil || !strings.Contains(err.Error(), "flag provided but not defined: -token") { + t.Fatalf("error = %v, want raw bearer flag rejection", err) + } +} + +func TestCollectorLifecycleCommandPrintsAuthoritativeLastSeen(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"agent":{"id":"agent-1","hostname":"host.local","lastSeen":"2026-08-30T12:00:01.123456789Z"}}`)) + })) + defer server.Close() + var stdout, stderr bytes.Buffer + err := runCollectorLifecycleCommand(context.Background(), collectorVerifyRegistrationCommand, []string{ + "--url", server.URL, + "--token-file", writeCollectorLifecycleToken(t, "collector-bearer"), + "--token-owner-uid", collectorLifecycleTestOwnerUID(), + "--agent-id", "agent-1", + "--previous-last-seen", "2026-08-30T12:00:01Z", + }, &stdout, &stderr) + if err != nil { + t.Fatalf("runCollectorLifecycleCommand: %v (stderr %q)", err, stderr.String()) + } + if got := strings.TrimSpace(stdout.String()); got != "2026-08-30T12:00:01.123456789Z" { + t.Fatalf("stdout = %q", got) + } +} + +func TestCollectorLifecycleCommandSafelyReadsAgentIdentity(t *testing.T) { + identityFile := writeCollectorLifecycleToken(t, "agent-file-bound") + var stdout, stderr bytes.Buffer + err := runCollectorLifecycleCommand(context.Background(), collectorReadAgentIDCommand, []string{ + "--agent-id-file", identityFile, + "--token-owner-uid", collectorLifecycleTestOwnerUID(), + }, &stdout, &stderr) + if err != nil { + t.Fatalf("runCollectorLifecycleCommand: %v (stderr %q)", err, stderr.String()) + } + if got := strings.TrimSpace(stdout.String()); got != "agent-file-bound" { + t.Fatalf("stdout = %q", got) + } +} + +func TestCollectorLifecycleCommandExitCodeDistinguishesRejectedCredential(t *testing.T) { + if got := collectorLifecycleExitCode(nil); got != 0 { + t.Fatalf("nil exit code = %d", got) + } + if got := collectorLifecycleExitCode(collectorlifecycle.ErrRegistrationPending); got != 1 { + t.Fatalf("pending exit code = %d", got) + } + if got := collectorLifecycleExitCode(errors.Join(errors.New("lookup failed"), collectorlifecycle.ErrCredentialRejected)); got != 2 { + t.Fatalf("rejected exit code = %d", got) + } +} + +func writeCollectorLifecycleToken(t *testing.T, bearer string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "collector.token") + if err := os.WriteFile(path, []byte(bearer), 0600); err != nil { + t.Fatal(err) + } + return path +} diff --git a/cmd/pulse-agent/collector_lifecycle_test_owner_unix_test.go b/cmd/pulse-agent/collector_lifecycle_test_owner_unix_test.go new file mode 100644 index 000000000..cc8c680b0 --- /dev/null +++ b/cmd/pulse-agent/collector_lifecycle_test_owner_unix_test.go @@ -0,0 +1,10 @@ +//go:build !windows + +package main + +import ( + "os" + "strconv" +) + +func collectorLifecycleTestOwnerUID() string { return strconv.Itoa(os.Geteuid()) } diff --git a/cmd/pulse-agent/collector_lifecycle_test_owner_windows_test.go b/cmd/pulse-agent/collector_lifecycle_test_owner_windows_test.go new file mode 100644 index 000000000..f4557cf9e --- /dev/null +++ b/cmd/pulse-agent/collector_lifecycle_test_owner_windows_test.go @@ -0,0 +1,5 @@ +//go:build windows + +package main + +func collectorLifecycleTestOwnerUID() string { return "" } diff --git a/cmd/pulse-agent/main.go b/cmd/pulse-agent/main.go index b5d4f98ef..d5c6a2043 100644 --- a/cmd/pulse-agent/main.go +++ b/cmd/pulse-agent/main.go @@ -258,6 +258,16 @@ func (m *multiValue) Set(value string) error { func main() { ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() + if len(os.Args) > 1 && isCollectorLifecycleCommand(os.Args[1]) { + err := runCollectorLifecycleCommand(ctx, os.Args[1], os.Args[2:], os.Stdout, os.Stderr) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + } + if code := collectorLifecycleExitCode(err); code != 0 { + os.Exit(code) + } + return + } if err := run(ctx, os.Args[1:], os.Getenv); err != nil { if err == flag.ErrHelp { diff --git a/docs/AGENT_SECURITY.md b/docs/AGENT_SECURITY.md index d3e8aa818..35f6a77ad 100644 --- a/docs/AGENT_SECURITY.md +++ b/docs/AGENT_SECURITY.md @@ -164,17 +164,50 @@ collector-writable direct-replacement fallback. Exceptional telemetry crosses `/run/pulse-agent/helper.sock` to a separate root process. The socket admits only the `pulse-agent` UID, and the helper has -no Pulse URL, API token, or network namespace. Its protocol exposes bounded, +no Pulse URL or API token and runs in a private network namespace rather than +the host network namespace. Its protocol exposes bounded, versioned SMART, Proxmox LXC filesystem, and fixed-endpoint container summary snapshots, not a shell, executable path, device path, VMID, daemon endpoint, environment, or caller-selected arguments. The helper service keeps `PrivateNetwork=true`, `RestrictAddressFamilies=AF_UNIX`, `NoNewPrivileges=true`, `ProtectSystem=strict`, and `ProtectHome=true`. +It also bounds the helper cgroup to `TasksMax=64`, `LimitNOFILE=256`, and +`MemoryMax=256M`. These limits leave headroom for the typed helper and its +short-lived platform tools while preventing an overridden or malformed request +from consuming unbounded process, descriptor, or memory resources. `PrivateDevices` is intentionally not enabled because SMART needs the host block devices. If the helper is missing, incompatible, or rejects a request, only the affected telemetry disappears; the collector does not fall back to sudo, root, or a broader local command path. +The installer treats the effective systemd configuration as part of the safe +profile boundary. The collector, helper service, helper socket, and an +installed action runner must load from the installer-owned `FragmentPath` with +no `DropInPaths`. Their effective executable, identity, environment-file, +address-family, network, and filesystem-hardening properties must match the +rendered profile. A healthy socket or process cannot override that check. +Qualification of helper network isolation is deliberately narrower than a +claim about every Linux network path: the guarded systemd lab must first reach +a TCP canary on a non-loopback host interface from the host namespace, then +show that a process entered into the live helper's network namespace cannot +connect to the same canary. That proves the exercised helper namespace cannot +reach that host-interface endpoint. It does not by itself qualify every Linux +distribution, firewall configuration, or future systemd version. + +The committed schema-v5 qualification contract remains immutable at its +original ordered fifteen scenarios. The effective-unit, bounded-resource, and network-isolation +requirements use a separate schema-v6 receipt and source manifest, so earlier +evidence is not silently reinterpreted. A schema-v6 local lab remains +committed-main, artifact-bound self-attestation. Release-candidate +classification additionally requires the canonical remote RC tag and commit, +an immutable signed GitHub Release packet, release-attested checksums, trusted +hosted-builder SLSA provenance, and a signed build contract that binds every +qualification artifact to its package, toolchain, exact build settings and +ldflags, version, production update-key fingerprint, and release checksum. +The current release workflow does not yet emit that complete secure-runtime +build contract or the four-version qualification artifact set, so a local tag +or VCS-stamped lab binary cannot upgrade the proof to RC status. + The typed-helper profile cannot be combined with `--grant-smart` or `--grant-pct`. The collector never joins the rootful Docker group. When no collector-owned rootless socket is available, the helper preserves only diff --git a/docs/release-control/v6/internal/AGENT_PRIVILEGE_BOUNDARY_PLAN.md b/docs/release-control/v6/internal/AGENT_PRIVILEGE_BOUNDARY_PLAN.md index 8476be819..1f3072e22 100644 --- a/docs/release-control/v6/internal/AGENT_PRIVILEGE_BOUNDARY_PLAN.md +++ b/docs/release-control/v6/internal/AGENT_PRIVILEGE_BOUNDARY_PLAN.md @@ -99,6 +99,14 @@ without changing the product default: collector-owned rootless socket exists. Explicit or automatic rollback may restore the old root service identity, but it removes `--enable-commands` and cannot resurrect the reduced server-side authority; +- the safe-profile declaration is bound to effective systemd state, not unit + file contents or socket health alone. The collector, helper service, helper + socket, and installed action runner must have the installer-owned + `FragmentPath`, no `DropInPaths`, and the expected effective executable, + identity, environment-file, address-family, network, and filesystem + hardening properties. The typed helper is additionally bounded to 64 tasks, + 256 file descriptors, and 256 MiB of memory, and those effective limits are + override-tested. Existing overrides fail closed; - the root action runner is networked and host-mutating by design. Its unit retains kernel/home/control-plane hardening but does not set the host filesystem read-only, because apt and Proxmox operations require their @@ -153,6 +161,41 @@ replacement admission closes the exact predecessor socket; successful activation durably replaces its secret; restart rejects the predecessor; and self-revoke survives a second restart. That code-level transport proof is not part of the systemd receipt or an exact release-candidate exercise. The +committed schema-v5 contract added the three helper-update recovery scenarios +and remains immutable at its original ordered fifteen-scenario definition. +Schema v6 is a separate twenty-scenario contract. It adds effective helper +service, helper resource-limit, helper socket, and runner override rejection +plus the host-canary helper-network-namespace exercise, and binds those claims to +`secure_runtime_source_manifest_v6.json`. It does not reinterpret schema-v4 or +schema-v5 evidence. + +Schema-v6 release-candidate classification does not trust a local ref or Go +VCS stamp. Pulse release tags are workflow-created annotated tags rather than +signed tags, so tag presence alone is not authority. RC classification +requires the exact canonical remote tag object and peeled commit, the immutable +GitHub Release and release attestation, release-attested `checksums.txt`, the +portable hosted-assembly provenance from `build-release-candidate.yml`, a +separate provenance bundle that binds every qualification binary to +`compile-release-payload.yml` with self-hosted runners denied, and a signed +secure-runtime build contract. That contract must bind every qualification +artifact to the checksums and record both workflow identities, a +GitHub-hosted-only compiler policy, the source commit, Go toolchain, package, +target, `CGO_ENABLED`, `-trimpath`, `-buildvcs=false`, exact ldflags, version, +and production update-key fingerprint. Hosted assembly of a payload emitted by +a self-hosted compiler is not trusted compilation provenance. The current +compiler workflow is self-hosted and the release workflow does not yet publish +the compiler provenance, secure-runtime build contract, or full multi-version +qualification artifact set. Therefore release-candidate classification fails +closed, and current local lab evidence remains committed-main, artifact-bound, +and self-attested even if a local tag exists. + +Schema-v6 committed-main classification is likewise not caller-relative. The +attester accepts only `origin/main`, requires the canonical Pulse origin URL, +and compares the local remote-tracking commit with a fresh `refs/heads/main` +query before proving ancestry. `HEAD`, a local branch, or an arbitrary ref can +never receive the committed-main label. + +The repository still needs a fresh exact committed release-candidate run, representative Proxmox, SMART, Docker and rootless Podman telemetry/action parity, appliance profiles, and the external security review. Until those @@ -197,10 +240,13 @@ The collector may not: ### 3. Typed Privileged Helper A small root-owned helper provides only the privileged operations that have a -documented monitoring or update need. It has no Pulse credential and no -outbound network access. The preferred transport is a root-owned local Unix -socket with peer-credential validation and a versioned request/response -schema. +documented monitoring or update need. It has no Pulse credential and runs in a +private network namespace restricted to `AF_UNIX`. The preferred transport is +a root-owned local Unix socket with peer-credential validation and a versioned +request/response schema. A qualification receipt may claim only the exercised +network boundary: a host-reachable non-loopback TCP canary was unreachable +from the live helper namespace. Broader platform-wide non-connectivity remains +unqualified until exercised on that platform. Initial operation families are expected to be: @@ -355,7 +401,11 @@ Required proof: version and field - adversarial tests reject path traversal, argument injection, unknown ops, oversized output, timeout abuse, symlink swaps, and unauthorized peers -- process/network proof shows the helper cannot make outbound connections +- process/network proof first reaches a non-loopback host-interface TCP canary + from the host namespace, then enters the live helper process's network + namespace and proves that the same connection is denied. This is evidence + for the exercised helper namespace and endpoint, not a universal claim about + every Linux distribution, firewall, address family, or future systemd build - telemetry parity tests compare the safe profile with the current root baseline and classify every intentional difference - update tests prove signature identity, atomic activation, restart, health diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index cfbd08bb4..f7946b181 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -116,22 +116,55 @@ identity file is present. Issuance and every runner boundary use the same bounded action-identity vocabulary. Credential rotation is a prepare/commit transaction. Issuance stores a ten-minute pending replacement beside the active predecessor; the pending transport may register -but is not dispatchable. The runner first durably replaces a private health -marker carrying the current installer-generated activation nonce, then calls -the authenticated activation method. Activation atomically removes the exact -predecessor set, removes the replacement expiry/pending state, and promotes the -exact registered session. Failure before that commit leaves the predecessor -valid, while persistence failure restores both records. The installer removes +but is staged in a separate bounded session slot and is not dispatchable. +Pending reconnect or flood traffic may replace only that pending slot; the +active predecessor remains connected and dispatchable until commit. The runner +first durably replaces a private pending +health marker carrying the current installer-generated activation nonce, then +calls the authenticated activation method. Activation atomically removes the +exact predecessor set, removes the replacement expiry/pending state, and +promotes the exact registered pending session while the token inventory lock +serializes both decisions. The server persists the activated inventory before +the bounded session-map swap and closes the displaced predecessor transport +only after both locks are released. If the exact pending transport vanished or +was replaced, the server durably restores the prior inventory and returns +conflict; if that compensating persistence fails, memory remains aligned with +the last known durable activated inventory and the response is indeterminate, +never a false success. Only after a successful response may the runner replace the +marker with activated state. Failure before that commit leaves the predecessor +valid, while initial persistence failure or a successful compensating save +restores both records. Installer rollback is +authorized only by a bodyless, self-only cancellation that durably removes the +exact still-pending replacement under the same inventory lock as activation. A +committed replacement returns conflict; persistence or admission-tombstone +uncertainty is never rollback authority. The installer removes the prior marker before restart, ignores wall-clock mtime as authority, and accepts only an activated marker whose nonce and canonical agent ID match the -current attempt; rollback never restores a prior marker. An already-started +current attempt; rollback never restores a prior marker. If readiness fails, +the installer stops the replacement and restores the predecessor only after +that atomic cancellation succeeds. Activation conflict, transport/TLS failure, +persistence failure, or indeterminate cancellation retains the replacement +with an explicit +repair-required result, because restoring the potentially revoked predecessor +would create a credential/runtime split. Before restart or predecessor-backup +removal, that path atomically and durably rewrites the exact requested +replacement bearer to the root-owned token file; failure leaves the runner +stopped, does not restore the predecessor, and requires re-enrollment. An +already-started host mutation remains governed by its typed receipt and best-effort cancellation semantics rather than being described as rolled back. Runner -uninstall attempts an authenticated -credential self-revoke before deleting local state. The delete route may -remove only the caller's exact host-bound action-runner record; an unreachable -server cannot prevent local runner removal and leaves an explicit operator -revocation residual. +uninstall stops and disables the runner, then requires an authenticated durable +credential self-revoke before deleting any local artifact. The delete route may +remove only the caller's exact host-bound action-runner record. An unreachable +or untrusted server, missing credential, or persistence failure retains all +root-only recovery material for retry or manual server-side revocation. +Runner WS registration and activation, cancellation, and self-revocation HTTP +calls require HTTPS/WSS with normal trust, a configured CA, or an enforced +certificate-DER fingerprint. Plaintext HTTP/WS accepts only exact `localhost`, +`127/8`, or `::1` literals (not `*.localhost`) and never +inherits generic installer insecure or curl `-k` behavior. Bearer-bearing +activation, cancellation, and self-revocation clients connect directly and do +not inherit ambient HTTP proxy variables. Runner readiness is exposed through a bounded, secret-free health marker that is replaced atomically only after its contents reach stable storage. The marker distinguishes registered/pending from activated and carries only the @@ -375,9 +408,11 @@ installer download and the agent's subsequent Pulse TLS connection. 5i. `internal/agenthelper/` 5j. `internal/actionrunner/` 5k. `internal/dockeragent/action_runtime.go` + 5l. `internal/collectorlifecycle/` 6. `cmd/pulse-agent/main.go` 6a. `cmd/pulse-agent-helper/main.go` 6b. `cmd/pulse-agent-runner/main.go` + 6c. `cmd/pulse-agent/collector_lifecycle.go` 7. `scripts/install.sh` 8. `scripts/install.ps1` 8a. `.github/workflows/unified-agent-native.yml` @@ -1541,6 +1576,17 @@ instead of lifecycle-local centered icon/text shells. ## Extension Points +Safe-profile installation and migration invoke the private lifecycle commands +in `cmd/pulse-agent/collector_lifecycle.go`, backed by +`internal/collectorlifecycle/`, for collector authority reduction and +authoritative registration proof. These commands are installer-only lifecycle +boundaries: they read the bearer from one bounded, no-follow descriptor owned +by root or the configured collector UID; use system/custom CA trust or exact +DER pinning; reject redirects and ambient proxies; and permit plaintext only +for literal loopback hosts. Any new bearer-bearing collector transition must +extend this client and its installer negative tests instead of adding a curl +or generic insecure-transport path. + The authenticated runtime-display projection under shared `internal/api/` may carry the effective global `disableDockerUpdateActions` boolean so non-admin viewers render container updates read-only. This is API/settings presentation diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 65aa92b2a..3bbd7f4e2 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -53,12 +53,25 @@ deadline, not predecessor secrets. commit. It requires the current `agent:exec` bearer, exact organization, canonical agent/hostname binding, and an exact registered pending transport. A pending transport is visible to this commit proof but unavailable to action -dispatch. The commit clears the pending expiry, durably removes only the -server-recorded predecessor IDs, restores the complete inventory on persistence -failure, promotes the exact replacement session, and invalidates exact stale -sessions without allowing a caller-selected token ID. Repeating activation for -an already active exact session is idempotent so a lost HTTP response can be -reconciled safely. +dispatch and cannot evict or interrupt the active predecessor. The commit +requires durable token persistence, clears the pending expiry, removes only the +server-recorded predecessor IDs, and atomically promotes the exact replacement +session while invalidating exact stale sessions without allowing a +caller-selected token ID. Persistence or promotion failure preserves one +coherent durable inventory/session outcome; the route never reports success for +an in-memory-only activation or a vanished pending connection. Repeating +activation for an already active exact session is idempotent so a lost HTTP +response can be reconciled safely. + +`DELETE /api/agents/action-runner/credential/activation` is the only +rollback-authorizing cancellation boundary for an installer-held replacement. +It accepts no request body, authenticates the exact pending replacement bearer, +and serializes with activation under the same durable token-inventory lock. A +successful `204` durably removes that replacement and tombstones its exact +admission so a pre-admitted or late transport cannot commit afterward. If +activation has already won, the route returns conflict; persistence failure or +any indeterminate state returns an error and must never authorize restoring the +predecessor credential or files. `DELETE /api/agents/action-runner/credential` is the runner's narrowly scoped self-revoke operation. It requires the current `agent:exec` bearer credential, diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index a04d3e39c..0b8d71dc3 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -148,11 +148,29 @@ and the already selected typed helper profile. The runner binary, unit, configuration, credential, health record, and receipt database are root-owned and independent from collector state. Activation is transactional: the server keeps the prior credential valid while a bounded replacement registers without -dispatch authority; the runner durably writes an installer-nonce-bound health -marker and commits activation before the installer removes backups. The +dispatch authority in a separate pending session slot. Pending reconnects can +replace only that slot and cannot evict or interrupt the active dispatch +transport. The runner durably writes an installer-nonce-bound health +marker in pending state, commits activation, and then replaces the marker with +activated state before the installer removes backups. Commit durably writes +the activated token inventory and, under the same inventory transaction, +performs a bounded exact pending-to-active session-map swap; displaced-socket +cleanup happens only after transaction locks are released. A missing or +superseded pending transport causes a durable inventory rollback and conflict. +If that compensating save fails, memory follows the last known durable active +inventory and activation is reported indeterminate so recovery retains repair +material rather than claiming success. The installer deletes the prior marker before restart, does not trust filesystem -timestamps, never restores a prior marker, and restores the previous runner-only -files if the current nonce never reaches activated state. Disable and uninstall +timestamps, and never restores a prior marker. If readiness fails, it stops the +replacement and restores previous runner-only files only when a bodyless self- +cancellation durably removes the exact pending replacement under the activation +transaction lock. Activation conflict, transport/TLS failure, persistence +failure, or admission-fence uncertainty retains the new credential/runtime and returns a +repair-required failure rather than restoring a potentially revoked secret. It +must first atomically and durably restore the exact requested replacement +bearer to the root-only token file; if that write fails, the runner remains +stopped and re-enrollment is required without predecessor restoration. +Disable and uninstall remove only remediation and leave monitoring running. The action credential is never placed in argv or reused as the collector token. The installer persists the canonical enrollment hostname for runner admission. If @@ -163,10 +181,13 @@ file remains the fallback for later-generated IDs. The direct binding takes precedence consistently during runner startup, activation health, and self-revocation, and issuance rejects identities outside the runner's bounded action-identity vocabulary. The installer -uses a private curl configuration for best-effort exact self-revocation before -local teardown, so the bearer secret does not enter argv. Server unreachability -is warned explicitly but does not make local removal depend on the remote -control plane. +uses the Go lifecycle client with a root-only token file for exact durable self- +revocation before local teardown, so the bearer secret does not enter argv. If +any runner artifact exists, server unreachability, TLS/auth failure, or missing +credential stops and disables the runner but retains every artifact for retry or +manual server-side revocation. Runner WS and lifecycle HTTP require HTTPS/WSS +with system/custom CA trust or exact certificate-DER pinning; plaintext is +loopback-only and generic installer insecure/curl `-k` is never inherited. The runner must retain a writable host filesystem because its closed protocol performs real package, storage, guest, and container mutations; `ProtectSystem=strict` is therefore forbidden for that unit. This exception diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 108ff1770..b2a631a33 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -1299,6 +1299,7 @@ "internal/agenthelper/", "internal/agenttarget/", "internal/agentupdate/", + "internal/collectorlifecycle/", "internal/hostagent/", "scripts/intelligence_lab/" ], @@ -1306,6 +1307,7 @@ ".github/workflows/unified-agent-native.yml", "cmd/pulse-agent-helper/main.go", "cmd/pulse-agent-runner/main.go", + "cmd/pulse-agent/collector_lifecycle.go", "cmd/pulse-agent/main.go", "cmd/pulse-agent/service_windows.go", "frontend-modern/src/api/agentProfiles.ts", @@ -1394,6 +1396,25 @@ "exact_files": [], "require_explicit_path_policy_coverage": true, "path_policies": [ + { + "id": "collector-lifecycle-installer-transport", + "label": "authenticated collector authority transition and registration proof", + "match_prefixes": [ + "internal/collectorlifecycle/" + ], + "match_files": [ + "cmd/pulse-agent/collector_lifecycle.go" + ], + "allow_same_subsystem_tests": true, + "test_prefixes": [ + "internal/collectorlifecycle/" + ], + "exact_files": [ + "cmd/pulse-agent/collector_lifecycle_test.go", + "internal/api/collector_authority_test.go", + "scripts/installtests/install_sh_test.go" + ] + }, { "id": "action-runner-runtime", "label": "separate typed action runner and durable receipt proof", @@ -1477,6 +1498,7 @@ "allow_same_subsystem_tests": false, "test_prefixes": [], "exact_files": [ + "cmd/pulse-agent/collector_lifecycle_test.go", "cmd/pulse-agent/main_test.go", "cmd/pulse-agent/observers_config_test.go", "scripts/installtests/agent_state_dir_lifecycle_test.go" @@ -1625,6 +1647,7 @@ "test_prefixes": [], "exact_files": [ "internal/agentupdate/coverage_test.go", + "internal/hostagent/action_runner_client_test.go", "internal/hostagent/agent_flushbuffer_test.go", "internal/hostagent/agent_metrics_test.go", "internal/hostagent/agent_new_test.go", diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index e242253f2..4553f82c5 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -97,21 +97,47 @@ older unactivated replacements. Issuance, root-owned runtime configuration, session admission, typed payloads, and receipts share one bounded action-identity vocabulary, so an unsafe or unrepresentable host identity is rejected before any action credential is minted. The pending credential may authenticate one -exact runner transport but cannot become dispatch authority. After the runner -durably records its current activation nonce, its authenticated activation -request atomically promotes the replacement and revokes the server-recorded -predecessor set. A persistence failure restores both sides of the transition; +exact runner transport in a separate bounded pending slot but cannot become +dispatch authority. Pending reconnects replace only that slot; they cannot +close or displace the active predecessor, which remains the dispatch target +until commit. After the runner +durably records its current activation nonce as pending, its authenticated +activation request durably activates the replacement and revokes the +server-recorded predecessor set, then atomically promotes the exact pending +transport under the same token-inventory transaction; displaced transport +cleanup occurs outside both locks and the local activated marker follows that +commit. If the pending transport vanished or was superseded, a compensating +durable save restores both sides of the transition and returns conflict. If the +compensating save itself fails, memory remains aligned with the last known +durable activated inventory and the response is indeterminate rather than a +false success. An initial persistence failure also restores both sides; an unactivated replacement expires without revoking the predecessor. +Installer recovery stops the replacement and may restore a predecessor only +after a bodyless self-cancellation durably removes the exact pending credential +under the activation transaction lock. Activation conflict or any transport, +TLS, persistence, or admission-tombstone uncertainty retains the new +credential/runtime and surfaces repair-required rather than reinstalling a +secret that the server may already have revoked. Retention is claimed only +after the exact requested replacement bearer is atomically and durably +reinstalled with root-only ownership; failure keeps the runner stopped and +requires re-enrollment without restoring the predecessor. That invalidation is exact and post-persistence: it matches organization, token, canonical agent, hostname, runtime role, and typed capability before closing the session, so a stale rotation cannot evict a replacement. Activation -is idempotent for the exact live session so transport-level response loss is -recoverable. The +is idempotent for an already committed exact credential so transport-level +response loss is recoverable without requiring a stale pending-session +snapshot. The runner may also delete only its own matching record using its bearer credential; browser sessions and a caller-selected token ID are rejected, and persistence failure restores the prior inventory. Installer teardown keeps the -secret in a private file/config boundary rather than argv and reports remote -revocation failure without retaining local remediation authority. +secret in a private file/config boundary rather than argv, stops and disables +the runner first, and removes no artifact until self-revocation is durably +confirmed. Runner WS and lifecycle HTTP use HTTPS/WSS with normal/custom CA +trust or exact DER pinning; plaintext accepts only exact `localhost`, `127/8`, +or `::1` literals, never resolver-controlled `*.localhost`, and curl `-k` never +carries the runner bearer. These bearer-bearing lifecycle clients also disable +ambient proxy discovery so an unconfigured `HTTP_PROXY`/`HTTPS_PROXY` cannot +receive the credential. Safe-profile migration uses the authenticated `POST /api/agents/collector/reduce-authority` transition. The server accepts only the caller's exact organization, agent, and canonical hostname; rejects @@ -348,6 +374,15 @@ with missing, unknown, or unrelated scopes fail closed. ## Extension Points +The agent-lifecycle-owned `internal/collectorlifecycle/` package and +`cmd/pulse-agent/collector_lifecycle.go` are the security boundary for +bearer-authenticated safe-profile migration calls. They must preserve direct +transport, redirect rejection, normal/custom CA or exact DER-leaf pin trust, +literal-loopback-only plaintext, and single-open bounded credential-file +validation with an explicit root-or-collector-owner matrix. Installer changes +must not replace this boundary with curl `-k`, plaintext to a non-loopback +host, proxy-aware transport, or a bearer supplied in argv. + Catalog edits in `frontend-modern/src/i18n/` that add or promote Patrol-trigger copy (such as an alert's primary "Have Patrol investigate" action) must stay non-disclosing: the manual Patrol trigger carries resource identity only — diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 7b7c30fa6..3ad026d7d 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -40,14 +40,19 @@ the runner and session protocol. That fail-closed identity result cannot select, delete, restore, or reclassify storage/recovery state. Runner credential rotation follows the shared two-phase token-inventory commit boundary: issuance durably prepares a bounded, non-dispatchable replacement -without removing the active predecessor; activation after durable runner health -proof promotes the exact registered replacement and removes only its recorded -predecessor set. Failed persistence restores the complete inventory, and an -unactivated replacement expires without changing the predecessor. Exact bearer -self-revocation uses the same durable boundary and cannot select another token, -organization, or host. That rollback protects restart-time -credential truth only; it is not a customer backup, recovery point, restore -operation, or storage-cleanup grant. +without removing or disconnecting the active predecessor; activation after +durable runner health proof requires durable token persistence and atomically +promotes the exact registered replacement while removing only its recorded +predecessor set. The installer may restore predecessor files only after the +server atomically cancels and durably removes the exact still-pending bearer; +activation-winning, persistence-failure, and indeterminate outcomes retain the +replacement instead. Failed persistence or session promotion preserves one +coherent token/session outcome, and an unactivated replacement expires without +changing the predecessor. Exact bearer self-revocation uses the same durable +boundary and cannot select another token, organization, or host. These +credential rollback rules protect restart-time authentication truth only; they +are not a customer backup, recovery point, restore operation, or +storage-cleanup grant. The adjacent collector authority-reduction route removes `agent:exec` and `agent:manage` only from the caller's exact host-bound credential and closes only its matching command session after durable persistence. This transition diff --git a/frontend-modern/public/docs/AGENT_SECURITY.md b/frontend-modern/public/docs/AGENT_SECURITY.md index d3e8aa818..35f6a77ad 100644 --- a/frontend-modern/public/docs/AGENT_SECURITY.md +++ b/frontend-modern/public/docs/AGENT_SECURITY.md @@ -164,17 +164,50 @@ collector-writable direct-replacement fallback. Exceptional telemetry crosses `/run/pulse-agent/helper.sock` to a separate root process. The socket admits only the `pulse-agent` UID, and the helper has -no Pulse URL, API token, or network namespace. Its protocol exposes bounded, +no Pulse URL or API token and runs in a private network namespace rather than +the host network namespace. Its protocol exposes bounded, versioned SMART, Proxmox LXC filesystem, and fixed-endpoint container summary snapshots, not a shell, executable path, device path, VMID, daemon endpoint, environment, or caller-selected arguments. The helper service keeps `PrivateNetwork=true`, `RestrictAddressFamilies=AF_UNIX`, `NoNewPrivileges=true`, `ProtectSystem=strict`, and `ProtectHome=true`. +It also bounds the helper cgroup to `TasksMax=64`, `LimitNOFILE=256`, and +`MemoryMax=256M`. These limits leave headroom for the typed helper and its +short-lived platform tools while preventing an overridden or malformed request +from consuming unbounded process, descriptor, or memory resources. `PrivateDevices` is intentionally not enabled because SMART needs the host block devices. If the helper is missing, incompatible, or rejects a request, only the affected telemetry disappears; the collector does not fall back to sudo, root, or a broader local command path. +The installer treats the effective systemd configuration as part of the safe +profile boundary. The collector, helper service, helper socket, and an +installed action runner must load from the installer-owned `FragmentPath` with +no `DropInPaths`. Their effective executable, identity, environment-file, +address-family, network, and filesystem-hardening properties must match the +rendered profile. A healthy socket or process cannot override that check. +Qualification of helper network isolation is deliberately narrower than a +claim about every Linux network path: the guarded systemd lab must first reach +a TCP canary on a non-loopback host interface from the host namespace, then +show that a process entered into the live helper's network namespace cannot +connect to the same canary. That proves the exercised helper namespace cannot +reach that host-interface endpoint. It does not by itself qualify every Linux +distribution, firewall configuration, or future systemd version. + +The committed schema-v5 qualification contract remains immutable at its +original ordered fifteen scenarios. The effective-unit, bounded-resource, and network-isolation +requirements use a separate schema-v6 receipt and source manifest, so earlier +evidence is not silently reinterpreted. A schema-v6 local lab remains +committed-main, artifact-bound self-attestation. Release-candidate +classification additionally requires the canonical remote RC tag and commit, +an immutable signed GitHub Release packet, release-attested checksums, trusted +hosted-builder SLSA provenance, and a signed build contract that binds every +qualification artifact to its package, toolchain, exact build settings and +ldflags, version, production update-key fingerprint, and release checksum. +The current release workflow does not yet emit that complete secure-runtime +build contract or the four-version qualification artifact set, so a local tag +or VCS-stamped lab binary cannot upgrade the proof to RC status. + The typed-helper profile cannot be combined with `--grant-smart` or `--grant-pct`. The collector never joins the rootful Docker group. When no collector-owned rootless socket is available, the helper preserves only diff --git a/internal/actionrunner/transport.go b/internal/actionrunner/transport.go index 1d6bf87f7..281d66c97 100644 --- a/internal/actionrunner/transport.go +++ b/internal/actionrunner/transport.go @@ -9,6 +9,19 @@ import ( // TransportConfig carries only the separate runner's connection credential, // state, and TLS inputs. There is deliberately no monitoring/report config. type TransportConfig = hostagent.ActionRunnerClientConfig +type CredentialLifecycleConfig = hostagent.ActionRunnerCredentialLifecycleConfig + +func ValidatePulseURL(raw string) error { + return hostagent.ValidateActionRunnerPulseURL(raw) +} + +func CancelPendingCredential(ctx context.Context, config CredentialLifecycleConfig) error { + return hostagent.CancelPendingActionRunnerCredential(ctx, config) +} + +func RevokeCredential(ctx context.Context, config CredentialLifecycleConfig, hostID, hostname string) error { + return hostagent.RevokeActionRunnerCredential(ctx, config, hostID, hostname) +} // Client is the action-runner-owned facade over the existing typed action // codecs and executors. The hostagent implementation remains an internal diff --git a/internal/agentexec/server.go b/internal/agentexec/server.go index ca51884c7..e614b95ab 100644 --- a/internal/agentexec/server.go +++ b/internal/agentexec/server.go @@ -64,6 +64,7 @@ var hostStorageCleanupFingerprintPattern = regexp.MustCompile(`^sha256:[a-f0-9]{ type Server struct { mu sync.RWMutex agents map[string]*agentConn // organizationID + agentID -> connection + pendingActionRunners map[string]*agentConn // organizationID + agentID -> exact prepared runner transport pendingReqs map[string]chan CommandResultPayload // scoped request key -> response channel pendingHostStorageCleanups map[string]chan HostStorageCleanupResultPayload // scoped request key -> typed storage-cleanup response pendingHostUpdates map[string]chan HostUpdateResultPayload // scoped request key -> typed host-update response @@ -87,6 +88,7 @@ type Server struct { newCommandApprovalGrant func([]byte, string, ExecuteCommandPayload, time.Time, time.Duration) (*CommandApprovalGrant, error) now func() time.Time agentRegisteredNotifier func(AgentAdmission) + actionRunnerAdmissionTombstones map[string]time.Time } const defaultOrganizationID = "default" @@ -197,6 +199,8 @@ func NewServerWithAdmissionValidator(admit AgentRegistrationValidator, validateS return &Server{ agents: make(map[string]*agentConn), + pendingActionRunners: make(map[string]*agentConn), + actionRunnerAdmissionTombstones: make(map[string]time.Time), pendingReqs: make(map[string]chan CommandResultPayload), pendingHostStorageCleanups: make(map[string]chan HostStorageCleanupResultPayload), pendingHostUpdates: make(map[string]chan HostUpdateResultPayload), @@ -220,6 +224,60 @@ func NewServerWithAdmissionValidator(admit AgentRegistrationValidator, validateS } } +func actionRunnerAdmissionTombstoneKey(admission AgentAdmission) string { + return strings.Join([]string{ + normalizeOrganizationID(admission.OrganizationID), + strings.TrimSpace(admission.TokenID), + strings.TrimSpace(admission.AgentID), + unifiedresources.NormalizeFullHostname(admission.Hostname), + strings.TrimSpace(admission.RuntimeRole), + strings.TrimSpace(admission.ActionCapability), + }, "\x00") +} + +// TombstoneActionRunnerAdmission prevents an already-admitted prepared socket +// from registering after its credential has been durably cancelled. The +// tombstone is exact and bounded to the preparation window. +func (s *Server) TombstoneActionRunnerAdmission(admission AgentAdmission, until time.Time) bool { + if s == nil || strings.TrimSpace(admission.TokenID) == "" || strings.TrimSpace(admission.AgentID) == "" || + strings.TrimSpace(admission.Hostname) == "" || + strings.TrimSpace(admission.RuntimeRole) != RuntimeRoleActionRunner || + strings.TrimSpace(admission.ActionCapability) != ActionCapabilityTypedV1 { + return false + } + now := time.Now() + if s.now != nil { + now = s.now() + } + maximum := now.Add(10 * time.Minute) + if !until.After(now) || until.After(maximum) { + until = maximum + } + key := actionRunnerAdmissionTombstoneKey(admission) + s.mu.Lock() + if s.actionRunnerAdmissionTombstones == nil { + s.actionRunnerAdmissionTombstones = make(map[string]time.Time) + } + for existingKey, expiry := range s.actionRunnerAdmissionTombstones { + if !expiry.After(now) { + delete(s.actionRunnerAdmissionTombstones, existingKey) + } + } + s.actionRunnerAdmissionTombstones[key] = until + sessionKey := agentSessionKey(admission.OrganizationID, admission.AgentID) + var invalidated *agentConn + if existing, ok := s.pendingActionRunners[sessionKey]; ok && actionRunnerAdmissionTombstoneKey(existing.admission) == key { + delete(s.pendingActionRunners, sessionKey) + invalidated = existing + } + s.mu.Unlock() + if invalidated != nil { + invalidated.signalDone() + _ = invalidated.conn.Close() + } + return true +} + // WithOrganizationID scopes command-session lookup and dispatch to a tenant. // Empty values normalize to the single-tenant default for compatibility. func WithOrganizationID(ctx context.Context, organizationID string) context.Context { @@ -358,13 +416,9 @@ func (s *Server) connectionForOrganization(organizationID, agentID string) (*age if !ok { return nil, false } - // A prepared action-runner credential may establish the authenticated - // transport needed to commit activation, but it is not dispatch authority. - // Promotion clears this bit only after the server durably revokes the prior - // credential set. - if ac.admission.ActivationPending { - return nil, false - } + // Prepared action runners live only in pendingActionRunners. Membership in + // the active map is the immutable dispatch-authority decision; do not read + // or mutate admission state outside the server lock during promotion. if s.validateSession == nil || s.validateSession(ac.admission) { return ac, true } @@ -393,27 +447,52 @@ func (s *Server) HasActionRunnerSession(admission AgentAdmission) bool { } key := agentSessionKey(admission.OrganizationID, admission.AgentID) s.mu.RLock() - current, ok := s.agents[key] + current, ok := s.pendingActionRunners[key] s.mu.RUnlock() - return ok && current != nil && sameActionRunnerAdmission(current.admission, admission) && - current.admission.ActivationPending == admission.ActivationPending + return ok && current != nil && admission.ActivationPending && sameActionRunnerAdmission(current.admission, admission) } -// PromoteActionRunnerSession makes an exact prepared session dispatchable -// after its credential activation has been durably committed. -func (s *Server) PromoteActionRunnerSession(admission AgentAdmission) bool { +// PromoteActionRunnerSessionForCommit performs only the bounded map mutation +// needed by the credential transaction. Callers may invoke it while holding +// config.Mu; they must run the returned cleanup only after releasing that lock. +// This preserves the sole nested order config.Mu -> Server.mu and keeps socket +// close/logging I/O outside both locks. +func (s *Server) PromoteActionRunnerSessionForCommit(admission AgentAdmission) (func(), bool) { if s == nil { - return false + return nil, false } key := agentSessionKey(admission.OrganizationID, admission.AgentID) s.mu.Lock() - defer s.mu.Unlock() - current, ok := s.agents[key] - if !ok || current == nil || !sameActionRunnerAdmission(current.admission, admission) { - return false + pending, ok := s.pendingActionRunners[key] + if !ok || pending == nil || !admission.ActivationPending || !sameActionRunnerAdmission(pending.admission, admission) { + s.mu.Unlock() + return nil, false } - current.admission.ActivationPending = false - return true + delete(s.pendingActionRunners, key) + displaced := s.agents[key] + s.agents[key] = pending + s.mu.Unlock() + var cleanup func() + if displaced != nil && displaced != pending { + cleanup = func() { + displaced.signalDone() + if displaced.conn != nil { + _ = displaced.conn.Close() + } + } + } + return cleanup, true +} + +// PromoteActionRunnerSession is the non-transactional compatibility wrapper. +// Production activation uses PromoteActionRunnerSessionForCommit and defers +// cleanup until after config.Mu has been released. +func (s *Server) PromoteActionRunnerSession(admission AgentAdmission) bool { + cleanup, promoted := s.PromoteActionRunnerSessionForCommit(admission) + if cleanup != nil { + cleanup() + } + return promoted } // InvalidateActionRunnerSession closes exactly the currently admitted typed @@ -438,13 +517,18 @@ func (s *Server) InvalidateActionRunnerSession(admission AgentAdmission) bool { } key := agentSessionKey(expected.OrganizationID, expected.AgentID) s.mu.Lock() - current, ok := s.agents[key] - if !ok || current == nil || !sameActionRunnerAdmission(current.admission, expected) { - s.mu.Unlock() + var current *agentConn + if active, ok := s.agents[key]; ok && active != nil && sameActionRunnerAdmission(active.admission, expected) { + delete(s.agents, key) + current = active + } else if pending, ok := s.pendingActionRunners[key]; ok && pending != nil && sameActionRunnerAdmission(pending.admission, expected) { + delete(s.pendingActionRunners, key) + current = pending + } + s.mu.Unlock() + if current == nil { return false } - delete(s.agents, key) - s.mu.Unlock() current.signalDone() if current.conn != nil { @@ -1186,6 +1270,25 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) { // Register agent - after this point, other goroutines can access the connection s.mu.Lock() + now := time.Now() + if s.now != nil { + now = s.now() + } + for key, expiry := range s.actionRunnerAdmissionTombstones { + if !expiry.After(now) { + delete(s.actionRunnerAdmissionTombstones, key) + } + } + if expiry, cancelled := s.actionRunnerAdmissionTombstones[actionRunnerAdmissionTombstoneKey(admission)]; cancelled && expiry.After(now) { + s.mu.Unlock() + log.Warn().Str("agent_id", reg.AgentID).Msg("Action runner registration rejected: prepared credential was cancelled") + rejectedMsg, err := NewMessage(MsgTypeRegistered, "", RegisteredPayload{Success: false, Message: "action runner credential preparation was cancelled"}) + if err == nil { + _ = s.sendMessage(conn, rejectedMsg) + } + closeConn("Failed to close cancelled action runner registration") + return + } for key, existing := range s.agents { if key != ac.sessionKey && normalizeOrganizationID(existing.admission.OrganizationID) == admission.OrganizationID && @@ -1205,38 +1308,49 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) { return } } - // Close existing connection if any - if existing, ok := s.agents[ac.sessionKey]; ok { - preparedActionRunnerRename := admission.ActivationPending && - admission.RuntimeRole == RuntimeRoleActionRunner && - existing.admission.RuntimeRole == RuntimeRoleActionRunner - if !unifiedresources.HostnamesEquivalent(existing.agent.Hostname, ac.agent.Hostname) && !preparedActionRunnerRename { - s.mu.Unlock() - log.Warn(). - Str("organization_id", admission.OrganizationID). - Str("agent_id", admission.AgentID). - Str("connected_hostname", existing.agent.Hostname). - Str("requested_hostname", admission.Hostname). - Msg("Agent registration rejected: duplicate identity is already connected from another host") - rejectedMsg, err := NewMessage(MsgTypeRegistered, "", RegisteredPayload{Success: false, Message: "agent identity is already connected from another host"}) - if err == nil { - _ = s.sendMessage(conn, rejectedMsg) + var replaced *agentConn + if admission.ActivationPending && admission.RuntimeRole == RuntimeRoleActionRunner { + // A prepared transport is staged separately. Reconnect/flood traffic can + // replace only the one bounded pending slot and cannot evict or interrupt + // the active dispatch session before durable activation. + replaced = s.pendingActionRunners[ac.sessionKey] + s.pendingActionRunners[ac.sessionKey] = ac + } else { + if existing, ok := s.agents[ac.sessionKey]; ok { + if !unifiedresources.HostnamesEquivalent(existing.agent.Hostname, ac.agent.Hostname) { + s.mu.Unlock() + log.Warn(). + Str("organization_id", admission.OrganizationID). + Str("agent_id", admission.AgentID). + Str("connected_hostname", existing.agent.Hostname). + Str("requested_hostname", admission.Hostname). + Msg("Agent registration rejected: duplicate identity is already connected from another host") + rejectedMsg, err := NewMessage(MsgTypeRegistered, "", RegisteredPayload{Success: false, Message: "agent identity is already connected from another host"}) + if err == nil { + _ = s.sendMessage(conn, rejectedMsg) + } + closeConn("Failed to close duplicate agent identity connection") + return } - closeConn("Failed to close duplicate agent identity connection") - return + replaced = existing } + s.agents[ac.sessionKey] = ac + } + s.mu.Unlock() + if replaced != nil && replaced != ac { log.Info(). Str("organization_id", admission.OrganizationID). Str("agent_id", admission.AgentID). Str("hostname", admission.Hostname). + Bool("activation_pending", admission.ActivationPending). Msg("Replacing existing agent connection") - existing.signalDone() - if err := existing.conn.Close(); err != nil { - log.Debug().Err(err).Str("agent_id", admission.AgentID).Msg("Failed to close existing connection during reconnect") + replaced.signalDone() + if replaced.conn != nil { + if err := replaced.conn.Close(); err != nil { + log.Debug().Err(err).Str("agent_id", admission.AgentID).Msg("Failed to close existing connection during reconnect") + } } } - s.agents[ac.sessionKey] = ac - s.mu.Unlock() log.Info(). Str("organization_id", admission.OrganizationID). @@ -1262,6 +1376,9 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) { Msg("Failed to send registration ack") ac.writeMu.Unlock() s.mu.Lock() + if existing, ok := s.pendingActionRunners[ac.sessionKey]; ok && existing == ac { + delete(s.pendingActionRunners, ac.sessionKey) + } if existing, ok := s.agents[ac.sessionKey]; ok && existing == ac { delete(s.agents, ac.sessionKey) } @@ -1290,15 +1407,21 @@ func (s *Server) readLoop(ac *agentConn) { agentID := ac.agent.AgentID sessionKey := connectionSessionKey(ac) s.mu.Lock() - existing, sessionExists := s.agents[sessionKey] - ownsSession := !sessionExists || existing == ac - if sessionExists && existing == ac { + wasActive := false + ownsSession := false + if existing, exists := s.agents[sessionKey]; exists && existing == ac { delete(s.agents, sessionKey) + wasActive = true + ownsSession = true + } + if existing, exists := s.pendingActionRunners[sessionKey]; exists && existing == ac { + delete(s.pendingActionRunners, sessionKey) + ownsSession = true } // Close all deploy progress subscriptions for this agent so // processPreflightProgress goroutines unblock and detect disconnect. var closeChs []chan DeployProgressPayload - if ownsSession { + if ownsSession && wasActive { prefix := sessionKey + "\x00" for key, ch := range s.deploySubs { if strings.HasPrefix(key, prefix) { @@ -1729,11 +1852,15 @@ func (s *Server) Shutdown() { close(s.shutdown) s.mu.Lock() - agents := make([]*agentConn, 0, len(s.agents)) + agents := make([]*agentConn, 0, len(s.agents)+len(s.pendingActionRunners)) for _, ac := range s.agents { agents = append(agents, ac) } + for _, ac := range s.pendingActionRunners { + agents = append(agents, ac) + } s.agents = make(map[string]*agentConn) + s.pendingActionRunners = make(map[string]*agentConn) s.mu.Unlock() for _, ac := range agents { diff --git a/internal/agentexec/server_test.go b/internal/agentexec/server_test.go index 31b6a96b2..02a81b813 100644 --- a/internal/agentexec/server_test.go +++ b/internal/agentexec/server_test.go @@ -492,7 +492,7 @@ func TestPreparedActionRunnerSessionIsNotDispatchableUntilPromoted(t *testing.T) ActionCapability: ActionCapabilityTypedV1, ActivationPending: true, } key := agentSessionKey(admission.OrganizationID, admission.AgentID) - s.agents[key] = &agentConn{admission: admission, agent: ConnectedAgent{AgentID: admission.AgentID}, done: make(chan struct{})} + s.pendingActionRunners[key] = &agentConn{admission: admission, agent: ConnectedAgent{AgentID: admission.AgentID}, done: make(chan struct{})} if _, ok := s.connectionForOrganization("org-a", "agent-1"); ok { t.Fatal("prepared runner was dispatchable") } diff --git a/internal/agentexec/server_websocket_test.go b/internal/agentexec/server_websocket_test.go index b70a5432a..e8514504d 100644 --- a/internal/agentexec/server_websocket_test.go +++ b/internal/agentexec/server_websocket_test.go @@ -272,6 +272,154 @@ func TestActionRunnerRegistrationRequiresCredentialBoundRuntimeRoleAndCapability } } +func TestPreparedActionRunnerReconnectCannotDisplaceActiveDispatchAndExactPromotionSwaps(t *testing.T) { + active := AgentAdmission{ + OrganizationID: "org-a", TokenID: "active-token", AgentID: "machine-a", Hostname: "node.example", + RuntimeRole: RuntimeRoleActionRunner, ActionCapability: ActionCapabilityTypedV1, + } + pendingOne := active + pendingOne.TokenID = "pending-token-1" + pendingOne.ActivationPending = true + pendingTwo := pendingOne + pendingTwo.TokenID = "pending-token-2" + admissions := map[string]AgentAdmission{ + active.TokenID: active, + pendingOne.TokenID: pendingOne, + pendingTwo.TokenID: pendingTwo, + } + s := NewServerWithAdmissionValidator(func(token, _, _ string) (AgentAdmission, bool) { + admission, ok := admissions[token] + return admission, ok + }, func(AgentAdmission) bool { return true }) + ts := newWSServer(t, s) + defer ts.Close() + register := func(admission AgentAdmission) *websocket.Conn { + t.Helper() + conn, _, err := dialAgentExecWebSocket(t, ts.URL) + if err != nil { + t.Fatal(err) + } + wsWriteMessage(t, conn, mustNewMessage(t, MsgTypeAgentRegister, "", AgentRegisterPayload{ + AgentID: admission.AgentID, Hostname: admission.Hostname, Token: admission.TokenID, + RuntimeRole: admission.RuntimeRole, ActionCapability: admission.ActionCapability, + })) + if ack := wsReadRegisteredPayload(t, conn); !ack.Success { + conn.Close() + t.Fatalf("registration for %s failed: %s", admission.TokenID, ack.Message) + } + return conn + } + requirePong := func(conn *websocket.Conn, label string) { + t.Helper() + wsWriteMessage(t, conn, mustNewMessage(t, MsgTypeAgentPing, "", nil)) + if msg := wsReadRawMessage(t, conn); msg.Type != MsgTypePong { + t.Fatalf("%s message type = %q, want pong", label, msg.Type) + } + } + + activeConn := register(active) + defer activeConn.Close() + pendingOneConn := register(pendingOne) + defer pendingOneConn.Close() + + if current, ok := s.connectionForOrganization(active.OrganizationID, active.AgentID); !ok || current.admission.TokenID != active.TokenID { + t.Fatalf("pending registration displaced active dispatch: %#v, %v", current, ok) + } + requirePong(activeConn, "active runner while replacement pending") + if !s.HasActionRunnerSession(pendingOne) { + t.Fatal("first exact pending transport was not staged") + } + + pendingTwoConn := register(pendingTwo) + defer pendingTwoConn.Close() + if _, err := wsReadRawMessageWithTimeout(pendingOneConn, 2*time.Second); err == nil { + t.Fatal("replaced pending transport remained connected") + } + if current, ok := s.connectionForOrganization(active.OrganizationID, active.AgentID); !ok || current.admission.TokenID != active.TokenID { + t.Fatalf("pending reconnect displaced active dispatch: %#v, %v", current, ok) + } + requirePong(activeConn, "active runner after pending reconnect") + if s.HasActionRunnerSession(pendingOne) { + t.Fatal("superseded pending transport remained promotable") + } + if !s.HasActionRunnerSession(pendingTwo) { + t.Fatal("latest exact pending transport was not staged") + } + if cleanup, promoted := s.PromoteActionRunnerSessionForCommit(pendingOne); promoted || cleanup != nil { + t.Fatal("stale Has/promote snapshot displaced the current transport") + } + cleanup, promoted := s.PromoteActionRunnerSessionForCommit(pendingTwo) + if !promoted { + t.Fatal("latest exact pending transport was not promoted") + } + if current, ok := s.connectionForOrganization(active.OrganizationID, active.AgentID); !ok || current.admission.TokenID != pendingTwo.TokenID { + t.Fatalf("promotion did not atomically swap dispatch: %#v, %v", current, ok) + } + if cleanup == nil { + t.Fatal("promotion did not return displaced active cleanup") + } + cleanup() + if _, err := wsReadRawMessageWithTimeout(activeConn, 2*time.Second); err == nil { + t.Fatal("displaced active transport remained connected after deferred cleanup") + } + requirePong(pendingTwoConn, "promoted runner") + + s.mu.RLock() + activeCount := len(s.agents) + pendingCount := len(s.pendingActionRunners) + s.mu.RUnlock() + if activeCount != 1 || pendingCount != 0 { + t.Fatalf("session maps after promotion = active %d pending %d", activeCount, pendingCount) + } +} + +func TestActionRunnerPromotionVersusDisconnectNeverRetainsDeadSession(t *testing.T) { + for attempt := 0; attempt < 20; attempt++ { + admission := AgentAdmission{ + OrganizationID: "org-a", TokenID: fmt.Sprintf("pending-%d", attempt), AgentID: "machine-a", Hostname: "node.example", + RuntimeRole: RuntimeRoleActionRunner, ActionCapability: ActionCapabilityTypedV1, ActivationPending: true, + } + s := NewServerWithAdmissionValidator(func(token, _, _ string) (AgentAdmission, bool) { + return admission, token == admission.TokenID + }, func(AgentAdmission) bool { return true }) + ts := newWSServer(t, s) + conn, _, err := dialAgentExecWebSocket(t, ts.URL) + if err != nil { + ts.Close() + t.Fatal(err) + } + wsWriteMessage(t, conn, mustNewMessage(t, MsgTypeAgentRegister, "", AgentRegisterPayload{ + AgentID: admission.AgentID, Hostname: admission.Hostname, Token: admission.TokenID, + RuntimeRole: admission.RuntimeRole, ActionCapability: admission.ActionCapability, + })) + if ack := wsReadRegisteredPayload(t, conn); !ack.Success { + conn.Close() + ts.Close() + t.Fatalf("attempt %d registration failed: %s", attempt, ack.Message) + } + start := make(chan struct{}) + promotionDone := make(chan func(), 1) + go func() { + <-start + cleanup, _ := s.PromoteActionRunnerSessionForCommit(admission) + promotionDone <- cleanup + }() + close(start) + _ = conn.Close() + cleanup := <-promotionDone + if cleanup != nil { + cleanup() + } + waitFor(t, 2*time.Second, func() bool { + s.mu.RLock() + defer s.mu.RUnlock() + key := agentSessionKey(admission.OrganizationID, admission.AgentID) + return s.agents[key] == nil && s.pendingActionRunners[key] == nil + }) + ts.Close() + } +} + func TestLegacyCredentialCannotAssertActionRunnerRole(t *testing.T) { s := NewServerWithAdmissionValidator(func(token, _, _ string) (AgentAdmission, bool) { return AgentAdmission{TokenID: token, AgentID: "a1", Hostname: "host1", RuntimeRole: RuntimeRoleLegacyFullTrust}, token == "legacy" @@ -1306,6 +1454,132 @@ func TestInvalidateActionRunnerSessionClosesExactSessionAndUnblocksInflightDispa } } +func TestActionRunnerAdmissionTombstoneFencesDelayedCanonicalRegistrationAndIsExact(t *testing.T) { + cancelled := AgentAdmission{OrganizationID: "org-a", TokenID: "pending-token", AgentID: "agent-a", Hostname: "node.example", RuntimeRole: RuntimeRoleActionRunner, ActionCapability: ActionCapabilityTypedV1, ActivationPending: true} + other := cancelled + other.TokenID = "other-token" + admitted := make(chan struct{}) + release := make(chan struct{}) + s := NewServerWithAdmissionValidator(func(token, _, _ string) (AgentAdmission, bool) { + if token == cancelled.TokenID { + close(admitted) + <-release + return cancelled, true + } + return other, token == other.TokenID + }, func(AgentAdmission) bool { return true }) + ts := newWSServer(t, s) + defer ts.Close() + conn, _, err := dialAgentExecWebSocket(t, ts.URL) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + wsWriteMessage(t, conn, mustNewMessage(t, MsgTypeAgentRegister, "", AgentRegisterPayload{AgentID: cancelled.AgentID, Hostname: "NODE", Token: cancelled.TokenID, RuntimeRole: cancelled.RuntimeRole, ActionCapability: cancelled.ActionCapability})) + select { + case <-admitted: + case <-time.After(time.Second): + t.Fatal("registration did not reach pre-insertion admission point") + } + if !s.TombstoneActionRunnerAdmission(cancelled, time.Now().Add(time.Minute)) { + t.Fatal("exact pending admission was not tombstoned") + } + close(release) + if ack := wsReadRegisteredPayload(t, conn); ack.Success { + t.Fatal("pre-admitted cancelled socket registered after durable cancellation") + } + otherConn, _, err := dialAgentExecWebSocket(t, ts.URL) + if err != nil { + t.Fatal(err) + } + defer otherConn.Close() + wsWriteMessage(t, otherConn, mustNewMessage(t, MsgTypeAgentRegister, "", AgentRegisterPayload{AgentID: other.AgentID, Hostname: "NODE", Token: other.TokenID, RuntimeRole: other.RuntimeRole, ActionCapability: other.ActionCapability})) + if ack := wsReadRegisteredPayload(t, otherConn); !ack.Success { + t.Fatalf("exact tombstone rejected another credential: %s", ack.Message) + } +} + +func TestActionRunnerAdmissionTombstoneKeyIsolatesEveryBoundField(t *testing.T) { + base := AgentAdmission{ + OrganizationID: "org-a", TokenID: "pending-token", AgentID: "agent-a", Hostname: "node.a.example", + RuntimeRole: RuntimeRoleActionRunner, ActionCapability: ActionCapabilityTypedV1, + } + baseKey := actionRunnerAdmissionTombstoneKey(base) + canonicalSpelling := base + canonicalSpelling.Hostname = " NODE.A.EXAMPLE. " + if got := actionRunnerAdmissionTombstoneKey(canonicalSpelling); got != baseKey { + t.Fatalf("equivalent canonical hostname produced a distinct key: %q != %q", got, baseKey) + } + + mutations := map[string]func(*AgentAdmission){ + "organization": func(candidate *AgentAdmission) { candidate.OrganizationID = "org-b" }, + "token": func(candidate *AgentAdmission) { candidate.TokenID = "other-token" }, + "agent": func(candidate *AgentAdmission) { candidate.AgentID = "agent-b" }, + "full hostname": func(candidate *AgentAdmission) { + // The short label is intentionally unchanged: separate FQDNs must + // never share a cancellation fence. + candidate.Hostname = "node.b.example" + }, + "runtime role": func(candidate *AgentAdmission) { candidate.RuntimeRole = RuntimeRoleLegacyFullTrust }, + "capability": func(candidate *AgentAdmission) { candidate.ActionCapability = "typed_actions.v2" }, + } + for name, mutate := range mutations { + t.Run(name, func(t *testing.T) { + candidate := base + mutate(&candidate) + if got := actionRunnerAdmissionTombstoneKey(candidate); got == baseKey { + t.Fatalf("%s change did not isolate tombstone key", name) + } + }) + } +} + +func TestActionRunnerAdmissionTombstoneExpiresAndClosesCurrentExactSession(t *testing.T) { + now := time.Now().UTC() + admission := AgentAdmission{OrganizationID: "org-a", TokenID: "pending-token", AgentID: "agent-a", Hostname: "node.example", RuntimeRole: RuntimeRoleActionRunner, ActionCapability: ActionCapabilityTypedV1, ActivationPending: true} + s := NewServerWithAdmissionValidator(func(token, _, _ string) (AgentAdmission, bool) { return admission, token == admission.TokenID }, func(AgentAdmission) bool { return true }) + s.now = func() time.Time { return now } + ts := newWSServer(t, s) + defer ts.Close() + register := func() *websocket.Conn { + conn, _, err := dialAgentExecWebSocket(t, ts.URL) + if err != nil { + t.Fatal(err) + } + wsWriteMessage(t, conn, mustNewMessage(t, MsgTypeAgentRegister, "", AgentRegisterPayload{AgentID: admission.AgentID, Hostname: admission.Hostname, Token: admission.TokenID, RuntimeRole: admission.RuntimeRole, ActionCapability: admission.ActionCapability})) + return conn + } + current := register() + defer current.Close() + if ack := wsReadRegisteredPayload(t, current); !ack.Success { + t.Fatalf("initial registration failed: %s", ack.Message) + } + if !s.TombstoneActionRunnerAdmission(admission, now.Add(time.Second)) { + t.Fatal("current exact session was not tombstoned") + } + if s.IsAgentConnectedForOrganization(admission.OrganizationID, admission.AgentID) { + t.Fatal("tombstoned current session remained connected") + } + if err := current.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatal(err) + } + if _, _, err := current.ReadMessage(); err == nil { + t.Fatal("tombstoned current websocket remained open") + } + now = now.Add(2 * time.Second) + afterExpiry := register() + defer afterExpiry.Close() + if ack := wsReadRegisteredPayload(t, afterExpiry); !ack.Success { + t.Fatalf("expired tombstone rejected registration: %s", ack.Message) + } + s.mu.RLock() + tombstoneCount := len(s.actionRunnerAdmissionTombstones) + s.mu.RUnlock() + if tombstoneCount != 0 { + t.Fatalf("expired admission tombstone was not pruned: %d entries remain", tombstoneCount) + } +} + func TestInvalidateAgentSessionRequiresExactLegacyAdmission(t *testing.T) { admission := AgentAdmission{ OrganizationID: "org-a", TokenID: "collector-token", AgentID: "agent-a", diff --git a/internal/agenthelper/server.go b/internal/agenthelper/server.go index d1b735a9c..29807521d 100644 --- a/internal/agenthelper/server.go +++ b/internal/agenthelper/server.go @@ -9,8 +9,12 @@ import ( "time" ) -const defaultMaxOperationTimeout = 30 * time.Second -const responseWriteTimeout = 5 * time.Second +const ( + defaultMaxConcurrentConnections = 16 + defaultMaxOperationTimeout = 30 * time.Second + defaultPreFrameTimeout = 2 * time.Second + responseWriteTimeout = 5 * time.Second +) type Peer struct { UID uint32 @@ -54,19 +58,23 @@ type AuditEvent struct { type AuditHook func(AuditEvent) type ServerConfig struct { - AllowedUID uint32 - PeerResolver PeerResolver - Registry *Registry - MaxOperationTimeout time.Duration - Audit AuditHook - Now func() time.Time + AllowedUID uint32 + PeerResolver PeerResolver + Registry *Registry + MaxConcurrentConnections int + MaxOperationTimeout time.Duration + PreFrameTimeout time.Duration + Audit AuditHook + Now func() time.Time } type Server struct { allowedUID uint32 peerResolver PeerResolver registry *Registry + connectionSlots chan struct{} maxOperationTimeout time.Duration + preFrameTimeout time.Duration audit AuditHook now func() time.Time } @@ -78,13 +86,33 @@ func NewServer(config ServerConfig) (*Server, error) { if config.Registry == nil { return nil, errors.New("operation registry is required") } + maxConnections := config.MaxConcurrentConnections + if maxConnections < 0 { + return nil, errors.New("maximum concurrent connections must not be negative") + } + if maxConnections == 0 { + maxConnections = defaultMaxConcurrentConnections + } maxTimeout := config.MaxOperationTimeout - if maxTimeout <= 0 { + if maxTimeout < 0 { + return nil, errors.New("maximum operation timeout must not be negative") + } + if maxTimeout == 0 { maxTimeout = defaultMaxOperationTimeout } if maxTimeout < time.Millisecond { return nil, errors.New("maximum operation timeout must be at least 1ms") } + preFrameTimeout := config.PreFrameTimeout + if preFrameTimeout < 0 { + return nil, errors.New("pre-frame timeout must not be negative") + } + if preFrameTimeout == 0 { + preFrameTimeout = defaultPreFrameTimeout + } + if preFrameTimeout < time.Millisecond { + return nil, errors.New("pre-frame timeout must be at least 1ms") + } now := config.Now if now == nil { now = time.Now @@ -93,7 +121,9 @@ func NewServer(config ServerConfig) (*Server, error) { allowedUID: config.AllowedUID, peerResolver: config.PeerResolver, registry: config.Registry, + connectionSlots: make(chan struct{}, maxConnections), maxOperationTimeout: maxTimeout, + preFrameTimeout: preFrameTimeout, audit: config.Audit, now: now, }, nil @@ -112,11 +142,40 @@ func (s *Server) Serve(ctx context.Context, listener net.Listener) error { } return fmt.Errorf("accept helper connection: %w", err) } - go s.HandleConnection(ctx, conn) + if !s.tryAcquireConnection() { + _ = conn.Close() + continue + } + go func() { + defer s.releaseConnection() + s.handleConnection(ctx, conn) + }() } } func (s *Server) HandleConnection(parent context.Context, conn net.Conn) { + if !s.tryAcquireConnection() { + _ = conn.Close() + return + } + defer s.releaseConnection() + s.handleConnection(parent, conn) +} + +func (s *Server) tryAcquireConnection() bool { + select { + case s.connectionSlots <- struct{}{}: + return true + default: + return false + } +} + +func (s *Server) releaseConnection() { + <-s.connectionSlots +} + +func (s *Server) handleConnection(parent context.Context, conn net.Conn) { defer conn.Close() started := s.now() event := AuditEvent{StartedAt: started} @@ -127,7 +186,8 @@ func (s *Server) HandleConnection(parent context.Context, conn net.Conn) { } }() - _ = conn.SetDeadline(started.Add(s.maxOperationTimeout)) + preFrameTimeout := min(s.preFrameTimeout, s.maxOperationTimeout) + _ = conn.SetDeadline(started.Add(preFrameTimeout)) peer, err := s.peerResolver.Resolve(conn) event.Peer = peer if err != nil || peer.UID != s.allowedUID { @@ -150,6 +210,7 @@ func (s *Server) HandleConnection(parent context.Context, conn net.Conn) { event.RequestID = request.RequestID event.Operation = request.Operation event.OperationVersion = request.OperationVersion + _ = conn.SetDeadline(started.Add(s.maxOperationTimeout)) if validationError := s.validateRequest(request); validationError != nil { s.writeResponse(conn, &event, errorResponse(request, validationError)) diff --git a/internal/agenthelper/server_test.go b/internal/agenthelper/server_test.go index c964790d5..b40b29008 100644 --- a/internal/agenthelper/server_test.go +++ b/internal/agenthelper/server_test.go @@ -7,6 +7,8 @@ import ( "encoding/json" "errors" "net" + "os" + "path/filepath" "strings" "sync" "testing" @@ -318,6 +320,205 @@ func TestServerRejectsUnauthorizedPeer(t *testing.T) { <-done } +func TestServerBoundsConcurrentConnectionsAndRecovers(t *testing.T) { + const maxConnections = 2 + resolved := make(chan struct{}, maxConnections+1) + audited := make(chan AuditEvent, maxConnections+1) + server, err := NewServer(ServerConfig{ + AllowedUID: 1000, + PeerResolver: PeerResolverFunc(func(net.Conn) (Peer, error) { + resolved <- struct{}{} + return Peer{UID: 1000, GID: 2000, PID: 3000}, nil + }), + Registry: NewRegistry(nil, nil), + MaxConcurrentConnections: maxConnections, + MaxOperationTimeout: 5 * time.Second, + PreFrameTimeout: 5 * time.Second, + Audit: func(event AuditEvent) { audited <- event }, + }) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + + socketDir, err := os.MkdirTemp("", "pah-") + if err != nil { + t.Fatalf("create short socket directory: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(socketDir) }) + socketPath := filepath.Join(socketDir, "helper.sock") + listener, err := net.Listen("unix", socketPath) + if err != nil { + t.Fatalf("listen: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + serveDone := make(chan error, 1) + go func() { serveDone <- server.Serve(ctx, listener) }() + t.Cleanup(func() { + cancel() + _ = listener.Close() + if err := <-serveDone; err != nil { + t.Errorf("Serve: %v", err) + } + }) + + held := make([]net.Conn, 0, maxConnections) + for i := 0; i < maxConnections; i++ { + conn, dialErr := net.Dial("unix", socketPath) + if dialErr != nil { + t.Fatalf("dial held connection %d: %v", i, dialErr) + } + held = append(held, conn) + select { + case <-resolved: + case <-time.After(time.Second): + t.Fatalf("held connection %d was not admitted", i) + } + } + t.Cleanup(func() { + for _, conn := range held { + _ = conn.Close() + } + }) + + overload, err := net.Dial("unix", socketPath) + if err != nil { + t.Fatalf("dial overloaded connection: %v", err) + } + defer overload.Close() + if err := overload.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatalf("set overload deadline: %v", err) + } + buffer := make([]byte, 1) + if _, err := overload.Read(buffer); err == nil { + t.Fatal("overloaded connection remained open") + } else if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + t.Fatal("overloaded connection was not rejected promptly") + } + select { + case <-resolved: + t.Fatal("overloaded connection reached peer authentication") + default: + } + + if err := held[0].Close(); err != nil { + t.Fatalf("release held connection: %v", err) + } + select { + case event := <-audited: + if event.Success || event.ErrorCode != ErrorInvalidFrame { + t.Fatalf("released connection audit = %#v", event) + } + case <-time.After(time.Second): + t.Fatal("released connection was not cleaned up") + } + deadline := time.Now().Add(time.Second) + for len(server.connectionSlots) != maxConnections-1 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if got := len(server.connectionSlots); got != maxConnections-1 { + t.Fatalf("active connection slots = %d, want %d", got, maxConnections-1) + } + + client, err := NewClient(ClientConfig{ + SocketPath: socketPath, + MaxDeadline: time.Second, + NewRequestID: func() (string, error) { return "post-overload-health", nil }, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + var health HealthResult + if _, err := client.Call(t.Context(), OperationHealth, OperationVersion1, time.Second, nil, &health); err != nil { + t.Fatalf("health after releasing connection slot: %v", err) + } + if health.Status != "ok" { + t.Fatalf("health status = %q, want ok", health.Status) + } +} + +func TestServerAppliesShortPreFrameTimeout(t *testing.T) { + audited := make(chan AuditEvent, 1) + server, err := NewServer(ServerConfig{ + AllowedUID: 1000, + PeerResolver: authorizedResolver(1000), + Registry: NewRegistry(nil, nil), + MaxOperationTimeout: time.Second, + PreFrameTimeout: 25 * time.Millisecond, + Audit: func(event AuditEvent) { audited <- event }, + }) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + serverConn, clientConn := net.Pipe() + done := make(chan struct{}) + started := time.Now() + go func() { + server.HandleConnection(context.Background(), serverConn) + close(done) + }() + payload, err := readFrame(clientConn, MaxResponseBytes) + if err != nil { + t.Fatalf("read timeout response: %v", err) + } + response, err := DecodeResponse(payload) + if err != nil { + t.Fatalf("decode timeout response: %v", err) + } + requireErrorCode(t, response, ErrorInvalidFrame) + _ = clientConn.Close() + <-done + if elapsed := time.Since(started); elapsed >= 500*time.Millisecond { + t.Fatalf("pre-frame timeout took %s, want less than 500ms", elapsed) + } + event := <-audited + if event.RequestID != "" || event.Operation != "" || event.RequestBytes != 0 || event.ErrorCode != ErrorInvalidFrame { + t.Fatalf("pre-frame audit exposed unexpected metadata: %#v", event) + } +} + +func TestNewServerValidatesConnectionLimitsAndTimeouts(t *testing.T) { + base := ServerConfig{ + AllowedUID: 1000, + PeerResolver: authorizedResolver(1000), + Registry: NewRegistry(nil, nil), + PreFrameTimeout: time.Second, + } + tests := []struct { + name string + mutate func(*ServerConfig) + }{ + {name: "negative connection limit", mutate: func(config *ServerConfig) { config.MaxConcurrentConnections = -1 }}, + {name: "negative operation timeout", mutate: func(config *ServerConfig) { config.MaxOperationTimeout = -1 }}, + {name: "sub-millisecond operation timeout", mutate: func(config *ServerConfig) { config.MaxOperationTimeout = time.Nanosecond }}, + {name: "negative pre-frame timeout", mutate: func(config *ServerConfig) { config.PreFrameTimeout = -1 }}, + {name: "sub-millisecond pre-frame timeout", mutate: func(config *ServerConfig) { config.PreFrameTimeout = time.Nanosecond }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + config := base + test.mutate(&config) + if _, err := NewServer(config); err == nil { + t.Fatal("NewServer succeeded with invalid configuration") + } + }) + } + + server, err := NewServer(ServerConfig{ + AllowedUID: 1000, + PeerResolver: authorizedResolver(1000), + Registry: NewRegistry(nil, nil), + }) + if err != nil { + t.Fatalf("NewServer defaults: %v", err) + } + if cap(server.connectionSlots) != defaultMaxConcurrentConnections { + t.Fatalf("default connection limit = %d, want %d", cap(server.connectionSlots), defaultMaxConcurrentConnections) + } + if server.preFrameTimeout != defaultPreFrameTimeout { + t.Fatalf("default pre-frame timeout = %s, want %s", server.preFrameTimeout, defaultPreFrameTimeout) + } +} + func TestServerBoundsOperationDeadline(t *testing.T) { provider := fakeSMARTProvider(func(ctx context.Context) (json.RawMessage, error) { <-ctx.Done() diff --git a/internal/api/action_runner_credentials.go b/internal/api/action_runner_credentials.go index 25d0bd764..4115109aa 100644 --- a/internal/api/action_runner_credentials.go +++ b/internal/api/action_runner_credentials.go @@ -70,7 +70,7 @@ func (r *Router) handleIssueActionRunnerCredential(w http.ResponseWriter, req *h http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } - if r == nil || r.config == nil { + if r == nil || r.config == nil || r.persistence == nil { http.Error(w, "Action runner credential service unavailable", http.StatusServiceUnavailable) return } @@ -132,15 +132,15 @@ func (r *Router) handleIssueActionRunnerCredential(w http.ResponseWriter, req *h // handleActivateActionRunnerCredential commits a prepared rotation only after // the exact replacement runner has registered and durably written its local -// activation proof. The runner calls this endpoint itself; no plaintext token -// or caller-selected predecessor identity crosses the boundary. +// pending proof. The runner calls this endpoint itself; no plaintext token or +// caller-selected predecessor identity crosses the boundary. func (r *Router) handleActivateActionRunnerCredential(w http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPatch { w.Header().Set("Allow", http.MethodPatch) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } - if r == nil || r.config == nil || r.agentExecServer == nil { + if r == nil || r.config == nil || r.persistence == nil || r.agentExecServer == nil { http.Error(w, "Action runner activation service unavailable", http.StatusServiceUnavailable) return } @@ -166,7 +166,6 @@ func (r *Router) handleActivateActionRunnerCredential(w http.ResponseWriter, req http.Error(w, "Action runner credential binding mismatch", http.StatusForbidden) return } - pending := strings.TrimSpace(caller.Metadata[agenttokens.ActionRunnerActivationPendingMetadataKey]) == "true" admission := agentexec.AgentAdmission{ OrganizationID: organizationID, TokenID: strings.TrimSpace(caller.ID), @@ -174,34 +173,104 @@ func (r *Router) handleActivateActionRunnerCredential(w http.ResponseWriter, req Hostname: strings.TrimSpace(caller.Metadata["bound_hostname"]), RuntimeRole: agentexec.RuntimeRoleActionRunner, ActionCapability: agentexec.ActionCapabilityTypedV1, - ActivationPending: pending, + ActivationPending: true, } - if !r.agentExecServer.HasActionRunnerSession(admission) { - http.Error(w, "Exact action runner session is not registered", http.StatusConflict) - return - } - _, revoked, changed, err := agenttokens.ActivateActionRunnerAndPersist( + var promotionCleanup func() + _, revoked, changed, err := agenttokens.ActivateActionRunnerAndPersistWithPromotion( r.config, r.persistence, caller.ID, payload.AgentID, payload.Hostname, + func() bool { + cleanup, promoted := r.agentExecServer.PromoteActionRunnerSessionForCommit(admission) + if promoted { + promotionCleanup = cleanup + } + return promoted + }, ) if err != nil { status := http.StatusInternalServerError if errors.Is(err, agenttokens.ErrRecord) { status = http.StatusForbidden + } else if errors.Is(err, agenttokens.ErrActionRunnerSessionUnavailable) { + status = http.StatusConflict } http.Error(w, "Failed to activate action runner credential", status) return } if changed { + if promotionCleanup != nil { + promotionCleanup() + } for _, previous := range revoked { r.invalidateActionRunnerRecord(previous) } - admission.ActivationPending = false - r.agentExecServer.PromoteActionRunnerSession(admission) LogAuditEventForTenant(organizationID, "action_runner_credential_activated", auth.GetUser(req.Context()), GetClientIP(req), req.URL.Path, true, "Activated host-bound typed action runner credential") } w.WriteHeader(http.StatusNoContent) } +// handleCancelPendingActionRunnerCredentialActivation is the sole authority +// for installer rollback. It durably removes the exact still-pending +// replacement under the same token-inventory lock as activation; a committed +// credential returns conflict and can never authorize predecessor restore. +func (r *Router) handleCancelPendingActionRunnerCredentialActivation(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodDelete { + w.Header().Set("Allow", http.MethodDelete) + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + if r == nil || r.config == nil || r.agentExecServer == nil { + http.Error(w, "Action runner activation service unavailable", http.StatusServiceUnavailable) + return + } + if req.Body != nil { + bodyPrefix, err := io.ReadAll(io.LimitReader(req.Body, 1)) + if err != nil || len(bodyPrefix) != 0 { + http.Error(w, "Action runner activation cancellation accepts no request body", http.StatusBadRequest) + return + } + } + caller := getAPITokenRecordFromRequest(req) + if caller == nil { + http.Error(w, "Action runner bearer credential required", http.StatusForbidden) + return + } + organizationID := strings.TrimSpace(GetOrgID(req.Context())) + agentID := strings.TrimSpace(caller.Metadata["bound_agent_id"]) + hostname := strings.TrimSpace(caller.Metadata["bound_hostname"]) + if len(caller.GetBoundOrgs()) != 1 || strings.TrimSpace(caller.GetBoundOrgs()[0]) != organizationID || + !agentbinding.EvaluateActionRunner(caller, agentID, hostname).Admit { + http.Error(w, "Action runner credential binding mismatch", http.StatusForbidden) + return + } + removed, err := agenttokens.CancelPendingActionRunnerAndPersist(r.config, r.persistence, caller.ID, organizationID, agentID, hostname) + if err != nil { + if errors.Is(err, agenttokens.ErrActionRunnerAlreadyActivated) { + http.Error(w, "Action runner credential activation already committed", http.StatusConflict) + return + } + http.Error(w, "Could not establish rollback-safe action runner credential state", http.StatusInternalServerError) + return + } + admission := agentexec.AgentAdmission{ + OrganizationID: organizationID, + TokenID: strings.TrimSpace(removed.ID), + AgentID: strings.TrimSpace(removed.Metadata["bound_agent_id"]), + Hostname: strings.TrimSpace(removed.Metadata["bound_hostname"]), + RuntimeRole: strings.TrimSpace(removed.Metadata[agenttokens.RuntimeRoleMetadataKey]), + ActionCapability: strings.TrimSpace(removed.Metadata[agenttokens.ActionCapabilityMetadataKey]), + } + until := time.Now().UTC().Add(agenttokens.ActionRunnerActivationWindow) + if removed.ExpiresAt != nil && removed.ExpiresAt.After(time.Now()) { + until = *removed.ExpiresAt + } + if !r.agentExecServer.TombstoneActionRunnerAdmission(admission, until) { + http.Error(w, "Could not establish rollback-safe action runner admission state", http.StatusInternalServerError) + return + } + LogAuditEventForTenant(organizationID, "action_runner_credential_activation_cancelled", auth.GetUser(req.Context()), GetClientIP(req), req.URL.Path, true, "Cancelled pending host-bound typed action runner credential") + w.WriteHeader(http.StatusNoContent) +} + // handleSelfRevokeActionRunnerCredential lets the separately credentialed // runner revoke only its own exact tenant/host binding. It cannot select a // token ID or another host, and browser/session authentication is rejected. @@ -211,7 +280,7 @@ func (r *Router) handleSelfRevokeActionRunnerCredential(w http.ResponseWriter, r http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } - if r == nil || r.config == nil { + if r == nil || r.config == nil || r.persistence == nil { http.Error(w, "Action runner credential service unavailable", http.StatusServiceUnavailable) return } @@ -260,14 +329,12 @@ func (r *Router) handleSelfRevokeActionRunnerCredential(w http.ResponseWriter, r } r.config.APITokens = append(r.config.APITokens[:index], r.config.APITokens[index+1:]...) r.config.SortAPITokens() - if r.persistence != nil { - if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil { - r.config.APITokens = previousTokens - r.config.SortAPITokens() - config.Mu.Unlock() - http.Error(w, "Failed to revoke action runner credential", http.StatusInternalServerError) - return - } + if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil { + r.config.APITokens = previousTokens + r.config.SortAPITokens() + config.Mu.Unlock() + http.Error(w, "Failed to revoke action runner credential", http.StatusInternalServerError) + return } config.Mu.Unlock() diff --git a/internal/api/action_runner_credentials_test.go b/internal/api/action_runner_credentials_test.go index 8cef40e2d..26585d9f0 100644 --- a/internal/api/action_runner_credentials_test.go +++ b/internal/api/action_runner_credentials_test.go @@ -9,6 +9,7 @@ import ( "net/http" "net/http/httptest" "os" + "strings" "testing" "time" @@ -228,8 +229,8 @@ func TestIssueActionRunnerCredentialRotationCommitsOnlyAfterReplacementHealthHan pendingConn, pendingServer := connectActionRunnerCredentialForTest(t, router.agentExecServer, second) defer pendingConn.Close() defer pendingServer.Close() - if router.agentExecServer.IsAgentConnectedForOrganization("default", hostID) { - t.Fatal("pending replacement became dispatchable before activation") + if !router.agentExecServer.IsAgentConnectedForOrganization("default", hostID) { + t.Fatal("pending replacement interrupted active dispatch before activation") } if rec := requestActionRunnerActivationForTest(t, router, second); rec.Code != http.StatusNoContent { t.Fatalf("activation status = %d, body=%s", rec.Code, rec.Body.String()) @@ -270,6 +271,85 @@ func TestIssueActionRunnerCredentialPersistenceFailureKeepsPriorLiveSession(t *t } } +func TestCancelPendingActionRunnerCredentialActivationIsSelfOnlyAnd204IsRollbackAuthority(t *testing.T) { + router, cfg, hostID := newActionRunnerCredentialTestRouter(t) + router.agentExecServer = agentexec.NewServerWithAdmissionValidator(router.admitAgentExecToken, router.validateAgentExecSession) + handler := RequireAuth(cfg, RequireScope(config.ScopeAgentExec, router.handleCancelPendingActionRunnerCredentialActivation)) + request := func(token, organizationID, body string) *httptest.ResponseRecorder { + var reader io.Reader + if body != "" { + reader = strings.NewReader(body) + } + req := httptest.NewRequest(http.MethodDelete, "/api/agents/action-runner/credential/activation", reader) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, organizationID)) + rec := httptest.NewRecorder() + handler(rec, req) + return rec + } + + pending := issueActionRunnerCredentialForTest(t, router, hostID, "host-1.local") + if rec := request("", "default", ""); rec.Code != http.StatusUnauthorized { + t.Fatalf("missing bearer status = %d, body=%s", rec.Code, rec.Body.String()) + } + if rec := request(pending.Token, "other", ""); rec.Code == http.StatusNoContent { + t.Fatal("cross-organization bearer returned rollback-authorizing 204") + } + if rec := request(pending.Token, "default", `{"agentId":"other","hostname":"other"}`); rec.Code != http.StatusBadRequest { + t.Fatalf("selector body status = %d, body=%s", rec.Code, rec.Body.String()) + } + for index := range cfg.APITokens { + if cfg.APITokens[index].ID == pending.TokenID { + cfg.APITokens[index].Scopes = append(cfg.APITokens[index].Scopes, config.ScopeSettingsWrite) + } + } + if rec := request(pending.Token, "default", ""); rec.Code == http.StatusNoContent { + t.Fatal("excess-scope runner bearer returned rollback-authorizing 204") + } + for index := range cfg.APITokens { + if cfg.APITokens[index].ID == pending.TokenID { + cfg.APITokens[index].Scopes = []string{config.ScopeAgentExec} + } + } + legacyRaw := "legacy-cancel-token-1234567890.12345678" + legacy, err := config.NewAPITokenRecord(legacyRaw, "legacy exec", []string{config.ScopeAgentExec}) + if err != nil { + t.Fatal(err) + } + legacy.OrgID = "default" + legacy.Metadata = map[string]string{agenttokens.RuntimeRoleMetadataKey: agenttokens.CredentialKindLegacyFullTrust, "bound_agent_id": hostID, "bound_hostname": "host-1.local"} + cfg.APITokens = append(cfg.APITokens, *legacy) + if rec := request(legacyRaw, "default", ""); rec.Code == http.StatusNoContent { + t.Fatal("wrong-role exec bearer returned rollback-authorizing 204") + } + for index := range cfg.APITokens { + if cfg.APITokens[index].ID == legacy.ID { + cfg.APITokens = append(cfg.APITokens[:index], cfg.APITokens[index+1:]...) + break + } + } + if rec := request(pending.Token, "default", ""); rec.Code != http.StatusNoContent { + t.Fatalf("pending cancel status = %d, body=%s", rec.Code, rec.Body.String()) + } + if len(cfg.APITokens) != 0 { + t.Fatalf("cancelled pending credential remained: %#v", cfg.APITokens) + } + if rec := request(pending.Token, "default", ""); rec.Code == http.StatusNoContent { + t.Fatal("removed credential incorrectly returned rollback-authorizing 204") + } + + active := issueActionRunnerCredentialForTest(t, router, hostID, "host-1.local") + commitActionRunnerCredentialForTest(t, router, active) + if rec := request(active.Token, "default", ""); rec.Code != http.StatusConflict { + t.Fatalf("committed cancel status = %d, body=%s", rec.Code, rec.Body.String()) + } + if len(cfg.APITokens) != 1 || cfg.APITokens[0].ID != active.TokenID { + t.Fatalf("committed cancel changed active credential: %#v", cfg.APITokens) + } +} + func TestActivateActionRunnerCredentialPersistenceFailureKeepsBothCredentialsAndPendingSession(t *testing.T) { router, cfg, hostID := newActionRunnerCredentialTestRouter(t) router.agentExecServer = agentexec.NewServerWithAdmissionValidator(router.admitAgentExecToken, router.validateAgentExecSession) @@ -313,6 +393,26 @@ func TestActivateActionRunnerCredentialRequiresExactRegisteredSession(t *testing } } +func TestActivateActionRunnerCredentialRequiresDurablePersistenceWithoutChangingTokenOrSession(t *testing.T) { + router, cfg, hostID := newActionRunnerCredentialTestRouter(t) + router.agentExecServer = agentexec.NewServerWithAdmissionValidator(router.admitAgentExecToken, router.validateAgentExecSession) + issued := issueActionRunnerCredentialForTest(t, router, hostID, "host-1.local") + pendingConn, pendingServer := connectActionRunnerCredentialForTest(t, router.agentExecServer, issued) + defer pendingConn.Close() + defer pendingServer.Close() + router.persistence = nil + if rec := requestActionRunnerActivationForTest(t, router, issued); rec.Code != http.StatusServiceUnavailable { + t.Fatalf("nil persistence activation status = %d, body=%s", rec.Code, rec.Body.String()) + } + if len(cfg.APITokens) != 1 || cfg.APITokens[0].ID != issued.TokenID || cfg.APITokens[0].ExpiresAt == nil || cfg.APITokens[0].Metadata[agenttokens.ActionRunnerActivationPendingMetadataKey] != "true" { + t.Fatalf("nil persistence activation changed inventory = %#v", cfg.APITokens) + } + admission, ok := router.admitAgentExecToken(issued.Token, issued.AgentID, issued.Hostname) + if !ok || !admission.ActivationPending || !router.agentExecServer.HasActionRunnerSession(admission) { + t.Fatalf("nil persistence activation changed pending session = %#v, %v", admission, ok) + } +} + func TestSelfRevokeActionRunnerCredentialRequiresExactBearerBindingAndClosesSession(t *testing.T) { router, cfg, hostID := newActionRunnerCredentialTestRouter(t) router.agentExecServer = agentexec.NewServerWithAdmissionValidator(router.admitAgentExecToken, router.validateAgentExecSession) @@ -353,6 +453,36 @@ func TestSelfRevokeActionRunnerCredentialRequiresExactBearerBindingAndClosesSess } } +func TestSelfRevokeActionRunnerCredentialRequiresDurablePersistence(t *testing.T) { + router, cfg, hostID := newActionRunnerCredentialTestRouter(t) + issued := issueActionRunnerCredentialForTest(t, router, hostID, "host-1.local") + request := func() *httptest.ResponseRecorder { + body, _ := json.Marshal(actionRunnerCredentialSelfRevokeRequest{AgentID: issued.AgentID, Hostname: issued.Hostname}) + req := httptest.NewRequest(http.MethodDelete, "/api/agents/action-runner/credential", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+issued.Token) + req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, "default")) + rec := httptest.NewRecorder() + actionRunnerCredentialRoute(cfg, router.handleIssueActionRunnerCredential, router.handleActivateActionRunnerCredential, router.handleSelfRevokeActionRunnerCredential)(rec, req) + return rec + } + originalPersistence := router.persistence + router.persistence = nil + if rec := request(); rec.Code != http.StatusServiceUnavailable { + t.Fatalf("nil persistence status = %d, body=%s", rec.Code, rec.Body.String()) + } + if len(cfg.APITokens) != 1 || cfg.APITokens[0].ID != issued.TokenID { + t.Fatalf("nil persistence mutated inventory: %#v", cfg.APITokens) + } + router.persistence = originalPersistence + router.persistence.SetFileSystem(actionRunnerFailingPersistenceFS{}) + if rec := request(); rec.Code != http.StatusInternalServerError { + t.Fatalf("failed persistence status = %d, body=%s", rec.Code, rec.Body.String()) + } + if len(cfg.APITokens) != 1 || cfg.APITokens[0].ID != issued.TokenID { + t.Fatalf("failed persistence mutated inventory: %#v", cfg.APITokens) + } +} + func TestActionRunnerCredentialRouteAuthSupportsAdminSessionAndScopedToken(t *testing.T) { for _, mode := range []string{"api-token", "admin-session"} { t.Run(mode, func(t *testing.T) { @@ -587,14 +717,14 @@ func TestActionRunnerRotationProductionRouterTLSPersistenceRestart(t *testing.T) t.Fatal("rotation prepare rejected the first secret") } secondConn, registered := dial(server, second) - if !registered.Success || router.agentExecServer.IsAgentConnectedForOrganization("default", hostID) { + if !registered.Success || !router.agentExecServer.IsAgentConnectedForOrganization("default", hostID) { secondConn.Close() firstConn.Close() shutdown(router, monitor, server) t.Fatalf("prepared second registration = %#v", registered) } - requireSocketClosed(firstConn, "superseded runner") activate(server, second) + requireSocketClosed(firstConn, "superseded runner") if _, ok := cfg.ValidateAPIToken(first.Token); ok { secondConn.Close() firstConn.Close() diff --git a/internal/api/agent_exec_token_binding.go b/internal/api/agent_exec_token_binding.go index 8e2a2605d..91611a8cc 100644 --- a/internal/api/agent_exec_token_binding.go +++ b/internal/api/agent_exec_token_binding.go @@ -150,15 +150,17 @@ func (r *Router) admitAgentExecToken(token string, agentID string, hostname stri return agentexec.AgentAdmission{}, false } capability := strings.TrimSpace(record.Metadata[agenttokens.ActionCapabilityMetadataKey]) + canonicalHostname := strings.TrimSpace(record.Metadata["bound_hostname"]) + activationPending := strings.TrimSpace(record.Metadata[agenttokens.ActionRunnerActivationPendingMetadataKey]) == "true" config.Mu.Unlock() return agentexec.AgentAdmission{ OrganizationID: organizationID, TokenID: tokenID, AgentID: requestedID, - Hostname: requestedHost, + Hostname: canonicalHostname, RuntimeRole: agentexec.RuntimeRoleActionRunner, ActionCapability: capability, - ActivationPending: strings.TrimSpace(record.Metadata[agenttokens.ActionRunnerActivationPendingMetadataKey]) == "true", + ActivationPending: activationPending, }, true } if runtimeRole != "" && runtimeRole != agenttokens.CredentialKindLegacyFullTrust { diff --git a/internal/api/agenttokens/install.go b/internal/api/agenttokens/install.go index 82295b33c..a383ecdaf 100644 --- a/internal/api/agenttokens/install.go +++ b/internal/api/agenttokens/install.go @@ -35,9 +35,12 @@ const ( ) var ( - ErrGeneration = errors.New("agent install token generation failed") - ErrRecord = errors.New("agent install token record failed") - ErrPersist = errors.New("agent install token persistence failed") + ErrGeneration = errors.New("agent install token generation failed") + ErrRecord = errors.New("agent install token record failed") + ErrPersist = errors.New("agent install token persistence failed") + ErrActionRunnerAlreadyActivated = errors.New("action runner credential activation already committed") + ErrActionRunnerSessionUnavailable = errors.New("exact prepared action runner session unavailable") + ErrActionRunnerActivationIndeterminate = errors.New("action runner credential activation is indeterminate") ) type IssueOptions struct { @@ -306,9 +309,29 @@ func IssueActionRunnerAndPersistDetailed(cfg *config.Config, persistence *config // ActivateActionRunnerAndPersist commits a prepared credential and revokes the // exact predecessor set in the same durable token-inventory transaction. func ActivateActionRunnerAndPersist(cfg *config.Config, persistence *config.ConfigPersistence, tokenID, agentID, hostname string) (*config.APITokenRecord, []config.APITokenRecord, bool, error) { + return activateActionRunnerAndPersist(cfg, persistence, tokenID, agentID, hostname, false, nil) +} + +// ActivateActionRunnerAndPersistWithPromotion durably activates an action +// runner credential only when the exact prepared transport can be promoted in +// the same serialized transaction. The promotion callback must perform only a +// bounded in-memory mutation: it runs while config.Mu is held and may acquire +// the agent-exec server mutex, establishing the sole nested lock order +// config.Mu -> agentexec.Server.mu. It must not close sockets, log, or wait. +func ActivateActionRunnerAndPersistWithPromotion(cfg *config.Config, persistence *config.ConfigPersistence, tokenID, agentID, hostname string, promote func() bool) (*config.APITokenRecord, []config.APITokenRecord, bool, error) { + return activateActionRunnerAndPersist(cfg, persistence, tokenID, agentID, hostname, true, promote) +} + +func activateActionRunnerAndPersist(cfg *config.Config, persistence *config.ConfigPersistence, tokenID, agentID, hostname string, requireDurablePromotion bool, promote func() bool) (*config.APITokenRecord, []config.APITokenRecord, bool, error) { if cfg == nil { return nil, nil, false, fmt.Errorf("%w: config is required", ErrRecord) } + if persistence == nil { + return nil, nil, false, fmt.Errorf("%w: durable persistence is required", ErrPersist) + } + if requireDurablePromotion && promote == nil { + return nil, nil, false, fmt.Errorf("%w: prepared session promotion is required", ErrActionRunnerSessionUnavailable) + } tokenID = strings.TrimSpace(tokenID) agentID = strings.TrimSpace(agentID) hostname = unifiedresources.NormalizeFullHostname(hostname) @@ -362,16 +385,90 @@ func ActivateActionRunnerAndPersist(cfg *config.Config, persistence *config.Conf } cfg.APITokens = nextTokens cfg.SortAPITokens() - if persistence != nil { - if err := persistence.SaveAPITokens(cfg.APITokens); err != nil { - cfg.APITokens = previousTokens - cfg.SortAPITokens() - return nil, nil, false, fmt.Errorf("%w: %w", ErrPersist, err) + if err := persistence.SaveAPITokens(cfg.APITokens); err != nil { + cfg.APITokens = previousTokens + cfg.SortAPITokens() + return nil, nil, false, fmt.Errorf("%w: %w", ErrPersist, err) + } + if promote != nil && !promote() { + if err := persistence.SaveAPITokens(previousTokens); err != nil { + // The activation inventory was the last state known to reach durable + // storage. Keep memory aligned with that state and force repair rather + // than exposing pending memory against an active on-disk credential. + return &activated, revoked, true, fmt.Errorf("%w: rollback persistence failed: %v", ErrActionRunnerActivationIndeterminate, err) } + cfg.APITokens = previousTokens + cfg.SortAPITokens() + return nil, nil, false, ErrActionRunnerSessionUnavailable } return &activated, revoked, true, nil } +// CancelPendingActionRunnerAndPersist atomically makes a prepared replacement +// unusable before an installer restores its predecessor. A status snapshot is +// deliberately insufficient: only successful durable removal under the same +// inventory lock used by activation is rollback authority. +func CancelPendingActionRunnerAndPersist(cfg *config.Config, persistence *config.ConfigPersistence, tokenID, organizationID, agentID, hostname string) (*config.APITokenRecord, error) { + if cfg == nil || persistence == nil { + return nil, fmt.Errorf("%w: config and durable persistence are required", ErrPersist) + } + tokenID = strings.TrimSpace(tokenID) + organizationID = strings.TrimSpace(organizationID) + agentID = strings.TrimSpace(agentID) + hostname = unifiedresources.NormalizeFullHostname(hostname) + if tokenID == "" || organizationID == "" || agentID == "" || hostname == "" { + return nil, fmt.Errorf("%w: complete cancellation identity is required", ErrRecord) + } + + config.Mu.Lock() + defer config.Mu.Unlock() + index := -1 + for candidateIndex := range cfg.APITokens { + if strings.TrimSpace(cfg.APITokens[candidateIndex].ID) == tokenID { + index = candidateIndex + break + } + } + if index < 0 { + return nil, fmt.Errorf("%w: action runner credential not found", ErrRecord) + } + record := &cfg.APITokens[index] + boundOrgs := record.GetBoundOrgs() + if record.IsExpired() || len(boundOrgs) != 1 || strings.TrimSpace(boundOrgs[0]) != organizationID || + strings.TrimSpace(record.OrgID) != organizationID || + strings.TrimSpace(record.Metadata[CredentialKindMetadataKey]) != CredentialKindActionRunner || + strings.TrimSpace(record.Metadata[RuntimeRoleMetadataKey]) != CredentialKindActionRunner || + strings.TrimSpace(record.Metadata[ActionCapabilityMetadataKey]) != ActionCapabilityTypedV1 || + strings.TrimSpace(record.Metadata[ActionBindingVersionMetadataKey]) != ActionBindingVersion || + strings.TrimSpace(record.Metadata["bound_agent_id"]) != agentID || + !unifiedresources.HostnamesEquivalent(record.Metadata["bound_hostname"], hostname) { + return nil, fmt.Errorf("%w: action runner cancellation binding mismatch", ErrRecord) + } + if err := internalauth.ValidateRoleScopes(CredentialKindActionRunner, record.Scopes); err != nil { + return nil, fmt.Errorf("%w: action runner cancellation authority mismatch: %v", ErrRecord, err) + } + if strings.TrimSpace(record.Metadata[ActionRunnerActivationPendingMetadataKey]) != "true" { + return nil, ErrActionRunnerAlreadyActivated + } + for _, replacedID := range strings.Split(record.Metadata[ActionRunnerReplacesTokenIDsMetadataKey], ",") { + replacedID = strings.TrimSpace(replacedID) + if replacedID == tokenID { + return nil, fmt.Errorf("%w: invalid pending replacement structure", ErrRecord) + } + } + + previousTokens := cloneAPITokenRecords(cfg.APITokens) + removed := record.Clone() + cfg.APITokens = append(cfg.APITokens[:index], cfg.APITokens[index+1:]...) + cfg.SortAPITokens() + if err := persistence.SaveAPITokens(cfg.APITokens); err != nil { + cfg.APITokens = previousTokens + cfg.SortAPITokens() + return nil, fmt.Errorf("%w: %w", ErrPersist, err) + } + return &removed, nil +} + func normalizeCredentialKind(record *config.APITokenRecord) error { if record == nil { return nil diff --git a/internal/api/agenttokens/install_test.go b/internal/api/agenttokens/install_test.go index d7ffadc70..8aa1b58fa 100644 --- a/internal/api/agenttokens/install_test.go +++ b/internal/api/agenttokens/install_test.go @@ -4,13 +4,80 @@ import ( "errors" "os" "path/filepath" + "reflect" "strings" + "sync" "testing" "time" "github.com/rcourtman/pulse-go-rewrite/internal/config" ) +type actionRunnerTestFS struct { + blockOnce sync.Once + entered chan struct{} + release chan struct{} + writeErr error +} + +type actionRunnerFailAfterWritesFS struct { + mu sync.Mutex + writes int + allowWrites int +} + +func (fs *actionRunnerFailAfterWritesFS) ReadFile(name string) ([]byte, error) { + return os.ReadFile(name) +} +func (fs *actionRunnerFailAfterWritesFS) WriteFile(name string, data []byte, perm os.FileMode) error { + fs.mu.Lock() + fs.writes++ + writes := fs.writes + fs.mu.Unlock() + if writes > fs.allowWrites { + return errors.New("injected rollback persistence failure") + } + return os.WriteFile(name, data, perm) +} +func (fs *actionRunnerFailAfterWritesFS) Rename(oldPath, newPath string) error { + return os.Rename(oldPath, newPath) +} +func (fs *actionRunnerFailAfterWritesFS) Remove(name string) error { return os.Remove(name) } +func (fs *actionRunnerFailAfterWritesFS) Stat(name string) (os.FileInfo, error) { + return os.Stat(name) +} +func (fs *actionRunnerFailAfterWritesFS) MkdirAll(path string, perm os.FileMode) error { + return os.MkdirAll(path, perm) +} + +func (fs *actionRunnerTestFS) ReadFile(name string) ([]byte, error) { return os.ReadFile(name) } +func (fs *actionRunnerTestFS) WriteFile(name string, data []byte, perm os.FileMode) error { + blocked := false + fs.blockOnce.Do(func() { + blocked = fs.entered != nil + if blocked { + close(fs.entered) + } + }) + if blocked { + <-fs.release + } + if fs.writeErr != nil { + return fs.writeErr + } + return os.WriteFile(name, data, perm) +} +func (fs *actionRunnerTestFS) Rename(oldPath, newPath string) error { + return os.Rename(oldPath, newPath) +} +func (fs *actionRunnerTestFS) Remove(name string) error { return os.Remove(name) } +func (fs *actionRunnerTestFS) Stat(name string) (os.FileInfo, error) { + return os.Stat(name) +} +func (fs *actionRunnerTestFS) MkdirAll(path string, perm os.FileMode) error { + return os.MkdirAll(path, perm) +} + func TestIssueAndPersistInstallToken(t *testing.T) { cfg := &config.Config{DataPath: t.TempDir()} raw, record, err := IssueAndPersist(cfg, nil, IssueOptions{ @@ -96,7 +163,7 @@ func TestIssueActionRunnerAndPersistPreparesRotationWithoutRevokingActiveCredent if err != nil { t.Fatalf("first IssueActionRunnerAndPersist: %v", err) } - if _, _, changed, err := ActivateActionRunnerAndPersist(cfg, nil, first.ID, "machine-123", "node.example"); err != nil || !changed { + if _, _, changed, err := ActivateActionRunnerAndPersist(cfg, config.NewConfigPersistence(cfg.DataPath), first.ID, "machine-123", "node.example"); err != nil || !changed { t.Fatalf("activate first credential = changed %v, error %v", changed, err) } _, otherHost, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{ @@ -164,7 +231,7 @@ func TestActivateActionRunnerAndPersistAtomicallyPromotesAndRevokesPredecessor(t if err != nil { t.Fatal(err) } - if _, _, _, err := ActivateActionRunnerAndPersist(cfg, nil, first.ID, "machine-123", "node.example"); err != nil { + if _, _, _, err := ActivateActionRunnerAndPersist(cfg, config.NewConfigPersistence(cfg.DataPath), first.ID, "machine-123", "node.example"); err != nil { t.Fatal(err) } secondToken, second, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{OrgID: "org-a", AgentID: "machine-123", Hostname: "node.example"}) @@ -187,18 +254,37 @@ func TestActivateActionRunnerAndPersistAtomicallyPromotesAndRevokesPredecessor(t if _, ok := cfg.ValidateAPIToken(secondToken); !ok { t.Fatal("activated replacement is not valid") } - if _, revoked, changed, err := ActivateActionRunnerAndPersist(cfg, nil, second.ID, "machine-123", "node.example"); err != nil || changed || len(revoked) != 0 { + if _, revoked, changed, err := ActivateActionRunnerAndPersist(cfg, config.NewConfigPersistence(cfg.DataPath), second.ID, "machine-123", "node.example"); err != nil || changed || len(revoked) != 0 { t.Fatalf("idempotent activation = revoked %#v, changed %v, error %v", revoked, changed, err) } } +func TestActivateActionRunnerAndPersistRequiresDurablePersistenceWithoutMutation(t *testing.T) { + cfg := &config.Config{DataPath: t.TempDir()} + _, pending, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{ + OrgID: "org-a", AgentID: "machine-123", Hostname: "node.example", + }) + if err != nil { + t.Fatal(err) + } + before := cloneAPITokenRecords(cfg.APITokens) + if activated, revoked, changed, err := ActivateActionRunnerAndPersist( + cfg, nil, pending.ID, "machine-123", "node.example", + ); !errors.Is(err, ErrPersist) || activated != nil || revoked != nil || changed { + t.Fatalf("nil persistence activation = activated %#v revoked %#v changed %v err %v", activated, revoked, changed, err) + } + if !reflect.DeepEqual(cfg.APITokens, before) { + t.Fatalf("nil persistence mutated inventory: before %#v after %#v", before, cfg.APITokens) + } +} + func TestActivateActionRunnerAndPersistFailureRestoresPendingAndActiveInventory(t *testing.T) { cfg := &config.Config{DataPath: t.TempDir()} _, first, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{OrgID: "org-a", AgentID: "machine-123", Hostname: "node.example"}) if err != nil { t.Fatal(err) } - if _, _, _, err := ActivateActionRunnerAndPersist(cfg, nil, first.ID, "machine-123", "node.example"); err != nil { + if _, _, _, err := ActivateActionRunnerAndPersist(cfg, config.NewConfigPersistence(cfg.DataPath), first.ID, "machine-123", "node.example"); err != nil { t.Fatal(err) } _, second, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{OrgID: "org-a", AgentID: "machine-123", Hostname: "node.example"}) @@ -226,6 +312,245 @@ func TestActivateActionRunnerAndPersistFailureRestoresPendingAndActiveInventory( } } +func TestActivateActionRunnerAndPersistWithPromotionRollsBackWhenExactTransportVanished(t *testing.T) { + cfg := &config.Config{DataPath: t.TempDir()} + _, predecessor, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{OrgID: "org-a", AgentID: "machine-123", Hostname: "node.example"}) + if err != nil { + t.Fatal(err) + } + if _, _, _, err := ActivateActionRunnerAndPersist(cfg, config.NewConfigPersistence(cfg.DataPath), predecessor.ID, "machine-123", "node.example"); err != nil { + t.Fatal(err) + } + _, pending, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{OrgID: "org-a", AgentID: "machine-123", Hostname: "node.example"}) + if err != nil { + t.Fatal(err) + } + persistence := config.NewConfigPersistence(cfg.DataPath) + promotionCalls := 0 + activated, revoked, changed, err := ActivateActionRunnerAndPersistWithPromotion(cfg, persistence, pending.ID, "machine-123", "node.example", func() bool { + promotionCalls++ + return false + }) + if !errors.Is(err, ErrActionRunnerSessionUnavailable) || activated != nil || revoked != nil || changed { + t.Fatalf("vanished transport activation = activated %#v revoked %#v changed %v err %v", activated, revoked, changed, err) + } + if promotionCalls != 1 { + t.Fatalf("promotion calls = %d, want 1", promotionCalls) + } + if len(cfg.APITokens) != 2 { + t.Fatalf("rolled-back inventory = %#v", cfg.APITokens) + } + var foundPredecessor, foundPending bool + for _, record := range cfg.APITokens { + switch record.ID { + case predecessor.ID: + foundPredecessor = record.ExpiresAt == nil + case pending.ID: + foundPending = record.ExpiresAt != nil && record.Metadata[ActionRunnerActivationPendingMetadataKey] == "true" + } + } + if !foundPredecessor || !foundPending { + t.Fatalf("rollback did not restore predecessor and pending replacement: %#v", cfg.APITokens) + } + persisted, err := config.NewConfigPersistence(cfg.DataPath).LoadAPITokens() + if err != nil { + t.Fatal(err) + } + if len(persisted) != 2 { + t.Fatalf("durable rollback inventory = %#v", persisted) + } +} + +func TestActivateActionRunnerPromotionRollbackPersistenceFailureKeepsLastDurableActivation(t *testing.T) { + cfg := &config.Config{DataPath: t.TempDir()} + _, predecessor, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{OrgID: "org-a", AgentID: "machine-123", Hostname: "node.example"}) + if err != nil { + t.Fatal(err) + } + if _, _, _, err := ActivateActionRunnerAndPersist(cfg, config.NewConfigPersistence(cfg.DataPath), predecessor.ID, "machine-123", "node.example"); err != nil { + t.Fatal(err) + } + _, pending, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{OrgID: "org-a", AgentID: "machine-123", Hostname: "node.example"}) + if err != nil { + t.Fatal(err) + } + persistence := config.NewConfigPersistence(cfg.DataPath) + // The pre-existing durable predecessor inventory causes activation to write + // one backup plus the new token file. Permit both, then fail the compensating + // rollback writes. + persistence.SetFileSystem(&actionRunnerFailAfterWritesFS{allowWrites: 2}) + activated, revoked, changed, err := ActivateActionRunnerAndPersistWithPromotion(cfg, persistence, pending.ID, "machine-123", "node.example", func() bool { return false }) + if !errors.Is(err, ErrActionRunnerActivationIndeterminate) || activated == nil || activated.ID != pending.ID || len(revoked) != 1 || revoked[0].ID != predecessor.ID || !changed { + t.Fatalf("rollback persistence failure = activated %#v revoked %#v changed %v err %v", activated, revoked, changed, err) + } + if len(cfg.APITokens) != 1 || cfg.APITokens[0].ID != pending.ID || cfg.APITokens[0].ExpiresAt != nil || cfg.APITokens[0].Metadata[ActionRunnerActivationPendingMetadataKey] != "" { + t.Fatalf("memory diverged from last durable activation: %#v", cfg.APITokens) + } + persisted, err := config.NewConfigPersistence(cfg.DataPath).LoadAPITokens() + if err != nil { + t.Fatal(err) + } + if len(persisted) != 1 || persisted[0].ID != pending.ID || persisted[0].ExpiresAt != nil || persisted[0].Metadata[ActionRunnerActivationPendingMetadataKey] != "" { + t.Fatalf("last durable activation inventory = %#v", persisted) + } +} + +func TestCancelPendingActionRunnerAndPersistSerializesWithActivationInBothOrders(t *testing.T) { + seed := func(t *testing.T) (*config.Config, *config.ConfigPersistence, *config.APITokenRecord, *config.APITokenRecord) { + t.Helper() + cfg := &config.Config{DataPath: t.TempDir()} + _, first, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{OrgID: "org-a", AgentID: "machine-123", Hostname: "node.example"}) + if err != nil { + t.Fatal(err) + } + if _, _, _, err := ActivateActionRunnerAndPersist(cfg, config.NewConfigPersistence(cfg.DataPath), first.ID, "machine-123", "node.example"); err != nil { + t.Fatal(err) + } + _, second, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{OrgID: "org-a", AgentID: "machine-123", Hostname: "node.example"}) + if err != nil { + t.Fatal(err) + } + return cfg, config.NewConfigPersistence(cfg.DataPath), first, second + } + + t.Run("cancel wins", func(t *testing.T) { + cfg, persistence, first, second := seed(t) + removed, err := CancelPendingActionRunnerAndPersist(cfg, persistence, second.ID, "org-a", "machine-123", "NODE") + if err != nil || removed == nil || removed.ID != second.ID { + t.Fatalf("cancel = (%#v, %v)", removed, err) + } + if len(cfg.APITokens) != 1 || cfg.APITokens[0].ID != first.ID { + t.Fatalf("cancelled inventory = %#v", cfg.APITokens) + } + if _, _, _, err := ActivateActionRunnerAndPersist(cfg, persistence, second.ID, "machine-123", "node.example"); !errors.Is(err, ErrRecord) { + t.Fatalf("late activation error = %v, want ErrRecord", err) + } + }) + + t.Run("activation wins", func(t *testing.T) { + cfg, persistence, _, second := seed(t) + if _, _, changed, err := ActivateActionRunnerAndPersist(cfg, persistence, second.ID, "machine-123", "node.example"); err != nil || !changed { + t.Fatalf("activation = changed %v, error %v", changed, err) + } + if removed, err := CancelPendingActionRunnerAndPersist(cfg, persistence, second.ID, "org-a", "machine-123", "node.example"); !errors.Is(err, ErrActionRunnerAlreadyActivated) || removed != nil { + t.Fatalf("post-commit cancel = (%#v, %v)", removed, err) + } + if len(cfg.APITokens) != 1 || cfg.APITokens[0].ID != second.ID || cfg.APITokens[0].ExpiresAt != nil { + t.Fatalf("activated inventory = %#v", cfg.APITokens) + } + }) +} + +func TestCancelPendingActionRunnerAndPersistContendsUnderOneDurableTransaction(t *testing.T) { + seed := func(t *testing.T) (*config.Config, *config.ConfigPersistence, *config.APITokenRecord, *config.APITokenRecord) { + t.Helper() + cfg := &config.Config{DataPath: t.TempDir()} + _, first, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{OrgID: "org-a", AgentID: "machine-123", Hostname: "node.example"}) + if err != nil { + t.Fatal(err) + } + if _, _, _, err := ActivateActionRunnerAndPersist(cfg, config.NewConfigPersistence(cfg.DataPath), first.ID, "machine-123", "node.example"); err != nil { + t.Fatal(err) + } + _, pending, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{OrgID: "org-a", AgentID: "machine-123", Hostname: "node.example"}) + if err != nil { + t.Fatal(err) + } + return cfg, config.NewConfigPersistence(cfg.DataPath), first, pending + } + waitSignal := func(t *testing.T, signal <-chan struct{}, label string) { + t.Helper() + select { + case <-signal: + case <-time.After(2 * time.Second): + t.Fatalf("timed out waiting for %s", label) + } + } + waitErr := func(t *testing.T, result <-chan error, label string) error { + t.Helper() + select { + case err := <-result: + return err + case <-time.After(2 * time.Second): + t.Fatalf("timed out waiting for %s", label) + return nil + } + } + + t.Run("cancel owns lock before activation", func(t *testing.T) { + cfg, persistence, predecessor, pending := seed(t) + fs := &actionRunnerTestFS{entered: make(chan struct{}), release: make(chan struct{})} + persistence.SetFileSystem(fs) + cancelResult := make(chan error, 1) + go func() { + _, err := CancelPendingActionRunnerAndPersist(cfg, persistence, pending.ID, "org-a", "machine-123", "node.example") + cancelResult <- err + }() + waitSignal(t, fs.entered, "cancel persistence") + activateResult := make(chan error, 1) + go func() { + _, _, _, err := ActivateActionRunnerAndPersist(cfg, persistence, pending.ID, "machine-123", "node.example") + activateResult <- err + }() + close(fs.release) + if err := waitErr(t, cancelResult, "cancel result"); err != nil { + t.Fatalf("cancel error = %v", err) + } + if err := waitErr(t, activateResult, "late activation result"); !errors.Is(err, ErrRecord) { + t.Fatalf("late activation error = %v, want ErrRecord", err) + } + if len(cfg.APITokens) != 1 || cfg.APITokens[0].ID != predecessor.ID { + t.Fatalf("cancel-wins inventory = %#v", cfg.APITokens) + } + }) + + t.Run("activation owns lock before cancel", func(t *testing.T) { + cfg, persistence, predecessor, pending := seed(t) + fs := &actionRunnerTestFS{entered: make(chan struct{}), release: make(chan struct{})} + persistence.SetFileSystem(fs) + activateResult := make(chan error, 1) + go func() { + _, _, _, err := ActivateActionRunnerAndPersist(cfg, persistence, pending.ID, "machine-123", "node.example") + activateResult <- err + }() + waitSignal(t, fs.entered, "activation persistence") + cancelResult := make(chan error, 1) + go func() { + _, err := CancelPendingActionRunnerAndPersist(cfg, persistence, pending.ID, "org-a", "machine-123", "node.example") + cancelResult <- err + }() + close(fs.release) + if err := waitErr(t, activateResult, "activation result"); err != nil { + t.Fatalf("activation error = %v", err) + } + if err := waitErr(t, cancelResult, "post-commit cancel result"); !errors.Is(err, ErrActionRunnerAlreadyActivated) { + t.Fatalf("post-commit cancel error = %v, want ErrActionRunnerAlreadyActivated", err) + } + if len(cfg.APITokens) != 1 || cfg.APITokens[0].ID != pending.ID || cfg.APITokens[0].ExpiresAt != nil || cfg.APITokens[0].Metadata[ActionRunnerActivationPendingMetadataKey] != "" || cfg.APITokens[0].ID == predecessor.ID { + t.Fatalf("activation-wins inventory = %#v", cfg.APITokens) + } + }) +} + +func TestCancelPendingActionRunnerAndPersistFailureNeverAuthorizesRollback(t *testing.T) { + cfg := &config.Config{DataPath: t.TempDir()} + _, pending, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{OrgID: "org-a", AgentID: "machine-123", Hostname: "node.example"}) + if err != nil { + t.Fatal(err) + } + persistence := config.NewConfigPersistence(t.TempDir()) + persistence.SetFileSystem(&actionRunnerTestFS{writeErr: errors.New("injected persistence failure")}) + if removed, err := CancelPendingActionRunnerAndPersist(cfg, persistence, pending.ID, "org-a", "machine-123", "node.example"); !errors.Is(err, ErrPersist) || removed != nil { + t.Fatalf("persistence failure = (%#v, %v)", removed, err) + } + if len(cfg.APITokens) != 1 || cfg.APITokens[0].ID != pending.ID || cfg.APITokens[0].Metadata[ActionRunnerActivationPendingMetadataKey] != "true" { + t.Fatalf("failed cancel changed inventory = %#v", cfg.APITokens) + } + if removed, err := CancelPendingActionRunnerAndPersist(cfg, nil, pending.ID, "org-a", "machine-123", "node.example"); !errors.Is(err, ErrPersist) || removed != nil { + t.Fatalf("nil persistence = (%#v, %v)", removed, err) + } +} + func TestIssueActionRunnerAndPersistRestoresReplacedCredentialOnPersistenceFailure(t *testing.T) { cfg := &config.Config{DataPath: t.TempDir()} _, prior, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{ diff --git a/internal/api/collector_authority.go b/internal/api/collector_authority.go index bc61abc74..b2a5fb41d 100644 --- a/internal/api/collector_authority.go +++ b/internal/api/collector_authority.go @@ -10,6 +10,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" "github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens" "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth" ) @@ -75,7 +76,7 @@ func (r *Router) handleReduceCollectorAuthority(w http.ResponseWriter, req *http boundHostname := strings.TrimSpace(candidate.Metadata["bound_hostname"]) if len(orgs) != 1 || strings.TrimSpace(orgs[0]) != organizationID || boundAgentID == "" || boundAgentID != payload.AgentID || - boundHostname == "" || !strings.EqualFold(boundHostname, payload.Hostname) { + boundHostname == "" || !collectorAuthorityHostnamesEquivalent(boundHostname, payload.Hostname) { config.Mu.Unlock() http.Error(w, "Collector credential binding mismatch", http.StatusForbidden) return @@ -124,3 +125,9 @@ func (r *Router) handleReduceCollectorAuthority(w http.ResponseWriter, req *http LogAuditEventForTenant(organizationID, "collector_authority_reduced", caller.Name, GetClientIP(req), req.URL.Path, true, "Reduced collector credential to its exact monitoring scope allowlist") w.WriteHeader(http.StatusNoContent) } + +func collectorAuthorityHostnamesEquivalent(bound, requested string) bool { + bound = strings.TrimSpace(bound) + requested = strings.TrimSpace(requested) + return strings.EqualFold(bound, requested) || unifiedresources.HostnamesEquivalent(bound, requested) +} diff --git a/internal/api/collector_authority_test.go b/internal/api/collector_authority_test.go index fa331ca9a..cf6fe35e0 100644 --- a/internal/api/collector_authority_test.go +++ b/internal/api/collector_authority_test.go @@ -74,6 +74,30 @@ func TestReduceCollectorAuthorityRejectsCrossHostBinding(t *testing.T) { } } +func TestCollectorAuthorityHostnamesEquivalent(t *testing.T) { + for _, test := range []struct { + name string + bound string + requested string + want bool + }{ + {name: "short to FQDN", bound: "node-a", requested: "node-a.example.test", want: true}, + {name: "FQDN to short", bound: "node-a.example.test", requested: "node-a", want: true}, + {name: "same FQDN case and trailing dot", bound: "Node-A.Example.Test.", requested: "node-a.example.test", want: true}, + {name: "distinct same-label FQDN", bound: "node-a.one.test", requested: "node-a.two.test", want: false}, + {name: "same IPv4 literal", bound: "192.0.2.10", requested: "192.0.2.10", want: true}, + {name: "distinct IPv4 literal", bound: "192.0.2.10", requested: "192.0.2.11", want: false}, + {name: "same IPv6 literal", bound: "2001:DB8::10", requested: "2001:db8::10", want: true}, + {name: "IP and hostname", bound: "192.0.2.10", requested: "node-a", want: false}, + } { + t.Run(test.name, func(t *testing.T) { + if got := collectorAuthorityHostnamesEquivalent(test.bound, test.requested); got != test.want { + t.Fatalf("collectorAuthorityHostnamesEquivalent(%q, %q) = %v, want %v", test.bound, test.requested, got, test.want) + } + }) + } +} + func TestReduceCollectorAuthorityRejectsUnboundCredential(t *testing.T) { record := &config.APITokenRecord{ID: "token-a", OrgID: "org-a", Scopes: []string{config.ScopeAgentReport, config.ScopeAgentExec}} cfg := &config.Config{APITokens: []config.APITokenRecord{*record}} diff --git a/internal/api/route_inventory_test.go b/internal/api/route_inventory_test.go index bee1ed84b..ffae2879d 100644 --- a/internal/api/route_inventory_test.go +++ b/internal/api/route_inventory_test.go @@ -380,6 +380,7 @@ var allRouteAllowlist = []string{ "/api/agents/docker/report", "/api/agents/kubernetes/report", "/api/agents/action-runner/credential", + "/api/agents/action-runner/credential/activation", "/api/agents/collector/reduce-authority", "/api/agents/agent/report", "/api/agents/host/report", diff --git a/internal/api/router_routes_registration.go b/internal/api/router_routes_registration.go index 8f10c4dec..1b3379d4d 100644 --- a/internal/api/router_routes_registration.go +++ b/internal/api/router_routes_registration.go @@ -56,6 +56,7 @@ func (r *Router) registerConfigSystemRoutes(updateHandlers *UpdateHandlers) { r.mux.HandleFunc("/api/agents/kubernetes/report", RequireAuth(r.config, RequireScope(config.ScopeKubernetesReport, r.kubernetesAgentHandlers.HandleReport))) r.mux.HandleFunc("/api/agents/agent/report", RequireAuth(r.config, RequireScope(config.ScopeAgentReport, r.unifiedAgentHandlers.HandleReport))) r.mux.HandleFunc("/api/agents/action-runner/credential", RequireAuth(r.config, actionRunnerCredentialRoute(r.config, r.handleIssueActionRunnerCredential, r.handleActivateActionRunnerCredential, r.handleSelfRevokeActionRunnerCredential))) + r.mux.HandleFunc("/api/agents/action-runner/credential/activation", RequireAuth(r.config, RequireScope(config.ScopeAgentExec, r.handleCancelPendingActionRunnerCredentialActivation))) r.mux.HandleFunc("/api/agents/collector/reduce-authority", RequireAuth(r.config, RequireScope(config.ScopeAgentReport, r.handleReduceCollectorAuthority))) r.mux.HandleFunc("/api/agents/host/report", wrapLegacyHostAlias("/api/agents/host/report", RequireAuth(r.config, RequireScope(config.ScopeAgentReport, r.unifiedAgentHandlers.HandleReport)))) r.mux.HandleFunc("/api/agents/agent/lookup", RequireAuth(r.config, RequireScope(config.ScopeAgentReport, r.unifiedAgentHandlers.HandleLookup))) diff --git a/internal/api/security_regression_test.go b/internal/api/security_regression_test.go index 0525ad445..7e4ad8470 100644 --- a/internal/api/security_regression_test.go +++ b/internal/api/security_regression_test.go @@ -737,7 +737,7 @@ func TestActionRunnerCredentialRotationRevokesPreviousSecretOnlyAtActivation(t * if err != nil { t.Fatal(err) } - if _, _, _, err := agenttokens.ActivateActionRunnerAndPersist(cfg, nil, firstRecord.ID, "machine-a", "node.example"); err != nil { + if _, _, _, err := agenttokens.ActivateActionRunnerAndPersist(cfg, config.NewConfigPersistence(cfg.DataPath), firstRecord.ID, "machine-a", "node.example"); err != nil { t.Fatal(err) } router := &Router{config: cfg} @@ -760,7 +760,7 @@ func TestActionRunnerCredentialRotationRevokesPreviousSecretOnlyAtActivation(t * if admission, ok := router.admitAgentExecToken(secondToken, "machine-a", "renamed.example"); !ok || !admission.ActivationPending { t.Fatal("replacement action runner credential was rejected") } - if _, revoked, changed, err := agenttokens.ActivateActionRunnerAndPersist(cfg, nil, secondRecord.ID, "machine-a", "renamed.example"); err != nil || !changed || len(revoked) != 1 || revoked[0].ID != firstRecord.ID { + if _, revoked, changed, err := agenttokens.ActivateActionRunnerAndPersist(cfg, config.NewConfigPersistence(cfg.DataPath), secondRecord.ID, "machine-a", "renamed.example"); err != nil || !changed || len(revoked) != 1 || revoked[0].ID != firstRecord.ID { t.Fatalf("rotation activation = revoked %#v, changed %v, error %v", revoked, changed, err) } if _, ok := router.admitAgentExecToken(firstToken, "machine-a", "node.example"); ok { diff --git a/internal/collectorlifecycle/client.go b/internal/collectorlifecycle/client.go new file mode 100644 index 000000000..3e060dfb6 --- /dev/null +++ b/internal/collectorlifecycle/client.go @@ -0,0 +1,323 @@ +package collectorlifecycle + +import ( + "bytes" + "context" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/agenttls" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" + "github.com/rcourtman/pulse-go-rewrite/pkg/securityutil" +) + +const ( + defaultRequestTimeout = 15 * time.Second + maximumResponseBytes = 64 << 10 + maximumBearerBytes = 4 << 10 +) + +var ( + // ErrRegistrationPending means the server has not yet published a fresh + // registration for the requested collector identity. Installers may retry. + ErrRegistrationPending = errors.New("collector registration is not confirmed") + // ErrCredentialRejected means retrying cannot repair the collector bearer. + ErrCredentialRejected = errors.New("collector credential was rejected") + loadSystemCertPool = x509.SystemCertPool +) + +// Config contains the complete trust and credential inputs for the narrow +// installer lifecycle client. The bearer is deliberately accepted only as a +// file path so it never needs to appear in process arguments or curl config. +type Config struct { + PulseURL string + TokenFile string + CACertPath string + ServerFingerprint string + // TokenOwnerUID permits the one dedicated collector account that may own a + // migration-era runtime.token. Root is always trusted; no other UID is. + TokenOwnerUID *uint64 + Timeout time.Duration +} + +// Client can only reduce the current collector's authority and inspect its +// authoritative registration. It deliberately exposes no general request API. +type Client struct { + baseURL *url.URL + bearer string + http *http.Client +} + +// Registration is the bounded server registration evidence consumed by the +// safe-profile transaction. +type Registration struct { + AgentID string + Hostname string + LastSeen time.Time +} + +// New validates the destination before reading the bearer and constructs a +// redirect-denying, system-CA/custom-CA/exact-leaf-pin-aware HTTP client. +func New(config Config) (*Client, error) { + baseURL, err := securityutil.NormalizePulseHTTPBaseURL(config.PulseURL) + if err != nil { + return nil, fmt.Errorf("validate collector lifecycle URL: %w", err) + } + if baseURL.Scheme == "http" && !exactLifecycleLoopbackHost(baseURL.Hostname()) { + return nil, errors.New("collector lifecycle plaintext HTTP is allowed only for localhost, 127.0.0.1, or ::1") + } + if baseURL.Scheme == "http" && (strings.TrimSpace(config.CACertPath) != "" || strings.TrimSpace(config.ServerFingerprint) != "") { + return nil, errors.New("collector lifecycle TLS trust options require an HTTPS URL") + } + bearer, err := readPrivateBearer(config.TokenFile, config.TokenOwnerUID) + if err != nil { + return nil, err + } + tlsConfig, err := agenttls.NewClientTLSConfig(config.CACertPath, false, config.ServerFingerprint) + if err != nil { + return nil, fmt.Errorf("configure collector lifecycle TLS: %w", err) + } + if baseURL.Scheme == "https" && strings.TrimSpace(config.CACertPath) == "" && strings.TrimSpace(config.ServerFingerprint) == "" { + roots, err := loadSystemCertPool() + if err != nil || roots == nil { + return nil, fmt.Errorf("load system certificate authorities: %w", err) + } + tlsConfig.RootCAs = roots + } + timeout := config.Timeout + if timeout <= 0 { + timeout = defaultRequestTimeout + } + return &Client{ + baseURL: baseURL, + bearer: bearer, + http: &http.Client{ + Timeout: timeout, + Transport: &http.Transport{TLSClientConfig: tlsConfig}, + CheckRedirect: func(req *http.Request, _ []*http.Request) error { + return fmt.Errorf("collector lifecycle server returned redirect to %s; use the final Pulse URL explicitly", req.URL) + }, + }, + }, nil +} + +func exactLifecycleLoopbackHost(host string) bool { + host = strings.TrimSpace(strings.Trim(host, "[]")) + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +// Close releases idle transport connections held by the lifecycle client. +func (c *Client) Close() { + if c != nil && c.http != nil { + c.http.CloseIdleConnections() + } +} + +// ReduceAuthority durably removes execution and cross-host management scopes +// from the exact bearer-bound collector. Only HTTP 204 is success authority. +func (c *Client) ReduceAuthority(ctx context.Context, agentID, hostname string) error { + agentID = strings.TrimSpace(agentID) + hostname = strings.TrimSpace(hostname) + if !validBoundedIdentity(agentID, 256) || !validBoundedIdentity(hostname, 253) { + return errors.New("collector authority reduction requires a valid agent identity and hostname") + } + body, err := json.Marshal(map[string]string{"agentId": agentID, "hostname": hostname}) + if err != nil { + return err + } + response, err := c.do(ctx, http.MethodPost, "/api/agents/collector/reduce-authority", bytes.NewReader(body), "application/json") + if err != nil { + return fmt.Errorf("reduce collector authority: %w", err) + } + defer response.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maximumResponseBytes)) + if response.StatusCode != http.StatusNoContent { + return fmt.Errorf("reduce collector authority: server returned %s", response.Status) + } + return nil +} + +// VerifyRegistration asks the authenticated server for the exact collector +// registration. previousLastSeen, when non-zero, requires a later observation +// so a stopped predecessor row cannot authorize a safe-profile commit. +func (c *Client) VerifyRegistration(ctx context.Context, agentID, hostname string, previousLastSeen time.Time) (Registration, error) { + agentID = strings.TrimSpace(agentID) + hostname = strings.TrimSpace(hostname) + query := url.Values{} + switch { + case validBoundedIdentity(agentID, 256): + query.Set("id", agentID) + case validBoundedIdentity(hostname, 253): + query.Set("hostname", hostname) + default: + return Registration{}, errors.New("collector registration verification requires a valid agent identity or hostname") + } + response, err := c.do(ctx, http.MethodGet, "/api/agents/agent/lookup?"+query.Encode(), nil, "") + if err != nil { + return Registration{}, fmt.Errorf("verify collector registration: %w", err) + } + defer response.Body.Close() + + limited := io.LimitReader(response.Body, maximumResponseBytes+1) + encoded, readErr := io.ReadAll(limited) + if readErr != nil { + return Registration{}, fmt.Errorf("read collector registration response: %w", readErr) + } + if len(encoded) > maximumResponseBytes { + return Registration{}, errors.New("collector registration response exceeds the size limit") + } + switch response.StatusCode { + case http.StatusUnauthorized: + return Registration{}, fmt.Errorf("%w: server returned %s", ErrCredentialRejected, response.Status) + case http.StatusForbidden: + var failure struct { + Code string `json:"code"` + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if json.Unmarshal(encoded, &failure) == nil && (failure.Code == "agent_lookup_forbidden" || failure.Error.Code == "agent_lookup_forbidden") { + return Registration{}, fmt.Errorf("%w: prior credential binding is still visible", ErrRegistrationPending) + } + return Registration{}, fmt.Errorf("%w: server returned %s", ErrCredentialRejected, response.Status) + case http.StatusOK: + // Continue below. + default: + return Registration{}, fmt.Errorf("%w: server returned %s", ErrRegistrationPending, response.Status) + } + + var payload struct { + Success bool `json:"success"` + Agent struct { + ID string `json:"id"` + Hostname string `json:"hostname"` + LastSeen time.Time `json:"lastSeen"` + } `json:"agent"` + } + decoder := json.NewDecoder(bytes.NewReader(encoded)) + if err := decoder.Decode(&payload); err != nil { + return Registration{}, fmt.Errorf("%w: invalid server response", ErrRegistrationPending) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return Registration{}, fmt.Errorf("%w: invalid trailing server response", ErrRegistrationPending) + } + payload.Agent.ID = strings.TrimSpace(payload.Agent.ID) + payload.Agent.Hostname = strings.TrimSpace(payload.Agent.Hostname) + if !payload.Success || !validBoundedIdentity(payload.Agent.ID, 256) || !validBoundedIdentity(payload.Agent.Hostname, 253) || payload.Agent.LastSeen.IsZero() { + return Registration{}, fmt.Errorf("%w: incomplete server response", ErrRegistrationPending) + } + if agentID != "" && payload.Agent.ID != agentID { + return Registration{}, fmt.Errorf("%w: server returned a different agent identity", ErrRegistrationPending) + } + if hostname != "" && !strings.EqualFold(payload.Agent.Hostname, hostname) && !unifiedresources.HostnamesEquivalent(payload.Agent.Hostname, hostname) { + return Registration{}, fmt.Errorf("%w: server returned a different collector hostname", ErrRegistrationPending) + } + lastSeen := payload.Agent.LastSeen.UTC() + if !previousLastSeen.IsZero() && !lastSeen.After(previousLastSeen.UTC()) { + return Registration{}, fmt.Errorf("%w: registration freshness did not advance", ErrRegistrationPending) + } + return Registration{AgentID: payload.Agent.ID, Hostname: payload.Agent.Hostname, LastSeen: lastSeen}, nil +} + +func (c *Client) do(ctx context.Context, method, path string, body io.Reader, contentType string) (*http.Response, error) { + if c == nil || c.baseURL == nil || c.http == nil || c.bearer == "" { + return nil, errors.New("collector lifecycle client is not initialized") + } + target := strings.TrimRight(c.baseURL.String(), "/") + path + request, err := http.NewRequestWithContext(ctx, method, target, body) + if err != nil { + return nil, err + } + request.Header.Set("Authorization", "Bearer "+c.bearer) + if contentType != "" { + request.Header.Set("Content-Type", contentType) + } + return c.http.Do(request) +} + +func readPrivateBearer(path string, tokenOwnerUID *uint64) (string, error) { + path = strings.TrimSpace(path) + if path == "" { + return "", errors.New("collector lifecycle token file is required") + } + file, err := openCredentialFile(path) + if err != nil { + return "", fmt.Errorf("open collector lifecycle token file: %w", err) + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return "", fmt.Errorf("inspect collector lifecycle token file descriptor: %w", err) + } + // The typed collector credential is root-owned 0640 with read access only + // for its dedicated service group. Group write/execute and all other access + // are forbidden; 0600 remains valid for legacy/root-only installs. + if !info.Mode().IsRegular() || info.Mode().Perm()&0037 != 0 { + return "", errors.New("collector lifecycle token file must be a private regular file") + } + if err := validateCredentialFileOwner(path, info, tokenOwnerUID); err != nil { + return "", err + } + encoded, err := io.ReadAll(io.LimitReader(file, maximumBearerBytes+1)) + if err != nil { + return "", fmt.Errorf("read collector lifecycle token file: %w", err) + } + if len(encoded) > maximumBearerBytes { + return "", errors.New("collector lifecycle token file exceeds the size limit") + } + bearer := strings.TrimSpace(string(encoded)) + if bearer == "" || strings.ContainsAny(bearer, "\r\n") { + return "", errors.New("collector lifecycle token file is empty or invalid") + } + return bearer, nil +} + +// ReadPrivateValueFile reads one bounded private local value through the same +// single-open, no-follow, nonblocking descriptor boundary used for collector +// credentials. Root is always an allowed owner; tokenOwnerUID may name the +// one dedicated collector account for migration-era identity state. +func ReadPrivateValueFile(path string, tokenOwnerUID *uint64) (string, error) { + return readPrivateBearer(path, tokenOwnerUID) +} + +// ReadAgentIDFile resolves the identity that binds a separate action runner +// while retaining the same single-open, no-follow owner and size checks used +// for the collector credential. +func ReadAgentIDFile(path string, tokenOwnerUID *uint64) (string, error) { + identity, err := ReadPrivateValueFile(path, tokenOwnerUID) + if err != nil { + return "", fmt.Errorf("read collector agent identity: %w", err) + } + if !validBoundedIdentity(identity, 128) { + return "", errors.New("collector agent identity file is invalid") + } + return identity, nil +} + +func validBoundedIdentity(value string, maximum int) bool { + if value == "" || len(value) > maximum { + return false + } + for index, character := range value { + if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' { + continue + } + if index > 0 && (character == '.' || character == '_' || character == ':' || character == '-') { + continue + } + return false + } + return true +} diff --git a/internal/collectorlifecycle/client_test.go b/internal/collectorlifecycle/client_test.go new file mode 100644 index 000000000..7d7c64001 --- /dev/null +++ b/internal/collectorlifecycle/client_test.go @@ -0,0 +1,360 @@ +package collectorlifecycle + +import ( + "context" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" +) + +const testBearer = "collector-private-bearer" + +func TestClientUsesSystemCA(t *testing.T) { + server := newTLSServer(t, successfulReductionHandler(t)) + pool := x509.NewCertPool() + pool.AddCert(server.Certificate()) + previousSystemCertPool := loadSystemCertPool + loadSystemCertPool = func() (*x509.CertPool, error) { return pool, nil } + t.Cleanup(func() { loadSystemCertPool = previousSystemCertPool }) + + client, err := New(Config{PulseURL: server.URL, TokenFile: writeToken(t), TokenOwnerUID: testTokenOwnerUID()}) + if err != nil { + t.Fatalf("New: %v", err) + } + defer client.Close() + if err := client.ReduceAuthority(context.Background(), "agent-1", "host.local"); err != nil { + t.Fatalf("ReduceAuthority with system CA: %v", err) + } +} + +func TestClientUsesCustomCA(t *testing.T) { + server := newTLSServer(t, successfulReductionHandler(t)) + client, err := New(Config{ + PulseURL: server.URL, TokenFile: writeToken(t), TokenOwnerUID: testTokenOwnerUID(), CACertPath: writeServerCertificate(t, server), + }) + if err != nil { + t.Fatalf("New: %v", err) + } + defer client.Close() + if err := client.ReduceAuthority(context.Background(), "agent-1", "host.local"); err != nil { + t.Fatalf("ReduceAuthority with custom CA: %v", err) + } +} + +func TestClientUsesExactDERLeafPin(t *testing.T) { + server := newTLSServer(t, successfulReductionHandler(t)) + fingerprint := sha256.Sum256(server.Certificate().Raw) + client, err := New(Config{ + PulseURL: server.URL, TokenFile: writeToken(t), TokenOwnerUID: testTokenOwnerUID(), ServerFingerprint: hex.EncodeToString(fingerprint[:]), + }) + if err != nil { + t.Fatalf("New: %v", err) + } + defer client.Close() + if err := client.ReduceAuthority(context.Background(), "agent-1", "host.local"); err != nil { + t.Fatalf("ReduceAuthority with exact pin: %v", err) + } +} + +func TestFingerprintMismatchNeverAuthorizesHandler(t *testing.T) { + var authorized atomic.Bool + server := newTLSServer(t, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if request.Header.Get("Authorization") != "" { + authorized.Store(true) + } + w.WriteHeader(http.StatusNoContent) + })) + client, err := New(Config{ + PulseURL: server.URL, TokenFile: writeToken(t), TokenOwnerUID: testTokenOwnerUID(), ServerFingerprint: strings.Repeat("00", sha256.Size), + }) + if err != nil { + t.Fatalf("New: %v", err) + } + defer client.Close() + if err := client.ReduceAuthority(context.Background(), "agent-1", "host.local"); err == nil || !strings.Contains(err.Error(), "fingerprint mismatch") { + t.Fatalf("ReduceAuthority error = %v, want fingerprint mismatch", err) + } + if authorized.Load() { + t.Fatal("mismatched TLS peer received the collector Authorization header") + } +} + +func TestNonLoopbackHTTPIsRejectedBeforeTokenRead(t *testing.T) { + for _, rawURL := range []string{"http://192.0.2.10:7655", "http://agent.localhost:7655"} { + _, err := New(Config{PulseURL: rawURL, TokenFile: filepath.Join(t.TempDir(), "missing")}) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "http") { + t.Fatalf("New(%q) error = %v, want non-loopback HTTP rejection", rawURL, err) + } + } +} + +func TestExactLifecycleLoopbackHost(t *testing.T) { + for _, host := range []string{"localhost", "LOCALHOST", "127.0.0.1", "127.0.0.2", "::1", "::ffff:127.0.0.1"} { + if !exactLifecycleLoopbackHost(host) { + t.Errorf("exactLifecycleLoopbackHost(%q) = false", host) + } + } + for _, host := range []string{"agent.localhost", "0.0.0.0", "192.0.2.10"} { + if exactLifecycleLoopbackHost(host) { + t.Errorf("exactLifecycleLoopbackHost(%q) = true", host) + } + } +} + +func TestTokenFileRejectsSymlinkAndOversizeContent(t *testing.T) { + directory := t.TempDir() + target := filepath.Join(directory, "target.token") + if err := os.WriteFile(target, []byte(testBearer), 0600); err != nil { + t.Fatal(err) + } + link := filepath.Join(directory, "link.token") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + if _, err := New(Config{PulseURL: "http://127.0.0.1:7655", TokenFile: link, TokenOwnerUID: testTokenOwnerUID()}); err == nil { + t.Fatal("New accepted symlinked token file") + } + oversize := filepath.Join(directory, "oversize.token") + if err := os.WriteFile(oversize, []byte(strings.Repeat("x", maximumBearerBytes+1)), 0600); err != nil { + t.Fatal(err) + } + if _, err := New(Config{PulseURL: "http://127.0.0.1:7655", TokenFile: oversize, TokenOwnerUID: testTokenOwnerUID()}); err == nil || !strings.Contains(err.Error(), "size limit") { + t.Fatalf("New oversize token error = %v", err) + } +} + +func TestLoopbackHTTPIsAccepted(t *testing.T) { + server := httptest.NewServer(successfulReductionHandler(t)) + defer server.Close() + client, err := New(Config{PulseURL: server.URL, TokenFile: writeToken(t), TokenOwnerUID: testTokenOwnerUID()}) + if err != nil { + t.Fatalf("New: %v", err) + } + defer client.Close() + if err := client.ReduceAuthority(context.Background(), "agent-1", "host.local"); err != nil { + t.Fatalf("ReduceAuthority over loopback HTTP: %v", err) + } +} + +func TestConfiguredCollectorOwned0640TokenIsAccepted(t *testing.T) { + server := httptest.NewServer(successfulReductionHandler(t)) + defer server.Close() + tokenFile := writeToken(t) + if err := os.Chmod(tokenFile, 0640); err != nil { + t.Fatal(err) + } + client, err := New(Config{PulseURL: server.URL, TokenFile: tokenFile, TokenOwnerUID: testTokenOwnerUID()}) + if err != nil { + t.Fatalf("New: %v", err) + } + defer client.Close() + if err := client.ReduceAuthority(context.Background(), "agent-1", "host.local"); err != nil { + t.Fatalf("ReduceAuthority with collector-owned 0640 token: %v", err) + } +} + +func TestLifecycleTransportNeverUsesEnvironmentProxy(t *testing.T) { + var proxyRequests atomic.Int32 + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + proxyRequests.Add(1) + http.Error(w, "proxy must not be used", http.StatusBadGateway) + })) + defer proxy.Close() + t.Setenv("HTTP_PROXY", proxy.URL) + t.Setenv("HTTPS_PROXY", proxy.URL) + server := httptest.NewServer(successfulReductionHandler(t)) + defer server.Close() + client, err := New(Config{PulseURL: server.URL, TokenFile: writeToken(t), TokenOwnerUID: testTokenOwnerUID()}) + if err != nil { + t.Fatalf("New: %v", err) + } + defer client.Close() + transport, ok := client.http.Transport.(*http.Transport) + if !ok { + t.Fatalf("collector lifecycle transport = %T, want *http.Transport", client.http.Transport) + } + if transport.Proxy != nil { + t.Fatal("collector lifecycle transport configured an environment proxy") + } + if err := client.ReduceAuthority(context.Background(), "agent-1", "host.local"); err != nil { + t.Fatalf("ReduceAuthority: %v", err) + } + if proxyRequests.Load() != 0 { + t.Fatalf("environment proxy received %d lifecycle requests", proxyRequests.Load()) + } +} + +func TestRedirectIsRejectedWithoutAuthorizingDestination(t *testing.T) { + var destinationAuthorized atomic.Bool + destination := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if request.Header.Get("Authorization") != "" { + destinationAuthorized.Store(true) + } + w.WriteHeader(http.StatusNoContent) + })) + defer destination.Close() + source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + http.Redirect(w, request, destination.URL+"/forged-success", http.StatusTemporaryRedirect) + })) + defer source.Close() + + client, err := New(Config{PulseURL: source.URL, TokenFile: writeToken(t), TokenOwnerUID: testTokenOwnerUID()}) + if err != nil { + t.Fatalf("New: %v", err) + } + defer client.Close() + err = client.ReduceAuthority(context.Background(), "agent-1", "host.local") + if err == nil || !strings.Contains(err.Error(), "returned redirect") { + t.Fatalf("ReduceAuthority error = %v, want redirect rejection", err) + } + if destinationAuthorized.Load() { + t.Fatal("redirect destination received the collector Authorization header") + } +} + +func TestVerifyRegistrationRequiresFreshAuthenticatedEvidence(t *testing.T) { + prior := time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if got := request.Header.Get("Authorization"); got != "Bearer "+testBearer { + t.Errorf("Authorization = %q", got) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if request.URL.Query().Get("id") != "agent-1" { + t.Errorf("lookup id = %q", request.URL.Query().Get("id")) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"agent":{"id":"agent-1","hostname":"host.local","lastSeen":"2026-08-30T12:00:01Z"}}`)) + })) + defer server.Close() + client, err := New(Config{PulseURL: server.URL, TokenFile: writeToken(t), TokenOwnerUID: testTokenOwnerUID()}) + if err != nil { + t.Fatalf("New: %v", err) + } + defer client.Close() + registration, err := client.VerifyRegistration(context.Background(), "agent-1", "", prior) + if err != nil { + t.Fatalf("VerifyRegistration: %v", err) + } + if !registration.LastSeen.Equal(prior.Add(time.Second)) { + t.Fatalf("LastSeen = %s", registration.LastSeen) + } + if _, err := client.VerifyRegistration(context.Background(), "agent-1", "", prior.Add(time.Second)); !errors.Is(err, ErrRegistrationPending) { + t.Fatalf("stale VerifyRegistration error = %v, want ErrRegistrationPending", err) + } +} + +func TestVerifyRegistrationChecksBothBoundIdentities(t *testing.T) { + for _, test := range []struct { + name string + responseHostname string + wantErr bool + }{ + {name: "short and FQDN are equivalent", responseHostname: "host-1.example.test"}, + {name: "different full hostname is rejected", responseHostname: "host-1.other.test", wantErr: true}, + {name: "different short hostname is rejected", responseHostname: "host-2", wantErr: true}, + } { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"agent":{"id":"agent-1","hostname":"` + test.responseHostname + `","lastSeen":"2026-08-30T12:00:01Z"}}`)) + })) + defer server.Close() + client, err := New(Config{PulseURL: server.URL, TokenFile: writeToken(t), TokenOwnerUID: testTokenOwnerUID()}) + if err != nil { + t.Fatalf("New: %v", err) + } + defer client.Close() + _, err = client.VerifyRegistration(context.Background(), "agent-1", "host-1.example.test", time.Time{}) + if test.wantErr && !errors.Is(err, ErrRegistrationPending) { + t.Fatalf("VerifyRegistration error = %v, want ErrRegistrationPending", err) + } + if !test.wantErr && err != nil { + t.Fatalf("VerifyRegistration: %v", err) + } + }) + } +} + +func TestVerifyRegistrationClassifiesCredentialRejection(t *testing.T) { + for _, test := range []struct { + name string + code int + body string + want error + }{ + {name: "unauthorized", code: http.StatusUnauthorized, want: ErrCredentialRejected}, + {name: "scope forbidden", code: http.StatusForbidden, body: `{"code":"forbidden"}`, want: ErrCredentialRejected}, + {name: "binding pending", code: http.StatusForbidden, body: `{"code":"agent_lookup_forbidden"}`, want: ErrRegistrationPending}, + } { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(test.code) + _, _ = w.Write([]byte(test.body)) + })) + defer server.Close() + client, err := New(Config{PulseURL: server.URL, TokenFile: writeToken(t), TokenOwnerUID: testTokenOwnerUID()}) + if err != nil { + t.Fatalf("New: %v", err) + } + defer client.Close() + _, err = client.VerifyRegistration(context.Background(), "agent-1", "", time.Time{}) + if !errors.Is(err, test.want) { + t.Fatalf("VerifyRegistration error = %v, want %v", err, test.want) + } + }) + } +} + +func successfulReductionHandler(t *testing.T) http.Handler { + t.Helper() + return http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPost || request.URL.Path != "/api/agents/collector/reduce-authority" { + t.Errorf("request = %s %s", request.Method, request.URL.Path) + http.NotFound(w, request) + return + } + if got := request.Header.Get("Authorization"); got != "Bearer "+testBearer { + t.Errorf("Authorization = %q", got) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusNoContent) + }) +} + +func newTLSServer(t *testing.T, handler http.Handler) *httptest.Server { + t.Helper() + server := httptest.NewTLSServer(handler) + t.Cleanup(server.Close) + return server +} + +func writeServerCertificate(t *testing.T, server *httptest.Server) string { + t.Helper() + path := filepath.Join(t.TempDir(), "server-ca.pem") + encoded := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}) + if err := os.WriteFile(path, encoded, 0600); err != nil { + t.Fatal(err) + } + return path +} + +func writeToken(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "collector.token") + if err := os.WriteFile(path, []byte(testBearer), 0600); err != nil { + t.Fatal(err) + } + return path +} diff --git a/internal/collectorlifecycle/credential_owner_unix.go b/internal/collectorlifecycle/credential_owner_unix.go new file mode 100644 index 000000000..5a5c60a87 --- /dev/null +++ b/internal/collectorlifecycle/credential_owner_unix.go @@ -0,0 +1,40 @@ +//go:build !windows + +package collectorlifecycle + +import ( + "errors" + "fmt" + "os" + "syscall" + + "golang.org/x/sys/unix" +) + +func openCredentialFile(path string) (*os.File, error) { + // O_NONBLOCK prevents an attacker-controlled FIFO from hanging the root + // installer before descriptor metadata can reject the non-regular object. + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0) + if err != nil { + return nil, err + } + return os.NewFile(uintptr(fd), path), nil +} + +func validateCredentialFileOwner(path string, info os.FileInfo, tokenOwnerUID *uint64) error { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || stat == nil { + return errors.New("collector lifecycle token file owner is unavailable") + } + if !credentialFileOwnerAllowed(uint64(stat.Uid), tokenOwnerUID) { + return fmt.Errorf("collector lifecycle token file %s is not owned by root or the configured collector identity", path) + } + return nil +} + +func credentialFileOwnerAllowed(ownerUID uint64, tokenOwnerUID *uint64) bool { + // Lifecycle commands run as root in production, so the effective-owner case + // collapses to UID 0 there. Keeping it explicit also lets non-root package + // tests and diagnostic invocations read only their own private files. + return ownerUID == 0 || ownerUID == uint64(os.Geteuid()) || tokenOwnerUID != nil && ownerUID == *tokenOwnerUID +} diff --git a/internal/collectorlifecycle/credential_owner_unix_test.go b/internal/collectorlifecycle/credential_owner_unix_test.go new file mode 100644 index 000000000..0b36242e7 --- /dev/null +++ b/internal/collectorlifecycle/credential_owner_unix_test.go @@ -0,0 +1,86 @@ +//go:build !windows + +package collectorlifecycle + +import ( + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "golang.org/x/sys/unix" +) + +func TestCredentialFileOwnerAllowed(t *testing.T) { + collectorUID := uint64(998) + arbitraryUID := uint64(os.Geteuid()) + 1 + if arbitraryUID == collectorUID { + arbitraryUID++ + } + for _, test := range []struct { + name string + ownerUID uint64 + allowed *uint64 + want bool + }{ + {name: "root owned", ownerUID: 0, want: true}, + {name: "root owned with collector configured", ownerUID: 0, allowed: &collectorUID, want: true}, + {name: "configured collector owned", ownerUID: 998, allowed: &collectorUID, want: true}, + {name: "effective owner", ownerUID: uint64(os.Geteuid()), want: true}, + {name: "arbitrary owner without collector", ownerUID: arbitraryUID, want: false}, + {name: "arbitrary owner with collector", ownerUID: arbitraryUID, allowed: &collectorUID, want: false}, + } { + t.Run(test.name, func(t *testing.T) { + if got := credentialFileOwnerAllowed(test.ownerUID, test.allowed); got != test.want { + t.Fatalf("credentialFileOwnerAllowed(%d) = %v, want %v", test.ownerUID, got, test.want) + } + }) + } +} + +func TestReadPrivateBearerRejectsFIFOWithoutBlocking(t *testing.T) { + path := filepath.Join(t.TempDir(), "collector.token") + if err := unix.Mkfifo(path, 0600); err != nil { + t.Fatal(err) + } + ownerUID := uint64(os.Geteuid()) + started := time.Now() + if _, err := readPrivateBearer(path, &ownerUID); err == nil || !strings.Contains(err.Error(), "private regular file") { + t.Fatalf("readPrivateBearer FIFO error = %v", err) + } + if elapsed := time.Since(started); elapsed > time.Second { + t.Fatalf("FIFO rejection blocked for %s", elapsed) + } +} + +func TestOpenCredentialFileDescriptorCannotBeSymlinkSwapped(t *testing.T) { + directory := t.TempDir() + path := filepath.Join(directory, "collector.token") + if err := os.WriteFile(path, []byte("original-bearer"), 0600); err != nil { + t.Fatal(err) + } + file, err := openCredentialFile(path) + if err != nil { + t.Fatal(err) + } + defer file.Close() + if err := os.Rename(path, path+".original"); err != nil { + t.Fatal(err) + } + attacker := filepath.Join(directory, "attacker.token") + if err := os.WriteFile(attacker, []byte("attacker-bearer"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(attacker, path); err != nil { + t.Fatal(err) + } + got, err := io.ReadAll(file) + if err != nil { + t.Fatal(err) + } + if string(got) != "original-bearer" { + t.Fatalf("open descriptor read %q after path swap", got) + } +} diff --git a/internal/collectorlifecycle/credential_owner_windows.go b/internal/collectorlifecycle/credential_owner_windows.go new file mode 100644 index 000000000..89e3827bd --- /dev/null +++ b/internal/collectorlifecycle/credential_owner_windows.go @@ -0,0 +1,42 @@ +//go:build windows + +package collectorlifecycle + +import ( + "errors" + "fmt" + "os" + + internalsecurity "github.com/rcourtman/pulse-go-rewrite/internal/securityutil" +) + +func openCredentialFile(path string) (*os.File, error) { + before, err := os.Lstat(path) + if err != nil { + return nil, err + } + if before.Mode()&os.ModeSymlink != 0 || !before.Mode().IsRegular() { + return nil, errors.New("collector lifecycle token file must not be a symlink or reparse point") + } + file, err := os.Open(path) + if err != nil { + return nil, err + } + after, err := file.Stat() + if err != nil { + file.Close() + return nil, err + } + if !os.SameFile(before, after) { + file.Close() + return nil, errors.New("collector lifecycle token file changed while it was opened") + } + return file, nil +} + +func validateCredentialFileOwner(path string, info os.FileInfo, _ *uint64) error { + if err := internalsecurity.ValidatePrivatePath(path, info); err != nil { + return fmt.Errorf("collector lifecycle token file owner or DACL is not trusted: %w", err) + } + return nil +} diff --git a/internal/collectorlifecycle/test_owner_unix_test.go b/internal/collectorlifecycle/test_owner_unix_test.go new file mode 100644 index 000000000..61fd0a1fa --- /dev/null +++ b/internal/collectorlifecycle/test_owner_unix_test.go @@ -0,0 +1,10 @@ +//go:build !windows + +package collectorlifecycle + +import "os" + +func testTokenOwnerUID() *uint64 { + uid := uint64(os.Geteuid()) + return &uid +} diff --git a/internal/collectorlifecycle/test_owner_windows_test.go b/internal/collectorlifecycle/test_owner_windows_test.go new file mode 100644 index 000000000..7e64bcb58 --- /dev/null +++ b/internal/collectorlifecycle/test_owner_windows_test.go @@ -0,0 +1,5 @@ +//go:build windows + +package collectorlifecycle + +func testTokenOwnerUID() *uint64 { return nil } diff --git a/internal/hostagent/action_runner_client.go b/internal/hostagent/action_runner_client.go index d2c8cfb66..e1c459ee5 100644 --- a/internal/hostagent/action_runner_client.go +++ b/internal/hostagent/action_runner_client.go @@ -6,7 +6,9 @@ import ( "encoding/json" "fmt" "io" + "net" "net/http" + "net/url" "os" "path/filepath" "runtime" @@ -35,6 +37,135 @@ type ActionRunnerClientConfig struct { DockerContainerLifecycleOperator DockerContainerLifecycleOperator } +// ActionRunnerCredentialLifecycleConfig carries the exact non-browser inputs +// used by installer recovery and uninstall. The bearer is read from a private +// file by the runner command and is never passed in argv or to curl. +type ActionRunnerCredentialLifecycleConfig struct { + PulseURL string + APIToken string + InsecureSkipVerify bool + CACertPath string + ServerFingerprint string +} + +func normalizeActionRunnerHTTPBaseURL(raw string) (*url.URL, error) { + parsed, err := securityutil.NormalizePulseHTTPBaseURL(raw) + if err != nil { + return nil, err + } + if parsed.Scheme != "http" { + return parsed, nil + } + hostname := strings.TrimSpace(strings.ToLower(parsed.Hostname())) + if hostname == "localhost" { + return parsed, nil + } + ip := net.ParseIP(hostname) + if ip == nil || !ip.IsLoopback() { + return nil, fmt.Errorf("plaintext action-runner URL requires a literal loopback host") + } + return parsed, nil +} + +// ValidateActionRunnerPulseURL enforces the runner's stricter plaintext +// boundary. Unlike generic URL handling, *.localhost is not accepted because +// a resolver can direct that name away from the local host. +func ValidateActionRunnerPulseURL(raw string) error { + _, err := normalizeActionRunnerHTTPBaseURL(raw) + return err +} + +func effectiveActionRunnerInsecureMode(pulseURL string, insecure bool, caCertPath, serverFingerprint string) (bool, error) { + parsed, err := normalizeActionRunnerHTTPBaseURL(pulseURL) + if err != nil { + return false, err + } + if parsed.Scheme != "https" || !insecure { + return insecure, nil + } + if strings.TrimSpace(serverFingerprint) != "" { + return true, nil // agenttls replaces insecure verification with the exact DER pin. + } + if strings.TrimSpace(caCertPath) != "" { + return false, nil // custom CA verification must remain enabled. + } + return false, fmt.Errorf("generic insecure HTTPS is forbidden for the action runner") +} + +// newActionRunnerHTTPClient deliberately bypasses ambient proxy variables. +// Runner bearers are host-bound control-plane credentials and must never be +// disclosed to an operator-unconfigured HTTP_PROXY intermediary. +func newActionRunnerHTTPClient(caCertPath string, insecureSkipVerify bool, serverFingerprint string) (*http.Client, error) { + client, err := newAgentHTTPClient(caCertPath, insecureSkipVerify, serverFingerprint) + if err != nil { + return nil, err + } + transport, ok := client.Transport.(*http.Transport) + if !ok || transport == nil { + return nil, fmt.Errorf("action-runner HTTP transport is unavailable") + } + direct := transport.Clone() + direct.Proxy = nil + client.Transport = direct + return client, nil +} + +func performActionRunnerCredentialLifecycle(ctx context.Context, config ActionRunnerCredentialLifecycleConfig, method, path, agentID, hostname string, includeBindingBody bool) error { + baseURL, err := normalizeActionRunnerHTTPBaseURL(config.PulseURL) + if err != nil { + return fmt.Errorf("validate action-runner lifecycle URL: %w", err) + } + effectiveInsecure, err := effectiveActionRunnerInsecureMode(config.PulseURL, config.InsecureSkipVerify, config.CACertPath, config.ServerFingerprint) + if err != nil { + return fmt.Errorf("validate action-runner lifecycle TLS mode: %w", err) + } + token := strings.TrimSpace(config.APIToken) + if token == "" || strings.ContainsAny(token, "\r\n") { + return fmt.Errorf("action-runner lifecycle bearer is invalid") + } + var body io.Reader + if includeBindingBody { + encoded, err := json.Marshal(map[string]string{"agentId": strings.TrimSpace(agentID), "hostname": strings.TrimSpace(hostname)}) + if err != nil { + return err + } + body = bytes.NewReader(encoded) + } + request, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(baseURL.String(), "/")+path, body) + if err != nil { + return err + } + request.Header.Set("Authorization", "Bearer "+token) + if includeBindingBody { + request.Header.Set("Content-Type", "application/json") + } + client, err := newActionRunnerHTTPClient(config.CACertPath, effectiveInsecure, config.ServerFingerprint) + if err != nil { + return fmt.Errorf("build action-runner lifecycle client: %w", err) + } + response, err := client.Do(request) + if err != nil { + return fmt.Errorf("action-runner lifecycle request: %w", err) + } + defer response.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096)) + if response.StatusCode != http.StatusNoContent { + return fmt.Errorf("action-runner lifecycle request: server returned %s", response.Status) + } + return nil +} + +// CancelPendingActionRunnerCredential durably cancels a prepared replacement. +// Only a nil result (HTTP 204) is safe authority to restore a predecessor. +func CancelPendingActionRunnerCredential(ctx context.Context, config ActionRunnerCredentialLifecycleConfig) error { + return performActionRunnerCredentialLifecycle(ctx, config, http.MethodDelete, "/api/agents/action-runner/credential/activation", "", "", false) +} + +// RevokeActionRunnerCredential removes the exact active runner credential. +func RevokeActionRunnerCredential(ctx context.Context, config ActionRunnerCredentialLifecycleConfig, agentID, hostname string) error { + return performActionRunnerCredentialLifecycle(ctx, config, http.MethodDelete, "/api/agents/action-runner/credential", agentID, hostname, true) +} + // NewActionRunnerClient constructs the separately credentialed, typed-only // action transport. It does not create a collector, reporter, deploy client, // arbitrary command executor, or unrestricted file reader. @@ -96,6 +227,13 @@ type actionRunnerHealth struct { RegisteredAt time.Time `json:"registered_at"` } +func (c *CommandClient) persistActionRunnerHealth(activated bool) error { + if c != nil && c.actionHealthWriter != nil { + return c.actionHealthWriter(activated) + } + return c.writeActionRunnerHealth(activated) +} + func (c *CommandClient) writeActionRunnerHealth(activated bool) error { if c == nil || !c.actionRunnerOnly || strings.TrimSpace(c.healthPath) == "" { return fmt.Errorf("action-runner health path is required") @@ -167,13 +305,21 @@ func (c *CommandClient) activateActionRunnerCredential(ctx context.Context) erro if err != nil { return err } - request, err := http.NewRequestWithContext(ctx, http.MethodPatch, strings.TrimRight(c.pulseURL, "/")+"/api/agents/action-runner/credential", bytes.NewReader(body)) + baseURL, err := securityutil.NormalizePulseHTTPBaseURL(c.pulseURL) + if err != nil { + return fmt.Errorf("validate action-runner activation URL: %w", err) + } + effectiveInsecure, err := effectiveActionRunnerInsecureMode(c.pulseURL, c.insecureSkipVerify, c.caCertPath, c.serverFingerprint) + if err != nil { + return fmt.Errorf("validate action-runner activation TLS mode: %w", err) + } + request, err := http.NewRequestWithContext(ctx, http.MethodPatch, strings.TrimRight(baseURL.String(), "/")+"/api/agents/action-runner/credential", bytes.NewReader(body)) if err != nil { return err } request.Header.Set("Authorization", "Bearer "+c.apiToken) request.Header.Set("Content-Type", "application/json") - client, err := newAgentHTTPClient(c.caCertPath, c.insecureSkipVerify, c.serverFingerprint) + client, err := newActionRunnerHTTPClient(c.caCertPath, effectiveInsecure, c.serverFingerprint) if err != nil { return fmt.Errorf("build action-runner activation client: %w", err) } diff --git a/internal/hostagent/action_runner_client_test.go b/internal/hostagent/action_runner_client_test.go index af7876b27..3fa9fe4c3 100644 --- a/internal/hostagent/action_runner_client_test.go +++ b/internal/hostagent/action_runner_client_test.go @@ -2,7 +2,16 @@ package hostagent import ( "context" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" "encoding/json" + "encoding/pem" + "errors" + "math/big" "net/http" "net/http/httptest" "os" @@ -40,6 +49,254 @@ func TestNewActionRunnerClientIsTypedOnlyAndEmitsExplicitRole(t *testing.T) { } } +func TestActionRunnerTransportRequiresTLSExceptLoopback(t *testing.T) { + logger := zerolog.Nop() + for _, test := range []struct { + name string + url string + wantURL string + wantErr bool + }{ + {name: "public plaintext rejected", url: "http://pulse.example.com:7655", wantErr: true}, + {name: "private LAN plaintext rejected", url: "http://192.168.1.20:7655", wantErr: true}, + {name: "localhost subdomain plaintext rejected", url: "http://agent.localhost:7655", wantErr: true}, + {name: "loopback plaintext accepted", url: "http://127.0.0.1:7655", wantURL: "ws://127.0.0.1:7655/api/agent/ws"}, + {name: "HTTPS accepted", url: "https://pulse.example.com:7655", wantURL: "wss://pulse.example.com:7655/api/agent/ws"}, + } { + t.Run(test.name, func(t *testing.T) { + client := NewActionRunnerClient(ActionRunnerClientConfig{ + PulseURL: test.url, APIToken: "runner-token", StateDir: t.TempDir(), + HealthPath: filepath.Join(t.TempDir(), "health.json"), ActivationNonce: strings.Repeat("a", 32), Logger: &logger, + }, "agent-1", "host-1", "v1") + t.Cleanup(func() { _ = client.Close() }) + got, err := client.buildWebSocketURL() + if test.wantErr { + if err == nil || !strings.Contains(err.Error(), "loopback") { + t.Fatalf("buildWebSocketURL() = %q, %v; want plaintext rejection", got, err) + } + if err := client.activateActionRunnerCredential(context.Background()); err == nil || !strings.Contains(err.Error(), "loopback") { + t.Fatalf("activation URL error = %v, want plaintext rejection", err) + } + return + } + if err != nil || got != test.wantURL { + t.Fatalf("buildWebSocketURL() = %q, %v; want %q", got, err, test.wantURL) + } + }) + } + if err := CancelPendingActionRunnerCredential(context.Background(), ActionRunnerCredentialLifecycleConfig{ + PulseURL: "http://agent.localhost:7655", APIToken: "runner-token", + }); err == nil || !strings.Contains(err.Error(), "literal loopback") { + t.Fatalf("localhost subdomain lifecycle error = %v", err) + } +} + +func TestActionRunnerCredentialHTTPSHonorsCAAndRejectsFingerprintMismatch(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPatch { + http.Error(w, "wrong method", http.StatusMethodNotAllowed) + return + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + caFile := filepath.Join(t.TempDir(), "ca.pem") + certificate := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}) + if err := os.WriteFile(caFile, certificate, 0o600); err != nil { + t.Fatal(err) + } + logger := zerolog.Nop() + client := NewActionRunnerClient(ActionRunnerClientConfig{ + PulseURL: server.URL, APIToken: "runner-token", StateDir: t.TempDir(), CACertPath: caFile, + HealthPath: filepath.Join(t.TempDir(), "health.json"), ActivationNonce: strings.Repeat("b", 32), Logger: &logger, + }, "agent-1", "host-1", "v1") + defer client.Close() + if err := client.activateActionRunnerCredential(context.Background()); err != nil { + t.Fatalf("CA-authenticated HTTPS activation: %v", err) + } + + mismatch := NewActionRunnerClient(ActionRunnerClientConfig{ + PulseURL: server.URL, APIToken: "runner-token", StateDir: t.TempDir(), InsecureSkipVerify: true, + ServerFingerprint: strings.Repeat("00", 32), HealthPath: filepath.Join(t.TempDir(), "health.json"), + ActivationNonce: strings.Repeat("c", 32), Logger: &logger, + }, "agent-1", "host-1", "v1") + defer mismatch.Close() + if err := mismatch.activateActionRunnerCredential(context.Background()); err == nil || !strings.Contains(strings.ToLower(err.Error()), "fingerprint") { + t.Fatalf("fingerprint mismatch error = %v", err) + } +} + +func TestActionRunnerLifecycleTransportEnforcesTrustLoopbackAndRedirectBoundary(t *testing.T) { + const token = "runner-lifecycle-token" + authorizedRequests := 0 + tlsServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if request.Header.Get("Authorization") != "Bearer "+token { + t.Errorf("authorization = %q", request.Header.Get("Authorization")) + } + authorizedRequests++ + w.WriteHeader(http.StatusNoContent) + })) + defer tlsServer.Close() + caFile := filepath.Join(t.TempDir(), "ca.pem") + if err := os.WriteFile(caFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: tlsServer.Certificate().Raw}), 0o600); err != nil { + t.Fatal(err) + } + base := ActionRunnerCredentialLifecycleConfig{PulseURL: tlsServer.URL, APIToken: token, CACertPath: caFile} + if err := CancelPendingActionRunnerCredential(context.Background(), base); err != nil { + t.Fatalf("custom CA cancel: %v", err) + } + fingerprintBytes := sha256.Sum256(tlsServer.Certificate().Raw) + pinned := ActionRunnerCredentialLifecycleConfig{PulseURL: tlsServer.URL, APIToken: token, ServerFingerprint: hex.EncodeToString(fingerprintBytes[:])} + if err := RevokeActionRunnerCredential(context.Background(), pinned, "agent-1", "host.local"); err != nil { + t.Fatalf("exact fingerprint revoke: %v", err) + } + beforeMismatch := authorizedRequests + mismatch := ActionRunnerCredentialLifecycleConfig{PulseURL: tlsServer.URL, APIToken: token, ServerFingerprint: strings.Repeat("00", 32)} + if err := CancelPendingActionRunnerCredential(context.Background(), mismatch); err == nil || !strings.Contains(strings.ToLower(err.Error()), "fingerprint") { + t.Fatalf("mismatched fingerprint error = %v", err) + } + if authorizedRequests != beforeMismatch { + t.Fatal("mismatched pin transmitted Authorization to the handler") + } + if err := CancelPendingActionRunnerCredential(context.Background(), ActionRunnerCredentialLifecycleConfig{PulseURL: "http://192.0.2.10:7655", APIToken: token}); err == nil || !strings.Contains(err.Error(), "loopback") { + t.Fatalf("non-loopback HTTP error = %v", err) + } + + loopback := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if request.Header.Get("Authorization") != "Bearer "+token { + t.Errorf("loopback authorization = %q", request.Header.Get("Authorization")) + } + w.WriteHeader(http.StatusNoContent) + })) + defer loopback.Close() + if err := CancelPendingActionRunnerCredential(context.Background(), ActionRunnerCredentialLifecycleConfig{PulseURL: loopback.URL, APIToken: token}); err != nil { + t.Fatalf("loopback HTTP cancel: %v", err) + } + + redirectTargetAuthorization := "" + redirectTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + redirectTargetAuthorization = request.Header.Get("Authorization") + w.WriteHeader(http.StatusNoContent) + })) + defer redirectTarget.Close() + redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + http.Redirect(w, request, redirectTarget.URL, http.StatusTemporaryRedirect) + })) + defer redirector.Close() + err := CancelPendingActionRunnerCredential(context.Background(), ActionRunnerCredentialLifecycleConfig{PulseURL: redirector.URL, APIToken: token}) + if err == nil || !strings.Contains(err.Error(), "redirect") { + t.Fatalf("redirect error = %v", err) + } + if redirectTargetAuthorization != "" { + t.Fatalf("redirect target received bearer %q", redirectTargetAuthorization) + } +} + +func TestActionRunnerBearerHTTPBypassesAmbientProxy(t *testing.T) { + const token = "runner-proxy-sensitive-token" + proxyRequests := 0 + proxyAuthorization := "" + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + proxyRequests++ + proxyAuthorization = request.Header.Get("Authorization") + http.Error(w, "proxy must not be used", http.StatusBadGateway) + })) + defer proxy.Close() + for _, key := range []string{"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"} { + t.Setenv(key, proxy.URL) + } + t.Setenv("NO_PROXY", "") + t.Setenv("no_proxy", "") + + targetRequests := 0 + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + targetRequests++ + if request.Header.Get("Authorization") != "Bearer "+token { + t.Errorf("target authorization = %q", request.Header.Get("Authorization")) + } + w.WriteHeader(http.StatusNoContent) + })) + defer target.Close() + pulseURL := strings.Replace(target.URL, "127.0.0.1", "localhost", 1) + if pulseURL == target.URL { + t.Fatalf("unexpected httptest URL %q", target.URL) + } + + directClient, err := newActionRunnerHTTPClient("", false, "") + if err != nil { + t.Fatal(err) + } + transport, ok := directClient.Transport.(*http.Transport) + if !ok || transport.Proxy != nil { + t.Fatalf("action-runner transport proxy configured = %t, want false", transport.Proxy != nil) + } + config := ActionRunnerCredentialLifecycleConfig{PulseURL: pulseURL, APIToken: token} + if err := CancelPendingActionRunnerCredential(context.Background(), config); err != nil { + t.Fatalf("direct cancellation: %v", err) + } + if err := RevokeActionRunnerCredential(context.Background(), config, "agent-1", "runner.localhost"); err != nil { + t.Fatalf("direct self-revoke: %v", err) + } + logger := zerolog.Nop() + runner := NewActionRunnerClient(ActionRunnerClientConfig{ + PulseURL: pulseURL, APIToken: token, StateDir: t.TempDir(), + HealthPath: filepath.Join(t.TempDir(), "health.json"), ActivationNonce: strings.Repeat("a", 32), Logger: &logger, + }, "agent-1", "runner.localhost", "v1") + defer runner.Close() + if err := runner.activateActionRunnerCredential(context.Background()); err != nil { + t.Fatalf("direct activation: %v", err) + } + if proxyRequests != 0 || proxyAuthorization != "" { + t.Fatalf("ambient proxy saw %d requests and authorization %q", proxyRequests, proxyAuthorization) + } + if targetRequests != 3 { + t.Fatalf("direct target requests = %d, want cancel, revoke, and activate", targetRequests) + } +} + +func TestActionRunnerWebSocketCustomCADisablesRawInsecureBypass(t *testing.T) { + targetRequests := 0 + target := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + targetRequests++ + http.Error(w, "TLS verification should fail before HTTP", http.StatusInternalServerError) + })) + defer target.Close() + caFile := filepath.Join(t.TempDir(), "unrelated-ca.pem") + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "unrelated-action-runner-test-ca"}, + NotBefore: now.Add(-time.Minute), NotAfter: now.Add(time.Hour), IsCA: true, BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + } + certificate, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(caFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificate}), 0o600); err != nil { + t.Fatal(err) + } + logger := zerolog.Nop() + runner := NewActionRunnerClient(ActionRunnerClientConfig{ + PulseURL: target.URL, APIToken: "runner-token", StateDir: t.TempDir(), + HealthPath: filepath.Join(t.TempDir(), "health.json"), ActivationNonce: strings.Repeat("a", 32), + InsecureSkipVerify: true, CACertPath: caFile, Logger: &logger, + }, "agent-1", "host.local", "v1") + defer runner.Close() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + err = runner.connectAndHandle(ctx) + if err == nil { + t.Fatal("websocket connection succeeded with an unrelated custom CA and raw insecure flag") + } + if targetRequests != 0 { + t.Fatalf("raw insecure bypass reached target handler %d times", targetRequests) + } +} + func TestActionRunnerTransportRegistersRoleWritesHealthAndRejectsGenericExec(t *testing.T) { upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} registration := make(chan registerPayload, 1) @@ -185,6 +442,64 @@ func TestActionRunnerActivationFailureLeavesOnlyPendingHealthProof(t *testing.T) } } +func TestActionRunnerPostCommitHealthFailureKeepsPendingProof(t *testing.T) { + upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + activationCommitted := make(chan struct{}, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if request.Method == http.MethodPatch { + activationCommitted <- struct{}{} + w.WriteHeader(http.StatusNoContent) + return + } + conn, err := upgrader.Upgrade(w, request, nil) + if err != nil { + return + } + defer conn.Close() + var message wsMessage + if conn.ReadJSON(&message) != nil { + return + } + ack, _ := json.Marshal(registeredPayload{Success: true}) + _ = conn.WriteJSON(wsMessage{Type: msgTypeRegistered, Timestamp: time.Now(), Payload: ack}) + _, _, _ = conn.ReadMessage() + })) + defer server.Close() + dir := t.TempDir() + healthPath := filepath.Join(dir, "health.json") + logger := zerolog.Nop() + client := NewActionRunnerClient(ActionRunnerClientConfig{ + PulseURL: server.URL, APIToken: "runner-token", StateDir: filepath.Join(dir, "state"), + HealthPath: healthPath, ActivationNonce: strings.Repeat("e", 32), InsecureSkipVerify: true, Logger: &logger, + }, "agent-1", "host-1", "v1") + defer client.Close() + client.actionHealthWriter = func(activated bool) error { + if activated { + return errors.New("injected activated health replacement failure") + } + return client.writeActionRunnerHealth(false) + } + if err := client.connectAndHandle(context.Background()); err == nil || !strings.Contains(err.Error(), "injected activated health replacement failure") { + t.Fatalf("post-commit health error = %v", err) + } + select { + case <-activationCommitted: + default: + t.Fatal("credential activation did not commit before the injected health failure") + } + data, err := os.ReadFile(healthPath) + if err != nil { + t.Fatal(err) + } + var health actionRunnerHealth + if err := json.Unmarshal(data, &health); err != nil { + t.Fatal(err) + } + if !health.Registered || health.Activated || health.ActivationNonce != strings.Repeat("e", 32) { + t.Fatalf("post-commit failed health marker = %+v", health) + } +} + func jsonContains(data []byte, value string) bool { var decoded any if json.Unmarshal(data, &decoded) != nil { diff --git a/internal/hostagent/commands.go b/internal/hostagent/commands.go index 8789e642e..63e6348fd 100644 --- a/internal/hostagent/commands.go +++ b/internal/hostagent/commands.go @@ -143,6 +143,7 @@ type CommandClient struct { healthPath string healthCapabilities []string actionActivationNonce string + actionHealthWriter func(bool) error } // NewCommandClient creates a new command execution client @@ -369,7 +370,14 @@ func (c *CommandClient) connectAndHandle(ctx context.Context) error { c.logger.Debug().Str("url", wsURL).Msg("Connecting to Pulse command server") // Create dialer with TLS config - tlsConfig, err := agenttls.NewClientTLSConfig(c.caCertPath, c.insecureSkipVerify, c.serverFingerprint) + effectiveInsecure := c.insecureSkipVerify + if c.actionRunnerOnly { + effectiveInsecure, err = effectiveActionRunnerInsecureMode(c.pulseURL, c.insecureSkipVerify, c.caCertPath, c.serverFingerprint) + if err != nil { + return fmt.Errorf("validate action-runner websocket TLS mode: %w", err) + } + } + tlsConfig, err := agenttls.NewClientTLSConfig(c.caCertPath, effectiveInsecure, c.serverFingerprint) if err != nil { return fmt.Errorf("build websocket TLS config: %w", err) } @@ -420,13 +428,13 @@ func (c *CommandClient) connectAndHandle(ctx context.Context) error { return fmt.Errorf("registration failed: %w", err) } if c.actionRunnerOnly { - if err := c.writeActionRunnerHealth(false); err != nil { + if err := c.persistActionRunnerHealth(false); err != nil { return fmt.Errorf("write action-runner health: %w", err) } if err := c.activateActionRunnerCredential(ctx); err != nil { return err } - if err := c.writeActionRunnerHealth(true); err != nil { + if err := c.persistActionRunnerHealth(true); err != nil { return fmt.Errorf("write activated action-runner health: %w", err) } } @@ -460,9 +468,14 @@ func (c *CommandClient) connectAndHandle(ctx context.Context) error { } func (c *CommandClient) buildWebSocketURL() (string, error) { - parsed, err := securityutil.NormalizePulseWebSocketBaseURLWithOptions(c.pulseURL, securityutil.PulseURLValidationOptions{ - AllowLocalNetworkHTTP: true, - }) + options := securityutil.PulseURLValidationOptions{AllowLocalNetworkHTTP: true} + if c.actionRunnerOnly { + options = securityutil.PulseURLValidationOptions{} + if _, err := effectiveActionRunnerInsecureMode(c.pulseURL, c.insecureSkipVerify, c.caCertPath, c.serverFingerprint); err != nil { + return "", err + } + } + parsed, err := securityutil.NormalizePulseWebSocketBaseURLWithOptions(c.pulseURL, options) if err != nil { return "", err } @@ -480,9 +493,14 @@ func (c *CommandClient) buildWebSocketURL() (string, error) { } func (c *CommandClient) buildWebSocketOrigin() (string, error) { - return securityutil.HTTPOriginForWebSocketBaseURLWithOptions(c.pulseURL, securityutil.PulseURLValidationOptions{ - AllowLocalNetworkHTTP: true, - }) + options := securityutil.PulseURLValidationOptions{AllowLocalNetworkHTTP: true} + if c.actionRunnerOnly { + options = securityutil.PulseURLValidationOptions{} + if _, err := effectiveActionRunnerInsecureMode(c.pulseURL, c.insecureSkipVerify, c.caCertPath, c.serverFingerprint); err != nil { + return "", err + } + } + return securityutil.HTTPOriginForWebSocketBaseURLWithOptions(c.pulseURL, options) } func (c *CommandClient) sendRegistration(conn *websocket.Conn) error { diff --git a/scripts/install.sh b/scripts/install.sh index 22fa6d1f2..b79205ff3 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -642,6 +642,105 @@ warn_agent_token_rejected() { log_warn "Re-run the full agent install command from the Pulse UI to mint a fresh token. The agent will keep reporting 401/403 until the credential is replaced." } +# Resolve the newly downloaded/installed pulse-agent command surface used for +# authenticated installer lifecycle operations. Safe-profile preflight runs +# after download but before replacement, so it must prefer the new binary over +# an older installed agent that does not yet expose these commands. +collector_lifecycle_binary() { + if [[ -n "${COLLECTOR_LIFECYCLE_BINARY_PATH:-}" && -x "$COLLECTOR_LIFECYCLE_BINARY_PATH" ]]; then + printf '%s\n' "$COLLECTOR_LIFECYCLE_BINARY_PATH" + return 0 + fi + if [[ -n "${TMP_BIN:-}" && -x "$TMP_BIN" ]]; then + printf '%s\n' "$TMP_BIN" + return 0 + fi + if [[ -x "${INSTALL_DIR%/}/${BINARY_NAME}" ]]; then + printf '%s\n' "${INSTALL_DIR%/}/${BINARY_NAME}" + return 0 + fi + return 1 +} + +resolve_safe_profile_hostname() { + local resolved_hostname="${HOSTNAME_OVERRIDE:-}" + if [[ -z "$resolved_hostname" ]]; then + resolved_hostname=$(hostname -f 2>/dev/null || true) + fi + if [[ -z "$resolved_hostname" ]]; then + resolved_hostname=$(hostname 2>/dev/null || true) + fi + resolved_hostname=$(printf '%s' "$resolved_hostname" | tr '[:upper:]' '[:lower:]') + resolved_hostname="${resolved_hostname%.}" + [[ ${#resolved_hostname} -ge 1 && ${#resolved_hostname} -le 253 && + "$resolved_hostname" =~ ^[a-z0-9][a-z0-9._:-]*$ ]] || return 1 + HOSTNAME_OVERRIDE="$resolved_hostname" +} + +# Select the bearer actually used by the collector without copying it through +# argv. Enrolled runtime state wins over the bootstrap token. PULSE_TOKEN-only +# installs get a root-only temporary file that is removed after each command. +prepare_collector_lifecycle_token_file() { + local candidate="" + local temp_token="" + + COLLECTOR_LIFECYCLE_TOKEN_FILE="" + COLLECTOR_LIFECYCLE_TEMP_TOKEN_FILE="" + for candidate in \ + "${STATE_DIR%/}/runtime.token" \ + "${RUNTIME_TOKEN_FILE:-}" \ + "${STATE_DIR%/}/token"; do + [[ -n "$candidate" ]] || continue + if [[ -s "$candidate" && -f "$candidate" && ! -L "$candidate" ]]; then + COLLECTOR_LIFECYCLE_TOKEN_FILE="$candidate" + return 0 + fi + done + + [[ -n "$PULSE_TOKEN" && "$PULSE_TOKEN" != *$'\r'* && "$PULSE_TOKEN" != *$'\n'* ]] || return 1 + temp_token=$(mktemp) || return 1 + chmod 0600 "$temp_token" || { rm -f "$temp_token"; return 1; } + if ! printf '%s' "$PULSE_TOKEN" > "$temp_token"; then + rm -f "$temp_token" + return 1 + fi + COLLECTOR_LIFECYCLE_TOKEN_FILE="$temp_token" + COLLECTOR_LIFECYCLE_TEMP_TOKEN_FILE="$temp_token" + return 0 +} + +run_collector_lifecycle_command() { + local command_name="$1" + shift + local lifecycle_binary="" + local collector_uid="" + local lifecycle_rc=1 + local -a lifecycle_args + + lifecycle_binary=$(collector_lifecycle_binary) || return 1 + prepare_collector_lifecycle_token_file || return 1 + lifecycle_args=("$command_name" --url "$PULSE_URL" --token-file "$COLLECTOR_LIFECYCLE_TOKEN_FILE") + collector_uid=$(id -u "$LEAST_PRIVILEGE_USER" 2>/dev/null || true) + if [[ "$collector_uid" =~ ^[0-9]+$ ]]; then + lifecycle_args+=(--token-owner-uid "$collector_uid") + fi + [[ -n "$CURL_CA_BUNDLE" ]] && lifecycle_args+=(--cacert "$CURL_CA_BUNDLE") + [[ -n "$SERVER_FINGERPRINT" ]] && lifecycle_args+=(--server-fingerprint "$SERVER_FINGERPRINT") + lifecycle_args+=("$@") + + if "$lifecycle_binary" "${lifecycle_args[@]}"; then + lifecycle_rc=0 + else + lifecycle_rc=$? + fi + if [[ -n "$COLLECTOR_LIFECYCLE_TEMP_TOKEN_FILE" ]]; then + rm -f "$COLLECTOR_LIFECYCLE_TEMP_TOKEN_FILE" + fi + COLLECTOR_LIFECYCLE_TOKEN_FILE="" + COLLECTOR_LIFECYCLE_TEMP_TOKEN_FILE="" + return "$lifecycle_rc" +} + # verify_agent_server_registration returns: # 0 - the server confirmed this agent's registration # 1 - registration not confirmed yet (transient: agent not reported, network) @@ -651,15 +750,9 @@ verify_agent_server_registration() { local required_previous_last_seen="${1:-}" local lookup_id="${AGENT_ID}" local lookup_hostname="${HOSTNAME_OVERRIDE}" - local lookup_query="" - local lookup_out="" - local lookup_status="" - local lookup_body="" local lookup_last_seen="" - # No -f: we need the response body AND the HTTP status even on 4xx so a - # rejected token (401/403) can be told apart from "not reported yet". The - # -w format appends "\n" after the body. - local lookup_args=(-sSL --connect-timeout 5 --max-time 10 -w $'\n%{http_code}') + local lookup_rc=1 + local -a lookup_args=(collector-verify-registration) if [[ -z "$PULSE_URL" ]]; then return 1 @@ -674,45 +767,26 @@ verify_agent_server_registration() { lookup_hostname=$(hostname 2>/dev/null || true) fi if [[ -n "$lookup_id" ]]; then - lookup_query="id=$(url_encode "$lookup_id")" - elif [[ -n "$lookup_hostname" ]]; then - lookup_query="hostname=$(url_encode "$lookup_hostname")" - else + lookup_args+=(--agent-id "$lookup_id") + fi + if [[ -n "$lookup_hostname" ]]; then + lookup_args+=(--hostname "$lookup_hostname") + fi + if [[ ${#lookup_args[@]} -eq 1 ]]; then return 1 fi - - if [[ "$INSECURE" == "true" ]]; then lookup_args+=(-k); fi - if [[ -n "$CURL_CA_BUNDLE" ]]; then lookup_args+=(--cacert "$CURL_CA_BUNDLE"); fi - - lookup_out=$(curl_with_pulse_token "${lookup_args[@]}" "${PULSE_URL}/api/agents/agent/lookup?${lookup_query}" 2>/dev/null || true) - lookup_status="${lookup_out##*$'\n'}" - lookup_body="${lookup_out%$'\n'*}" - - # Authentication failure and missing reporting scope are definitive. A - # lookup can instead hit the previous registration during first-use token - # binding; agent_lookup_forbidden is transient until the new ownership is - # visible, so keep polling rather than falsely condemning the fresh token. - case "$lookup_status" in - 401) return 2 ;; - 403) - if echo "$lookup_body" | grep -q '"code"[[:space:]]*:[[:space:]]*"agent_lookup_forbidden"'; then - return 1 - fi - return 2 - ;; - esac - + [[ -n "$required_previous_last_seen" ]] && lookup_args+=(--previous-last-seen "$required_previous_last_seen") AGENT_REGISTRATION_LAST_SEEN="" - if echo "$lookup_body" | grep -q '"agent"[[:space:]]*:' && echo "$lookup_body" | grep -q '"id"[[:space:]]*:'; then - lookup_last_seen=$(printf '%s' "$lookup_body" | tr -d '\r\n' | - sed -n 's/.*"lastSeen"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') + if lookup_last_seen=$(run_collector_lifecycle_command "${lookup_args[@]}" 2>/dev/null); then + lookup_rc=0 + else + lookup_rc=$? + fi + if [[ $lookup_rc -eq 0 && -n "$lookup_last_seen" ]]; then AGENT_REGISTRATION_LAST_SEEN="$lookup_last_seen" - if [[ -n "$required_previous_last_seen" ]] && - [[ -z "$lookup_last_seen" || "$lookup_last_seen" == "$required_previous_last_seen" ]]; then - return 1 - fi return 0 fi + [[ $lookup_rc -eq 2 ]] && return 2 return 1 } @@ -966,20 +1040,26 @@ revoke_action_runner_credential() { local runner_agent_id_direct="" local runner_agent_id_file="" local runner_agent_id="" - local runner_token_file="" - local runner_token="" - local token_mode="" - local token_size="" - local curl_config="" - local payload="" - local -a revoke_args + local runner_token_file="" + local runner_server_fingerprint="" + local runner_ca_file="" + local runner_insecure="" + local lifecycle_binary="" + local collector_uid="" + local -a identity_args + local -a revoke_args runner_url=$(read_action_runner_env_value "PULSE_URL" || true) runner_hostname=$(read_action_runner_env_value "PULSE_AGENT_RUNNER_HOSTNAME" || true) runner_agent_id_direct=$(read_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID" || true) runner_agent_id_file=$(read_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID_FILE" || true) runner_token_file=$(read_action_runner_env_value "PULSE_AGENT_RUNNER_TOKEN_FILE" || true) - [[ "$runner_url" =~ ^https?://[^[:space:]]+$ && -n "$runner_hostname" ]] || return 1 + runner_server_fingerprint=$(read_action_runner_env_value "PULSE_SERVER_FINGERPRINT" || true) + runner_ca_file=$(read_action_runner_env_value "SSL_CERT_FILE" || true) + runner_insecure=$(read_action_runner_env_value "PULSE_INSECURE" || true) + action_runner_url_transport_allowed "$runner_url" || return 1 + [[ -n "$runner_hostname" ]] || return 1 + [[ -x "$ACTION_RUNNER_BINARY_PATH" ]] || return 1 [[ "$runner_token_file" == /* && "$runner_token_file" != *'/../'* && -f "$runner_token_file" && ! -L "$runner_token_file" ]] || return 1 if [[ -n "$runner_agent_id_direct" ]]; then @@ -987,95 +1067,63 @@ revoke_action_runner_credential() { else [[ "$runner_agent_id_file" == /* && "$runner_agent_id_file" != *'/../'* && -f "$runner_agent_id_file" && ! -L "$runner_agent_id_file" ]] || return 1 - runner_agent_id=$(head -1 "$runner_agent_id_file" 2>/dev/null || true) + lifecycle_binary=$(collector_lifecycle_binary) || return 1 + identity_args=(collector-read-agent-id --agent-id-file "$runner_agent_id_file") + collector_uid=$(id -u "$LEAST_PRIVILEGE_USER" 2>/dev/null || true) + if [[ "$collector_uid" =~ ^[0-9]+$ ]]; then + identity_args+=(--token-owner-uid "$collector_uid") + fi + runner_agent_id=$("$lifecycle_binary" "${identity_args[@]}" 2>/dev/null || true) fi (( ${#runner_agent_id} >= 1 && ${#runner_agent_id} <= 128 && ${#runner_hostname} >= 1 && ${#runner_hostname} <= 253 )) || return 1 [[ "$runner_agent_id" =~ ^[A-Za-z0-9][A-Za-z0-9._:-]*$ && "$runner_hostname" =~ ^[A-Za-z0-9._:-]+$ ]] || return 1 - token_mode=$(stat -c '%a' "$runner_token_file" 2>/dev/null || true) - token_size=$(wc -c < "$runner_token_file" 2>/dev/null | tr -d ' ' || true) - [[ "$token_mode" =~ ^[0-7]{3,4}$ && "$token_size" =~ ^[0-9]+$ ]] || return 1 - (( (8#$token_mode & 8#077) == 0 && token_size >= 1 && token_size <= 4096 )) || return 1 - runner_token=$(head -1 "$runner_token_file" 2>/dev/null || true) - [[ -n "$runner_token" && "$runner_token" != *$'\r'* && "$runner_token" != *$'\n'* ]] || return 1 - - curl_config=$(mktemp) - chmod 0600 "$curl_config" - printf 'header = "Authorization: Bearer %s"\nheader = "Content-Type: application/json"\n' \ - "$runner_token" > "$curl_config" - runner_token="" - payload="{\"agentId\":\"${runner_agent_id}\",\"hostname\":\"${runner_hostname}\"}" - revoke_args=(--config "$curl_config" -fsS --connect-timeout 5 --max-time 10 -X DELETE --data-binary "$payload") - if grep -q '^PULSE_INSECURE="true"$' "$ACTION_RUNNER_ENV_FILE" 2>/dev/null; then - revoke_args+=(-k) - fi - if curl "${revoke_args[@]}" "${runner_url%/}/api/agents/action-runner/credential" >/dev/null 2>&1; then - rm -f "$curl_config" - return 0 - fi - rm -f "$curl_config" - return 1 + revoke_args=(revoke-credential --url "$runner_url" --token-file "$runner_token_file" --agent-id "$runner_agent_id" --hostname "$runner_hostname") + [[ -n "$runner_ca_file" ]] && revoke_args+=(--cacert "$runner_ca_file") + [[ -n "$runner_server_fingerprint" ]] && revoke_args+=(--server-fingerprint "$runner_server_fingerprint") + if action_runner_url_uses_loopback_http "$runner_url" && [[ "$runner_insecure" == "true" ]]; then + revoke_args+=(--insecure-loopback) + fi + "$ACTION_RUNNER_BINARY_PATH" "${revoke_args[@]}" } reduce_safe_profile_collector_authority() { - local active_token_file="${STATE_DIR%/}/runtime.token" - local active_token="" - local curl_config="" - local payload="" - local -a reduce_args - - if [[ ! -s "$active_token_file" || -L "$active_token_file" ]]; then - active_token_file="" - active_token="$PULSE_TOKEN" - else - active_token=$(head -1 "$active_token_file" 2>/dev/null || true) - fi - [[ -n "$active_token" && "$active_token" != *$'\r'* && "$active_token" != *$'\n'* ]] || return 1 [[ -n "$AGENT_ID" && ${#AGENT_ID} -le 256 && "$AGENT_ID" =~ ^[A-Za-z0-9._:-]+$ ]] || return 1 [[ -n "$HOSTNAME_OVERRIDE" && ${#HOSTNAME_OVERRIDE} -le 253 && "$HOSTNAME_OVERRIDE" =~ ^[A-Za-z0-9._:-]+$ ]] || return 1 - [[ "$PULSE_URL" =~ ^https?://[^[:space:]]+$ ]] || return 1 - - curl_config=$(mktemp) - chmod 0600 "$curl_config" - printf 'header = "Authorization: Bearer %s"\nheader = "Content-Type: application/json"\n' \ - "$active_token" > "$curl_config" - active_token="" - payload="{\"agentId\":\"${AGENT_ID}\",\"hostname\":\"${HOSTNAME_OVERRIDE}\"}" - reduce_args=(--config "$curl_config" -fsS --connect-timeout 5 --max-time 15 -X POST --data-binary "$payload") - if [[ "$INSECURE" == "true" ]]; then - reduce_args+=(-k) - elif [[ -n "$CURL_CA_BUNDLE" ]]; then - reduce_args+=(--cacert "$CURL_CA_BUNDLE") - fi - if curl "${reduce_args[@]}" "${PULSE_URL%/}/api/agents/collector/reduce-authority" >/dev/null 2>&1; then - rm -f "$curl_config" + if run_collector_lifecycle_command collector-reduce-authority \ + --agent-id "$AGENT_ID" --hostname "$HOSTNAME_OVERRIDE" >/dev/null 2>&1; then log_info "Durably removed execution and cross-host management scopes from the collector credential before migration." return 0 fi - rm -f "$curl_config" return 1 } teardown_action_runner_service() { - local had_runner_config="false" - if [[ -f "$ACTION_RUNNER_ENV_FILE" || -f "$ACTION_RUNNER_TOKEN_FILE" ]]; then - had_runner_config="true" - fi - if revoke_action_runner_credential; then - log_info "Revoked the action-runner credential before removing local runner state." - elif [[ "$had_runner_config" == "true" ]]; then - log_warn "Could not self-revoke the action-runner credential; local runner removal will continue. Revoke the credential from Pulse if the server was unreachable." - fi - if command -v systemctl >/dev/null 2>&1; then - systemctl stop "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true - systemctl disable "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true - rm -f "$ACTION_RUNNER_SERVICE_UNIT" - systemctl daemon-reload 2>/dev/null || true - systemctl reset-failed "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true - fi - rm -f "$ACTION_RUNNER_BINARY_PATH" - rm -rf "$ACTION_RUNNER_CONFIG_DIR" "$ACTION_RUNNER_STATE_DIR" + local had_runner_artifact="false" + if [[ -e "$ACTION_RUNNER_BINARY_PATH" || -e "$ACTION_RUNNER_SERVICE_UNIT" || + -e "$ACTION_RUNNER_CONFIG_DIR" || -e "$ACTION_RUNNER_STATE_DIR" ]]; then + had_runner_artifact="true" + fi + if command -v systemctl >/dev/null 2>&1; then + systemctl stop "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true + systemctl disable "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true + fi + if [[ "$had_runner_artifact" == "true" ]]; then + if revoke_action_runner_credential; then + log_info "Revoked the action-runner credential before removing local runner recovery material." + else + log_error "Could not confirm action-runner credential revocation. The runner is stopped and disabled; every local artifact was retained for a safe retry or manual server-side revoke." + fail "Action runner removal requires a successful credential revocation; retry with the exact root-only credential, or revoke it in Pulse before manual cleanup" "$EXIT_GENERAL" + fi + fi + rm -f "$ACTION_RUNNER_SERVICE_UNIT" + if command -v systemctl >/dev/null 2>&1; then + systemctl daemon-reload 2>/dev/null || true + systemctl reset-failed "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true + fi + rm -f "$ACTION_RUNNER_BINARY_PATH" + rm -rf "$ACTION_RUNNER_CONFIG_DIR" "$ACTION_RUNNER_STATE_DIR" } teardown_openrc_agent_service() { @@ -1299,6 +1347,9 @@ ProtectControlGroups=true LockPersonality=true RestrictSUIDSGID=true SystemCallArchitectures=native +TasksMax=64 +LimitNOFILE=256 +MemoryMax=256M ReadOnlyPaths=${PRIVILEGED_HELPER_UPDATE_QUARANTINE_DIR} ReadWritePaths=${PRIVILEGED_HELPER_STATE_DIR} /usr/local/bin EOF @@ -1343,12 +1394,19 @@ EOF write_action_runner_config() { local runner_hostname="$HOSTNAME_OVERRIDE" + local runner_agent_id="" if [[ -L "$ACTION_RUNNER_CONFIG_DIR" || -L "$ACTION_RUNNER_STATE_DIR" || -L "$ACTION_RUNNER_TOKEN_FILE" || -L "$ACTION_RUNNER_ENV_FILE" ]]; then fail "Refusing unsafe symlink in the action-runner config or state boundary" "$EXIT_GENERAL" fi install -d -o root -g root -m 0700 "$ACTION_RUNNER_CONFIG_DIR" install -d -o root -g root -m 0700 "$ACTION_RUNNER_STATE_DIR" + action_runner_url_transport_allowed "$PULSE_URL" || + fail "Action runner requires HTTPS/WSS; plaintext HTTP/WS is allowed only for loopback local use" "$EXIT_MISSING_ARGS" + if [[ "$INSECURE" == "true" && "$PULSE_URL" =~ ^[Hh][Tt][Tt][Pp][Ss]:// && + -z "$CURL_CA_BUNDLE" && -z "$SERVER_FINGERPRINT" ]]; then + fail "Action runner refuses generic insecure HTTPS; configure a trusted CA bundle or exact server fingerprint" "$EXIT_MISSING_ARGS" + fi if [[ -n "$ACTION_TOKEN" ]]; then printf '%s\n' "$ACTION_TOKEN" > "$ACTION_RUNNER_TOKEN_FILE" @@ -1366,6 +1424,9 @@ write_action_runner_config() { fail "Action runner requires a canonical hostname" "$EXIT_MISSING_ARGS" [[ "$ACTION_RUNNER_ACTIVATION_NONCE" =~ ^[a-f0-9]{64}$ ]] || fail "Action runner requires a fresh activation nonce" "$EXIT_GENERAL" + runner_agent_id=$(resolve_action_runner_agent_id) || + fail "Action runner requires a safely resolved canonical collector identity" "$EXIT_MISSING_ARGS" + AGENT_ID="$runner_agent_id" : > "$ACTION_RUNNER_ENV_FILE" chmod 0600 "$ACTION_RUNNER_ENV_FILE" @@ -1374,12 +1435,7 @@ write_action_runner_config() { write_action_runner_env_value "PULSE_AGENT_RUNNER_STATE_DIR" "$ACTION_RUNNER_STATE_DIR" write_action_runner_env_value "PULSE_AGENT_RUNNER_HEALTH_FILE" "$ACTION_RUNNER_HEALTH_FILE" write_action_runner_env_value "PULSE_AGENT_RUNNER_ACTIVATION_NONCE" "$ACTION_RUNNER_ACTIVATION_NONCE" - if [[ -n "$AGENT_ID" ]]; then - [[ ${#AGENT_ID} -le 128 && "$AGENT_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._:-]*$ ]] || - fail "Action runner requires a valid canonical agent identity" "$EXIT_MISSING_ARGS" - write_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID" "$AGENT_ID" - fi - write_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID_FILE" "${STATE_DIR%/}/agent-id" + write_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID" "$runner_agent_id" write_action_runner_env_value "PULSE_AGENT_RUNNER_HOSTNAME" "$runner_hostname" if [[ -n "$SERVER_FINGERPRINT" ]]; then write_action_runner_env_value "PULSE_SERVER_FINGERPRINT" "$SERVER_FINGERPRINT" @@ -1387,7 +1443,7 @@ write_action_runner_config() { if [[ -n "$CURL_CA_BUNDLE" ]]; then write_action_runner_env_value "SSL_CERT_FILE" "$CURL_CA_BUNDLE" fi - if [[ "$INSECURE" == "true" ]]; then + if [[ "$INSECURE" == "true" ]] && action_runner_url_uses_loopback_http "$PULSE_URL"; then write_action_runner_env_value "PULSE_INSECURE" "true" fi chown root:root "$ACTION_RUNNER_ENV_FILE" @@ -1420,6 +1476,128 @@ action_runner_health_matches_activation() { [[ "$health_agent_id" == "$expected_agent_id" && "$health_activation_nonce" == "$expected_nonce" ]] } +# Print pending or active for the exact installed runner credential. Failure is +# intentionally indeterminate: callers must not restore a predecessor because +# an unreachable server may already have committed and revoked it. +action_runner_url_uses_loopback_http() { + local raw_url="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" + local authority="" + local host="" + local octet="" + local -a octets + + [[ "$raw_url" =~ ^http://[^[:space:]]+$ ]] || return 1 + authority="${raw_url#http://}" + authority="${authority%%/*}" + [[ -n "$authority" && "$authority" != *'@'* ]] || return 1 + if [[ "$authority" == \[* ]]; then + host="${authority#\[}" + host="${host%%\]*}" + [[ "$authority" == "[${host}]" || "$authority" == "[${host}]:"* ]] || return 1 + [[ "$host" == "::1" ]] + return + fi + [[ "$authority" != *:*:* ]] || return 1 + host="${authority%%:*}" + host="${host%.}" + if [[ "$host" == "localhost" ]]; then + return 0 + fi + [[ "$host" =~ ^127\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]] || return 1 + IFS='.' read -r -a octets <<< "$host" + for octet in "${octets[@]}"; do + (( 10#$octet <= 255 )) || return 1 + done + return 0 +} + +action_runner_url_transport_allowed() { + local raw_url="$1" + local lower_url="$(printf '%s' "$raw_url" | tr '[:upper:]' '[:lower:]')" + if [[ "$lower_url" =~ ^https://[^[:space:]]+$ && "$lower_url" != *'@'* ]]; then + return 0 + fi + action_runner_url_uses_loopback_http "$raw_url" +} + +# Atomically cancel the exact pending replacement. Only the runner command's +# zero exit (server HTTP 204) authorizes predecessor restore. The bearer is +# written to a private temporary file and never appears in argv or curl. +cancel_pending_action_runner_credential() { + local expected_agent_id="$1" + local expected_hostname="$2" + local replacement_token="$3" + local token_tmp="" + local old_umask="" + local -a cancel_args + + [[ -x "$ACTION_RUNNER_BINARY_PATH" && -d "$ACTION_RUNNER_CONFIG_DIR" && ! -L "$ACTION_RUNNER_CONFIG_DIR" ]] || return 1 + [[ -n "$expected_agent_id" && -n "$expected_hostname" && -n "$replacement_token" ]] || return 1 + action_runner_url_transport_allowed "$PULSE_URL" || return 1 + old_umask=$(umask) + umask 077 + token_tmp=$(mktemp "${ACTION_RUNNER_CONFIG_DIR%/}/.cancel-token.XXXXXX") || { + umask "$old_umask" + return 1 + } + if ! printf '%s\n' "$replacement_token" > "$token_tmp" || + ! chown root:root "$token_tmp" || ! chmod 0600 "$token_tmp" || ! sync -f "$token_tmp"; then + rm -f "$token_tmp" + umask "$old_umask" + return 1 + fi + umask "$old_umask" + cancel_args=(cancel-pending-credential --url "$PULSE_URL" --token-file "$token_tmp") + [[ -n "${CURL_CA_BUNDLE:-}" ]] && cancel_args+=(--cacert "$CURL_CA_BUNDLE") + [[ -n "${SERVER_FINGERPRINT:-}" ]] && cancel_args+=(--server-fingerprint "$SERVER_FINGERPRINT") + action_runner_url_uses_loopback_http "$PULSE_URL" && cancel_args+=(--insecure-loopback) + if "$ACTION_RUNNER_BINARY_PATH" "${cancel_args[@]}"; then + rm -f "$token_tmp" + return 0 + fi + rm -f "$token_tmp" + return 1 +} + +persist_action_runner_replacement_token() { + local replacement_token="$1" + local token_dir="$(dirname "$ACTION_RUNNER_TOKEN_FILE")" + local token_tmp="" + local old_umask="" + local token_owner="" + local token_mode="" + + [[ -n "$replacement_token" && "$replacement_token" != *$'\r'* && "$replacement_token" != *$'\n'* ]] || return 1 + [[ "$ACTION_RUNNER_TOKEN_FILE" == "${ACTION_RUNNER_CONFIG_DIR%/}/"* && + -d "$token_dir" && ! -L "$token_dir" ]] || return 1 + [[ ! -e "$ACTION_RUNNER_TOKEN_FILE" || ( -f "$ACTION_RUNNER_TOKEN_FILE" && ! -L "$ACTION_RUNNER_TOKEN_FILE" ) ]] || return 1 + old_umask=$(umask) + umask 077 + token_tmp=$(mktemp "${token_dir}/.replacement-token.XXXXXX") || { + umask "$old_umask" + return 1 + } + if ! printf '%s\n' "$replacement_token" > "$token_tmp" || + ! chown root:root "$token_tmp" || + ! chmod 0600 "$token_tmp" || + ! sync -f "$token_tmp" || + ! mv -f "$token_tmp" "$ACTION_RUNNER_TOKEN_FILE"; then + rm -f "$token_tmp" + umask "$old_umask" + return 1 + fi + token_tmp="" + if ! sync -f "$token_dir"; then + umask "$old_umask" + return 1 + fi + umask "$old_umask" + token_owner=$(stat -c '%u' "$ACTION_RUNNER_TOKEN_FILE" 2>/dev/null || true) + token_mode=$(stat -c '%a' "$ACTION_RUNNER_TOKEN_FILE" 2>/dev/null || true) + [[ "$token_owner" == "0" && "$token_mode" == "600" && + -f "$ACTION_RUNNER_TOKEN_FILE" && ! -L "$ACTION_RUNNER_TOKEN_FILE" ]] +} + write_action_runner_env_value() { local key="$1" local value="$2" @@ -1433,8 +1611,23 @@ write_action_runner_env_value() { resolve_action_runner_agent_id() { local agent_id="${AGENT_ID:-}" - if [[ -z "$agent_id" && -s "${STATE_DIR%/}/agent-id" && ! -L "${STATE_DIR%/}/agent-id" ]]; then - agent_id=$(head -1 "${STATE_DIR%/}/agent-id" 2>/dev/null || true) + local persisted_agent_id="" + local lifecycle_binary="" + local collector_uid="" + local -a identity_args + + if [[ -z "$agent_id" && -f "${ACTION_RUNNER_ENV_FILE:-}" && ! -L "${ACTION_RUNNER_ENV_FILE:-}" ]]; then + agent_id=$(read_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID" 2>/dev/null || true) + fi + if [[ -z "$agent_id" ]]; then + lifecycle_binary=$(collector_lifecycle_binary) || return 1 + identity_args=(collector-read-agent-id --agent-id-file "${STATE_DIR%/}/agent-id") + collector_uid=$(id -u "$LEAST_PRIVILEGE_USER" 2>/dev/null || true) + if [[ "$collector_uid" =~ ^[0-9]+$ ]]; then + identity_args+=(--token-owner-uid "$collector_uid") + fi + persisted_agent_id=$("$lifecycle_binary" "${identity_args[@]}" 2>/dev/null || true) + agent_id="$persisted_agent_id" fi [[ ${#agent_id} -ge 1 && ${#agent_id} -le 128 && "$agent_id" =~ ^[A-Za-z0-9][A-Za-z0-9._:-]*$ ]] || return 1 printf '%s\n' "$agent_id" @@ -1450,6 +1643,18 @@ provision_action_runner() { local runner_active="false" local apply_succeeded="false" local activation_nonce="" + local credential_replacement_requested="false" + local replacement_action_token="" + local expected_agent_id="" + local expected_hostname="$HOSTNAME_OVERRIDE" + + if [[ -n "$ACTION_TOKEN" ]]; then + credential_replacement_requested="true" + replacement_action_token="$ACTION_TOKEN" + fi + if [[ -z "$expected_hostname" ]]; then + expected_hostname=$(hostname 2>/dev/null || true) + fi if [[ -d "$ACTION_RUNNER_STATE_DIR" ]]; then had_state_dir="true" @@ -1483,6 +1688,7 @@ provision_action_runner() { chmod 0644 "${ACTION_RUNNER_SERVICE_UNIT}.new" mv "${ACTION_RUNNER_SERVICE_UNIT}.new" "$ACTION_RUNNER_SERVICE_UNIT" systemctl daemon-reload + action_runner_verify_effective_target systemctl enable "${ACTION_RUNNER_NAME}.service" systemctl restart "${ACTION_RUNNER_NAME}.service" ); then @@ -1505,10 +1711,35 @@ provision_action_runner() { done fi - if [[ "$runner_active" != "true" ]]; then - log_error "New action runner did not become healthy; rolling back runner-only files while leaving monitoring active." - systemctl stop "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true - systemctl disable "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true + if [[ "$runner_active" != "true" ]]; then + systemctl stop "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true + if [[ "$credential_replacement_requested" == "true" ]]; then + expected_agent_id=$(resolve_action_runner_agent_id || true) + if ! cancel_pending_action_runner_credential "$expected_agent_id" "$expected_hostname" "$replacement_action_token"; then + log_error "The server did not durably confirm cancellation of the pending action-runner credential. The predecessor cannot be restored because activation may already be committed." + if ! persist_action_runner_replacement_token "$replacement_action_token"; then + replacement_action_token="" + log_error "Could not durably persist the exact replacement action-runner credential. The runner remains stopped, the predecessor was not restored, and action-runner re-enrollment is required." + ACTION_RUNNER_ACTIVATION_NONCE="" + fail "Action runner credential recovery requires re-enrollment; no predecessor credential was restored" "$EXIT_GENERAL" + fi + log_error "The exact replacement credential and runtime were retained durably; repair is required." + replacement_action_token="" + rm -f "${ACTION_RUNNER_BINARY_PATH}.new" "${ACTION_RUNNER_SERVICE_UNIT}.new" + rm -f \ + "${ACTION_RUNNER_BINARY_PATH}${backup_suffix}" \ + "${ACTION_RUNNER_SERVICE_UNIT}${backup_suffix}" \ + "${ACTION_RUNNER_ENV_FILE}${backup_suffix}" \ + "${ACTION_RUNNER_TOKEN_FILE}${backup_suffix}" + systemctl daemon-reload 2>/dev/null || true + systemctl enable --now "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true + ACTION_RUNNER_ACTIVATION_NONCE="" + fail "Action runner activation requires repair; the new credential and runtime were retained and the previous credential was not restored" "$EXIT_GENERAL" + fi + replacement_action_token="" + fi + log_error "New action runner did not become healthy before server activation committed; rolling back runner-only files while leaving monitoring active." + systemctl disable "${ACTION_RUNNER_NAME}.service" 2>/dev/null || true rm -f "${ACTION_RUNNER_BINARY_PATH}.new" "${ACTION_RUNNER_SERVICE_UNIT}.new" rm -f "$ACTION_RUNNER_HEALTH_FILE" for path in "$ACTION_RUNNER_BINARY_PATH" "$ACTION_RUNNER_SERVICE_UNIT" "$ACTION_RUNNER_ENV_FILE" "$ACTION_RUNNER_TOKEN_FILE"; do @@ -1531,6 +1762,7 @@ provision_action_runner() { fail "Action runner activation failed and its previous installation was restored; collector monitoring was not stopped or removed" "$EXIT_GENERAL" fi + replacement_action_token="" rm -f \ "${ACTION_RUNNER_BINARY_PATH}${backup_suffix}" \ "${ACTION_RUNNER_SERVICE_UNIT}${backup_suffix}" \ @@ -1593,29 +1825,146 @@ safe_profile_unit_property() { esac } -safe_profile_effective_unit_unoverridden() { +systemd_effective_unit_property() { + local unit_name="$1" + local property="$2" + systemctl show "$unit_name" --property "$property" --value 2>/dev/null +} + +systemd_effective_unit_unoverridden() { + local unit_name="$1" + local expected_fragment="$2" local fragment_path="" local drop_in_paths="" - fragment_path=$(systemctl show "${AGENT_NAME}.service" --property FragmentPath --value 2>/dev/null) || return 1 - drop_in_paths=$(systemctl show "${AGENT_NAME}.service" --property DropInPaths --value 2>/dev/null) || return 1 - [[ "$fragment_path" == "$SAFE_PROFILE_COLLECTOR_UNIT" && -z "$drop_in_paths" ]] + fragment_path=$(systemd_effective_unit_property "$unit_name" FragmentPath) || return 1 + drop_in_paths=$(systemd_effective_unit_property "$unit_name" DropInPaths) || return 1 + [[ "$fragment_path" == "$expected_fragment" && -z "$drop_in_paths" ]] +} + +systemd_effective_exec_argv() { + local unit_name="$1" + local exec_start="" + local argv="" + exec_start=$(systemd_effective_unit_property "$unit_name" ExecStart) || return 1 + [[ "$exec_start" == *"argv[]="* ]] || return 1 + argv="${exec_start#*argv[]=}" + argv="${argv%% ;*}" + printf '%s\n' "$argv" +} + +systemd_effective_exec_exact() { + local unit_name="$1" + local expected_binary="$2" + local argv="" + argv=$(systemd_effective_exec_argv "$unit_name") || return 1 + [[ "$argv" == "$expected_binary" ]] +} + +systemd_effective_words_equal() { + local unit_name="$1" + local property="$2" + shift 2 + local actual="" + local expected="" + actual=$(systemd_effective_unit_property "$unit_name" "$property") || return 1 + actual=$(printf '%s\n' "$actual" | tr '[:space:]' '\n' | sed '/^$/d' | LC_ALL=C sort -u | tr '\n' ' ' | sed 's/[[:space:]]*$//') + expected=$(printf '%s\n' "$@" | LC_ALL=C sort -u | tr '\n' ' ' | sed 's/[[:space:]]*$//') + [[ "$actual" == "$expected" ]] +} + +systemd_effective_common_hardening() { + local unit_name="$1" + [[ "$(systemd_effective_unit_property "$unit_name" UMask)" == "0077" ]] || return 1 + [[ "$(systemd_effective_unit_property "$unit_name" NoNewPrivileges)" == "yes" ]] || return 1 + [[ "$(systemd_effective_unit_property "$unit_name" PrivateTmp)" == "yes" ]] || return 1 + [[ "$(systemd_effective_unit_property "$unit_name" PrivateDevices)" == "no" ]] || return 1 + [[ "$(systemd_effective_unit_property "$unit_name" ProtectKernelTunables)" == "yes" ]] || return 1 + [[ "$(systemd_effective_unit_property "$unit_name" ProtectKernelModules)" == "yes" ]] || return 1 + [[ "$(systemd_effective_unit_property "$unit_name" ProtectControlGroups)" == "yes" ]] || return 1 + [[ "$(systemd_effective_unit_property "$unit_name" LockPersonality)" == "yes" ]] || return 1 + [[ "$(systemd_effective_unit_property "$unit_name" RestrictSUIDSGID)" == "yes" ]] || return 1 + [[ "$(systemd_effective_unit_property "$unit_name" SystemCallArchitectures)" == "native" ]] || return 1 +} + +safe_profile_verify_helper_effective_target() { + local helper_service="${PRIVILEGED_HELPER_NAME}.service" + local helper_socket="${PRIVILEGED_HELPER_NAME}.socket" + local listen="" + local environment_files="" + + systemd_effective_unit_unoverridden "$helper_service" "$PRIVILEGED_HELPER_SERVICE_UNIT" || return 1 + systemd_effective_unit_unoverridden "$helper_socket" "$PRIVILEGED_HELPER_SOCKET_UNIT" || return 1 + systemd_effective_exec_exact "$helper_service" "$PRIVILEGED_HELPER_BINARY_PATH" || return 1 + [[ "$(systemd_effective_unit_property "$helper_service" User)" == "root" ]] || return 1 + [[ "$(systemd_effective_unit_property "$helper_service" Group)" == "root" ]] || return 1 + [[ -z "$(systemd_effective_unit_property "$helper_service" AmbientCapabilities)" ]] || return 1 + systemd_effective_common_hardening "$helper_service" || return 1 + [[ "$(systemd_effective_unit_property "$helper_service" PrivateNetwork)" == "yes" ]] || return 1 + systemd_effective_words_equal "$helper_service" RestrictAddressFamilies AF_UNIX || return 1 + [[ "$(systemd_effective_unit_property "$helper_service" ProtectSystem)" == "strict" ]] || return 1 + [[ "$(systemd_effective_unit_property "$helper_service" ProtectHome)" == "yes" ]] || return 1 + [[ "$(systemd_effective_unit_property "$helper_service" TasksMax)" == "64" ]] || return 1 + [[ "$(systemd_effective_unit_property "$helper_service" LimitNOFILE)" == "256" ]] || return 1 + [[ "$(systemd_effective_unit_property "$helper_service" MemoryMax)" == "268435456" ]] || return 1 + [[ -z "$(systemd_effective_unit_property "$helper_service" Environment)" ]] || return 1 + environment_files=$(systemd_effective_unit_property "$helper_service" EnvironmentFiles) || return 1 + [[ -z "$environment_files" ]] || return 1 + systemd_effective_words_equal "$helper_service" ReadOnlyPaths "$PRIVILEGED_HELPER_UPDATE_QUARANTINE_DIR" || return 1 + systemd_effective_words_equal "$helper_service" ReadWritePaths "$PRIVILEGED_HELPER_STATE_DIR" /usr/local/bin || return 1 + + [[ "$(systemd_effective_unit_property "$helper_socket" SocketUser)" == "root" ]] || return 1 + [[ "$(systemd_effective_unit_property "$helper_socket" SocketGroup)" == "$LEAST_PRIVILEGE_USER" ]] || return 1 + [[ "$(systemd_effective_unit_property "$helper_socket" SocketMode)" == "0660" ]] || return 1 + [[ "$(systemd_effective_unit_property "$helper_socket" DirectoryMode)" == "0755" ]] || return 1 + [[ "$(systemd_effective_unit_property "$helper_socket" RemoveOnStop)" == "yes" ]] || return 1 + listen=$(systemd_effective_unit_property "$helper_socket" Listen) || return 1 + [[ "$listen" == *"${PRIVILEGED_HELPER_SOCKET_PATH}"* && "$listen" == *"Stream"* ]] || return 1 +} + +action_runner_verify_effective_target() { + local runner_service="${ACTION_RUNNER_NAME}.service" + local environment_files="" + + systemd_effective_unit_unoverridden "$runner_service" "$ACTION_RUNNER_SERVICE_UNIT" || return 1 + systemd_effective_exec_exact "$runner_service" "$ACTION_RUNNER_BINARY_PATH" || return 1 + [[ "$(systemd_effective_unit_property "$runner_service" User)" == "root" ]] || return 1 + [[ "$(systemd_effective_unit_property "$runner_service" Group)" == "root" ]] || return 1 + [[ -z "$(systemd_effective_unit_property "$runner_service" AmbientCapabilities)" ]] || return 1 + systemd_effective_common_hardening "$runner_service" || return 1 + [[ "$(systemd_effective_unit_property "$runner_service" PrivateNetwork)" == "no" ]] || return 1 + systemd_effective_words_equal "$runner_service" RestrictAddressFamilies AF_UNIX AF_INET AF_INET6 || return 1 + [[ "$(systemd_effective_unit_property "$runner_service" ProtectSystem)" == "no" ]] || return 1 + [[ "$(systemd_effective_unit_property "$runner_service" ProtectHome)" == "yes" ]] || return 1 + systemd_effective_words_equal "$runner_service" ReadWritePaths "$ACTION_RUNNER_STATE_DIR" || return 1 + environment_files=$(systemd_effective_unit_property "$runner_service" EnvironmentFiles) || return 1 + [[ "$environment_files" == "$ACTION_RUNNER_ENV_FILE (ignore_errors=no)" ]] +} + +safe_profile_effective_unit_unoverridden() { + systemd_effective_unit_unoverridden "${AGENT_NAME}.service" "$SAFE_PROFILE_COLLECTOR_UNIT" } safe_profile_verify_effective_target() { local unit_user="" local ambient="" - local exec_start="" + local exec_argv="" local environment="" safe_profile_effective_unit_unoverridden || return 1 unit_user=$(safe_profile_unit_property User) ambient=$(safe_profile_unit_property AmbientCapabilities) - exec_start=$(safe_profile_unit_property ExecStart) + exec_argv=$(systemd_effective_exec_argv "${AGENT_NAME}.service") || return 1 environment=$(safe_profile_unit_property Environment) [[ "$unit_user" == "$LEAST_PRIVILEGE_USER" ]] || return 1 [[ -z "$ambient" ]] || return 1 - [[ "$exec_start" != *"--enable-commands"* ]] || return 1 + systemd_effective_common_hardening "${AGENT_NAME}.service" || return 1 + [[ "$exec_argv" == "${INSTALL_DIR}/${BINARY_NAME}" || "$exec_argv" == "${INSTALL_DIR}/${BINARY_NAME} "* ]] || return 1 + [[ "$exec_argv" != *"--enable-commands"* ]] || return 1 [[ "$environment" == *"PULSE_AGENT_HELPER_SOCKET=${PRIVILEGED_HELPER_SOCKET_PATH}"* ]] || return 1 + safe_profile_verify_helper_effective_target || return 1 + if [[ -e "$ACTION_RUNNER_SERVICE_UNIT" ]]; then + action_runner_verify_effective_target || return 1 + fi } safe_profile_inspect() { @@ -4870,9 +5219,8 @@ fi if [[ "$SAFE_PROFILE_ACTION" == "apply" ]]; then safe_profile_platform_supported || fail "Safe-profile migration is supported only on standard Linux systemd hosts; no broader-privilege fallback was applied" "$EXIT_MISSING_ARGS" - safe_profile_begin_transaction - reduce_safe_profile_collector_authority || - fail "Safe-profile migration could not durably remove command and cross-host management authority from the existing collector credential; no privilege change was retained" "$EXIT_AUTH_REJECTED" + resolve_safe_profile_hostname || + fail "Safe-profile migration could not resolve a canonical local hostname" "$EXIT_MISSING_ARGS" fi # Create the dedicated service account for the least-privilege profile and @@ -5051,6 +5399,9 @@ provision_typed_privileged_helper() { append_service_env "PULSE_AGENT_HELPER_SOCKET" "$PRIVILEGED_HELPER_SOCKET_PATH" systemctl daemon-reload + if ! safe_profile_verify_helper_effective_target; then + fail "Refusing typed-helper activation because the effective helper service or socket differs from the installer-owned safe profile" "$EXIT_GENERAL" + fi if ! systemctl enable --now "${PRIVILEGED_HELPER_NAME}.socket"; then fail "Failed to enable the typed privileged helper socket" "$EXIT_GENERAL" fi @@ -5433,7 +5784,26 @@ if [[ "$ACTION_RUNNER_ENABLED" == "true" ]]; then fi chmod 0755 "$TMP_BIN" -NEW_VERSION=$("$TMP_BIN" --version 2>/dev/null | head -1 || echo "unknown") +if [[ "$SAFE_PROFILE_ACTION" == "apply" ]]; then + # The irreversible server-side reduction must run only through the staged + # binary whose checksum/signature have just been verified. The installed + # predecessor may not expose the authenticated lifecycle commands yet. + # Snapshot first, then reduce, before stopping or replacing any local + # runtime so a failed reduction leaves the legacy install untouched. + safe_profile_begin_transaction + SAFE_PROFILE_STAGED_COLLECTOR="${INSTALL_DIR}/.${BINARY_NAME}.safe-profile-new.$$" + TMP_FILES+=("$SAFE_PROFILE_STAGED_COLLECTOR") + install -o root -g root -m 0755 "$TMP_BIN" "$SAFE_PROFILE_STAGED_COLLECTOR" || + fail "Safe-profile migration could not stage the verified collector on the installation filesystem" "$EXIT_GENERAL" + COLLECTOR_LIFECYCLE_BINARY_PATH="$SAFE_PROFILE_STAGED_COLLECTOR" + reduce_safe_profile_collector_authority || + fail "Safe-profile migration could not durably remove command and cross-host management authority from the existing collector credential; no privilege change was retained" "$EXIT_AUTH_REJECTED" +fi +VERSION_PROBE_BINARY="$TMP_BIN" +if [[ "$SAFE_PROFILE_ACTION" == "apply" ]]; then + VERSION_PROBE_BINARY="$SAFE_PROFILE_STAGED_COLLECTOR" +fi +NEW_VERSION=$("$VERSION_PROBE_BINARY" --version 2>/dev/null | head -1 || echo "unknown") # Compare versions with any leading "v" stripped so the agent binary's "v6.0.4" # and the server /api/version "6.0.4" are treated as equal. Only a genuine @@ -5509,10 +5879,10 @@ fi log_info "Installing binary to ${INSTALL_DIR}/${BINARY_NAME}..." mkdir -p "$INSTALL_DIR" if [[ "$SAFE_PROFILE_ACTION" == "apply" ]]; then - SAFE_PROFILE_STAGED_COLLECTOR="${INSTALL_DIR}/.${BINARY_NAME}.safe-profile-new.$$" - TMP_FILES+=("$SAFE_PROFILE_STAGED_COLLECTOR") - install -o root -g root -m 0755 "$TMP_BIN" "$SAFE_PROFILE_STAGED_COLLECTOR" + [[ -x "$SAFE_PROFILE_STAGED_COLLECTOR" && -f "$SAFE_PROFILE_STAGED_COLLECTOR" ]] || + fail "Verified safe-profile collector staging artifact is unavailable" "$EXIT_GENERAL" mv "$SAFE_PROFILE_STAGED_COLLECTOR" "${INSTALL_DIR}/${BINARY_NAME}" + COLLECTOR_LIFECYCLE_BINARY_PATH="${INSTALL_DIR}/${BINARY_NAME}" else mv "$TMP_BIN" "${INSTALL_DIR}/${BINARY_NAME}" chmod 0755 "${INSTALL_DIR}/${BINARY_NAME}" diff --git a/scripts/installtests/install_sh_test.go b/scripts/installtests/install_sh_test.go index eb5d54d76..f02bfc3a2 100644 --- a/scripts/installtests/install_sh_test.go +++ b/scripts/installtests/install_sh_test.go @@ -2,6 +2,7 @@ package installtests import ( "encoding/json" + "encoding/pem" "errors" "fmt" "io" @@ -2745,6 +2746,28 @@ func extractInstallShellFunction(t *testing.T, name string) string { return string(match) } +func extractCollectorLifecycleShellFunctions(t *testing.T, includeVerify bool) string { + t.Helper() + functions := extractInstallShellFunction(t, "collector_lifecycle_binary") + "\n" + + extractInstallShellFunction(t, "prepare_collector_lifecycle_token_file") + "\n" + + extractInstallShellFunction(t, "run_collector_lifecycle_command") + if includeVerify { + functions += "\n" + extractInstallShellFunction(t, "verify_agent_server_registration") + } + return functions +} + +func buildPulseAgentLifecycleBinary(t *testing.T) string { + t.Helper() + binary := filepath.Join(t.TempDir(), "pulse-agent") + build := exec.Command("go", "build", "-o", binary, "./cmd/pulse-agent") + build.Dir = filepath.Dir(repoFile("go.mod")) + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build pulse-agent lifecycle binary: %v\n%s", err, output) + } + return binary +} + func extractRootInstallShellFunction(t *testing.T, name string) string { t.Helper() @@ -5082,9 +5105,8 @@ func TestSetupAutoUpdatesPreservesRCChannelWhenUpdatingExistingConfig(t *testing // transient "agent has not reported yet" state, so the installer can surface an // actionable error instead of leaving a silent 401 loop behind (issue #1515). func TestInstallSHVerifyAgentServerRegistrationDetectsRejectedToken(t *testing.T) { - urlEncode := extractInstallShellFunction(t, "url_encode") - curlWithPulseToken := extractInstallShellFunction(t, "curl_with_pulse_token") - verifyFn := extractInstallShellFunction(t, "verify_agent_server_registration") + pulseAgent := buildPulseAgentLifecycleBinary(t) + verifyFns := extractCollectorLifecycleShellFunctions(t, true) cases := []struct { name string @@ -5095,13 +5117,14 @@ func TestInstallSHVerifyAgentServerRegistrationDetectsRejectedToken(t *testing.T {"rejected token 401", http.StatusUnauthorized, `{"error":"Authentication required"}`, "rc=2"}, {"rejected token 403", http.StatusForbidden, `{"error":"missing_scope"}`, "rc=2"}, {"previous hostname owner during binding", http.StatusForbidden, `{"error":{"code":"agent_lookup_forbidden","message":"Agent does not belong to this API token"}}`, "rc=1"}, - {"registered", http.StatusOK, `{"success":true,"agent":{"id":"agent-omv"}}`, "rc=0"}, + {"registered", http.StatusOK, `{"success":true,"agent":{"id":"agent-omv","hostname":"omv","lastSeen":"2026-08-30T12:00:01Z"}}`, "rc=0"}, {"not reported yet", http.StatusNotFound, `{"error":"agent_not_found"}`, "rc=1"}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { + stateDir := t.TempDir() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.HasPrefix(r.URL.Path, "/api/agents/agent/lookup") { http.Error(w, "not found", http.StatusNotFound) @@ -5115,13 +5138,16 @@ func TestInstallSHVerifyAgentServerRegistrationDetectsRejectedToken(t *testing.T script := ` PULSE_URL="` + server.URL + `" PULSE_TOKEN="stale-token" + STATE_DIR="` + stateDir + `" + RUNTIME_TOKEN_FILE="" + COLLECTOR_LIFECYCLE_BINARY_PATH="` + pulseAgent + `" + LEAST_PRIVILEGE_USER="$(id -un)" + SERVER_FINGERPRINT="" AGENT_ID="" HOSTNAME_OVERRIDE="omv" INSECURE="false" CURL_CA_BUNDLE="" -` + curlWithPulseToken + ` -` + urlEncode + ` -` + verifyFn + ` +` + verifyFns + ` verify_agent_server_registration echo "rc=$?" ` @@ -5137,9 +5163,8 @@ func TestInstallSHVerifyAgentServerRegistrationDetectsRejectedToken(t *testing.T } func TestInstallSHVerifyAgentServerRegistrationPrefersCanonicalAgentID(t *testing.T) { - urlEncode := extractInstallShellFunction(t, "url_encode") - curlWithPulseToken := extractInstallShellFunction(t, "curl_with_pulse_token") - verifyFn := extractInstallShellFunction(t, "verify_agent_server_registration") + pulseAgent := buildPulseAgentLifecycleBinary(t) + verifyFns := extractCollectorLifecycleShellFunctions(t, true) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.URL.Query().Get("id"); got != "agent-current" { @@ -5148,20 +5173,23 @@ func TestInstallSHVerifyAgentServerRegistrationPrefersCanonicalAgentID(t *testin if got := r.URL.Query().Get("hostname"); got != "" { t.Errorf("hostname lookup = %q, want ID-only lookup", got) } - _, _ = w.Write([]byte(`{"success":true,"agent":{"id":"agent-current"}}`)) + _, _ = w.Write([]byte(`{"success":true,"agent":{"id":"agent-current","hostname":"hostname-owned-by-previous-token","lastSeen":"2026-08-30T12:00:01Z"}}`)) })) defer server.Close() script := ` PULSE_URL="` + server.URL + `" PULSE_TOKEN="install-token" + STATE_DIR="` + t.TempDir() + `" + RUNTIME_TOKEN_FILE="" + COLLECTOR_LIFECYCLE_BINARY_PATH="` + pulseAgent + `" + LEAST_PRIVILEGE_USER="$(id -un)" + SERVER_FINGERPRINT="" AGENT_ID="agent-current" HOSTNAME_OVERRIDE="hostname-owned-by-previous-token" INSECURE="false" CURL_CA_BUNDLE="" -` + curlWithPulseToken + ` -` + urlEncode + ` -` + verifyFn + ` +` + verifyFns + ` verify_agent_server_registration echo "rc=$?" ` @@ -5181,9 +5209,8 @@ func TestInstallSHVerifyAgentServerRegistrationPrefersCanonicalAgentID(t *testin // registration (issue #1644). A rejected token still short-circuits because // more polling cannot change a definitive 401/403. func TestInstallSHRegistrationRetryWindowOutlastsFirstReportCycle(t *testing.T) { - urlEncode := extractInstallShellFunction(t, "url_encode") - curlWithPulseToken := extractInstallShellFunction(t, "curl_with_pulse_token") - verifyFn := extractInstallShellFunction(t, "verify_agent_server_registration") + pulseAgent := buildPulseAgentLifecycleBinary(t) + verifyFns := extractCollectorLifecycleShellFunctions(t, true) retryFn := extractInstallShellFunction(t, "verify_agent_server_registration_with_retry") verifyStarted := extractInstallShellFunction(t, "verify_agent_started") @@ -5220,7 +5247,7 @@ func TestInstallSHRegistrationRetryWindowOutlastsFirstReportCycle(t *testing.T) } w.WriteHeader(tc.statuses[idx]) if tc.statuses[idx] == http.StatusOK { - _, _ = w.Write([]byte(`{"success":true,"agent":{"id":"agent-1644"}}`)) + _, _ = w.Write([]byte(`{"success":true,"agent":{"id":"agent-1644","hostname":"pve-1644","lastSeen":"2026-08-30T12:00:01Z"}}`)) } else { _, _ = w.Write([]byte(`{"error":"agent_not_found"}`)) } @@ -5230,14 +5257,17 @@ func TestInstallSHRegistrationRetryWindowOutlastsFirstReportCycle(t *testing.T) script := ` PULSE_URL="` + server.URL + `" PULSE_TOKEN="install-token" + STATE_DIR="` + t.TempDir() + `" + RUNTIME_TOKEN_FILE="" + COLLECTOR_LIFECYCLE_BINARY_PATH="` + pulseAgent + `" + LEAST_PRIVILEGE_USER="$(id -un)" + SERVER_FINGERPRINT="" AGENT_ID="" HOSTNAME_OVERRIDE="pve-1644" INSECURE="false" CURL_CA_BUNDLE="" sleep() { :; } -` + curlWithPulseToken + ` -` + urlEncode + ` -` + verifyFn + ` +` + verifyFns + ` ` + retryFn + ` verify_agent_server_registration_with_retry echo "rc=$?" @@ -5952,14 +5982,15 @@ func TestInstallSHActionRunnerIsSeparateOptInLifecycle(t *testing.T) { `The action runner must use a separate credential from the collector token`, `Preserving existing separately enabled action-runner profile`, `write_action_runner_env_value "PULSE_AGENT_RUNNER_TOKEN_FILE" "$ACTION_RUNNER_TOKEN_FILE"`, - `write_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID" "$AGENT_ID"`, - `write_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID_FILE" "${STATE_DIR%/}/agent-id"`, + `write_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID" "$runner_agent_id"`, + `collector-read-agent-id --agent-id-file "${STATE_DIR%/}/agent-id"`, + `identity_args=(collector-read-agent-id --agent-id-file "$runner_agent_id_file")`, `write_action_runner_env_value "PULSE_AGENT_RUNNER_HEALTH_FILE" "$ACTION_RUNNER_HEALTH_FILE"`, `write_action_runner_env_value "PULSE_AGENT_RUNNER_ACTIVATION_NONCE" "$ACTION_RUNNER_ACTIVATION_NONCE"`, `write_action_runner_env_value "PULSE_AGENT_RUNNER_HOSTNAME" "$runner_hostname"`, - `DELETE --data-binary "$payload"`, - `/api/agents/action-runner/credential`, - `Could not self-revoke the action-runner credential`, + `revoke-credential --url "$runner_url" --token-file "$runner_token_file"`, + `cancel-pending-credential --url "$PULSE_URL" --token-file "$token_tmp"`, + `Action runner removal requires a successful credential revocation`, `/download/${ACTION_RUNNER_BINARY_NAME}?${DOWNLOAD_QUERY}`, `verify_download_signature "$TMP_ACTION_RUNNER_BIN" "$runner_signature"`, `install -o root -g root -m 0755 "$TMP_ACTION_RUNNER_BIN"`, @@ -5967,6 +5998,11 @@ func TestInstallSHActionRunnerIsSeparateOptInLifecycle(t *testing.T) { `ACTION_TOKEN=""`, `action_runner_health_matches_activation "$expected_agent_id" "$activation_nonce"`, `[[ "$health_agent_id" == "$expected_agent_id" && "$health_activation_nonce" == "$expected_nonce" ]]`, + `cancel_pending_action_runner_credential "$expected_agent_id" "$expected_hostname" "$replacement_action_token"`, + `persist_action_runner_replacement_token "$replacement_action_token"`, + `if ! cancel_pending_action_runner_credential`, + `The exact replacement credential and runtime were retained durably`, + `did not durably confirm cancellation`, `rolling back runner-only files while leaving monitoring active`, `Pulse action runner removed. Collector monitoring was left installed and running.`, } { @@ -5978,6 +6014,9 @@ func TestInstallSHActionRunnerIsSeparateOptInLifecycle(t *testing.T) { if strings.Contains(teardown, "teardown_systemd_agent_service") || strings.Contains(teardown, `rm -f "${INSTALL_DIR}/${BINARY_NAME}"`) { t.Fatalf("runner teardown must not remove the monitoring collector:\n%s", teardown) } + if strings.Contains(script, `head -1 "$runner_agent_id_file"`) { + t.Fatal("legacy runner removal must not path-read the collector-owned identity file") + } } func TestInstallSHActionRunnerConfigBindsKnownAgentIDImmediately(t *testing.T) { @@ -6005,6 +6044,9 @@ fail() { printf '%s\n' "$1" >&2; exit "$2"; } install() { local destination="${!#}"; mkdir -p "$destination"; chmod 0700 "$destination"; } chown() { return 0; } ` + extractInstallShellFunction(t, "write_action_runner_env_value") + ` +` + extractInstallShellFunction(t, "action_runner_url_uses_loopback_http") + ` +` + extractInstallShellFunction(t, "action_runner_url_transport_allowed") + ` +` + extractInstallShellFunction(t, "resolve_action_runner_agent_id") + ` ` + extractInstallShellFunction(t, "write_action_runner_config") + ` write_action_runner_config ` @@ -6021,6 +6063,44 @@ write_action_runner_config } } +func TestInstallSHActionRunnerRejectsGenericInsecureHTTPSBeforeCredentialWrite(t *testing.T) { + root := t.TempDir() + tokenPath := filepath.Join(root, "config", "token") + script := ` +set -u +ACTION_RUNNER_CONFIG_DIR="` + filepath.Join(root, "config") + `" +ACTION_RUNNER_STATE_DIR="` + filepath.Join(root, "state") + `" +ACTION_RUNNER_TOKEN_FILE="` + tokenPath + `" +ACTION_RUNNER_ENV_FILE="` + filepath.Join(root, "config", "runner.env") + `" +ACTION_RUNNER_HEALTH_FILE="` + filepath.Join(root, "state", "health.json") + `" +ACTION_RUNNER_ACTIVATION_NONCE="` + strings.Repeat("a", 64) + `" +STATE_DIR="` + filepath.Join(root, "collector") + `" +HOSTNAME_OVERRIDE="host.local" +AGENT_ID="agent-1" +ACTION_TOKEN="must-not-be-written" +PULSE_URL="https://pulse.example" +SERVER_FINGERPRINT="" +CURL_CA_BUNDLE="" +INSECURE="true" +EXIT_GENERAL=1 +EXIT_MISSING_ARGS=2 +fail() { printf '%s\n' "$1" >&2; exit "$2"; } +install() { local destination="${!#}"; mkdir -p "$destination"; chmod 0700 "$destination"; } +` + extractInstallShellFunction(t, "write_action_runner_env_value") + ` +` + extractInstallShellFunction(t, "action_runner_url_uses_loopback_http") + ` +` + extractInstallShellFunction(t, "action_runner_url_transport_allowed") + ` +` + extractInstallShellFunction(t, "write_action_runner_config") + ` +write_action_runner_config +` + out, err := exec.Command("bash", "-c", script).CombinedOutput() + if err == nil || !strings.Contains(string(out), "refuses generic insecure HTTPS") { + t.Fatalf("generic insecure HTTPS was not rejected: %v\n%s", err, out) + } + if _, statErr := os.Stat(tokenPath); !os.IsNotExist(statErr) { + t.Fatalf("runner credential was written before transport rejection: %v", statErr) + } +} + func TestInstallSHActionRunnerDirectIdentityOverridesStaleCollectorFile(t *testing.T) { stateDir := t.TempDir() mustWrite(t, filepath.Join(stateDir, "agent-id"), "stale-agent-id\n") @@ -6040,6 +6120,108 @@ resolve_action_runner_agent_id } } +func TestInstallSHActionRunnerPersistsResolvedIdentityOutsideCollectorState(t *testing.T) { + root := t.TempDir() + stateDir := filepath.Join(root, "collector-state") + if err := os.MkdirAll(stateDir, 0o700); err != nil { + t.Fatal(err) + } + identityPath := filepath.Join(stateDir, "agent-id") + if err := os.WriteFile(identityPath, []byte("agent-canonical\n"), 0o640); err != nil { + t.Fatal(err) + } + pulseAgent := buildPulseAgentLifecycleBinary(t) + envFile := filepath.Join(root, "runner-config", "runner.env") + script := ` +set -euo pipefail +COLLECTOR_LIFECYCLE_BINARY_PATH="` + pulseAgent + `" +LEAST_PRIVILEGE_USER="$(id -un)" +INSTALL_DIR="` + root + `" +BINARY_NAME="legacy-agent" +TMP_BIN="" +ACTION_RUNNER_CONFIG_DIR="` + filepath.Join(root, "runner-config") + `" +ACTION_RUNNER_STATE_DIR="` + filepath.Join(root, "runner-state") + `" +ACTION_RUNNER_TOKEN_FILE="` + filepath.Join(root, "runner-config", "token") + `" +ACTION_RUNNER_ENV_FILE="` + envFile + `" +ACTION_RUNNER_HEALTH_FILE="` + filepath.Join(root, "runner-state", "health.json") + `" +ACTION_RUNNER_ACTIVATION_NONCE="` + strings.Repeat("a", 64) + `" +STATE_DIR="` + stateDir + `" +HOSTNAME_OVERRIDE="secure-runtime.lab" +AGENT_ID="" +ACTION_TOKEN="runner-secret" +PULSE_URL="https://pulse.example" +SERVER_FINGERPRINT="" +CURL_CA_BUNDLE="" +INSECURE="false" +EXIT_GENERAL=1 +EXIT_MISSING_ARGS=2 +fail() { printf '%s\n' "$1" >&2; exit "$2"; } +install() { local destination="${!#}"; mkdir -p "$destination"; chmod 0700 "$destination"; } +chown() { return 0; } +` + extractInstallShellFunction(t, "collector_lifecycle_binary") + ` +` + extractInstallShellFunction(t, "read_action_runner_env_value") + ` +` + extractInstallShellFunction(t, "resolve_action_runner_agent_id") + ` +` + extractInstallShellFunction(t, "write_action_runner_env_value") + ` +` + extractInstallShellFunction(t, "action_runner_url_uses_loopback_http") + ` +` + extractInstallShellFunction(t, "action_runner_url_transport_allowed") + ` +` + extractInstallShellFunction(t, "write_action_runner_config") + ` +write_action_runner_config +printf 'attacker-rewrite\n' > "${STATE_DIR}/agent-id" +grep '^PULSE_AGENT_RUNNER_AGENT_ID="agent-canonical"$' "$ACTION_RUNNER_ENV_FILE" +if grep -q 'PULSE_AGENT_RUNNER_AGENT_ID_FILE' "$ACTION_RUNNER_ENV_FILE"; then exit 9; fi +` + out, err := exec.Command("bash", "-c", script).CombinedOutput() + if err != nil { + t.Fatalf("persist action-runner identity: %v\n%s", err, out) + } +} + +func TestInstallSHSafeProfileLifecycleUsesVerifiedInstallFilesystemStage(t *testing.T) { + root := t.TempDir() + legacy := filepath.Join(root, "pulse-agent") + tmpSource := filepath.Join(root, "download.tmp") + staged := filepath.Join(root, ".pulse-agent.safe-profile-new") + logPath := filepath.Join(root, "binary.log") + mustWrite(t, legacy, "#!/bin/sh\nprintf 'legacy:%s\\n' \"$*\" >> \""+logPath+"\"\nexit 91\n") + mustWrite(t, tmpSource, "verified bytes that cannot execute on a noexec mount\n") + if err := os.Chmod(tmpSource, 0o644); err != nil { + t.Fatal(err) + } + mustWrite(t, staged, "#!/bin/sh\nprintf 'staged:%s\\n' \"$*\" >> \""+logPath+"\"\nexit 0\n") + script := ` +set -euo pipefail +INSTALL_DIR="` + root + `" +BINARY_NAME="pulse-agent" +TMP_BIN="` + tmpSource + `" +COLLECTOR_LIFECYCLE_BINARY_PATH="` + staged + `" +` + extractInstallShellFunction(t, "collector_lifecycle_binary") + ` +resolved=$(collector_lifecycle_binary) +[[ "$resolved" == "` + staged + `" ]] +"$resolved" collector-reduce-authority +` + if out, err := exec.Command("bash", "-c", script).CombinedOutput(); err != nil { + t.Fatalf("select staged lifecycle binary: %v\n%s", err, out) + } + logData, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(logData), "legacy:") || !strings.Contains(string(logData), "staged:collector-reduce-authority") { + t.Fatalf("lifecycle binary selection log: %s", logData) + } + installScript, err := os.ReadFile(repoFile("scripts", "install.sh")) + if err != nil { + t.Fatal(err) + } + source := string(installScript) + stageAt := strings.Index(source, `install -o root -g root -m 0755 "$TMP_BIN" "$SAFE_PROFILE_STAGED_COLLECTOR"`) + reduceAt := strings.Index(source, `reduce_safe_profile_collector_authority ||`) + moveAt := strings.Index(source, `mv "$SAFE_PROFILE_STAGED_COLLECTOR" "${INSTALL_DIR}/${BINARY_NAME}"`) + if stageAt < 0 || reduceAt <= stageAt || moveAt <= reduceAt { + t.Fatalf("safe-profile staged lifecycle ordering invalid: stage=%d reduce=%d move=%d", stageAt, reduceAt, moveAt) + } +} + func TestInstallSHActionRunnerReadinessRequiresCurrentActivationNonce(t *testing.T) { root := t.TempDir() healthPath := filepath.Join(root, "health.json") @@ -6095,6 +6277,250 @@ action_runner_health_matches_activation "agent-1" "` + nonce + `" } } +func TestInstallSHActionRunnerPostCommitReadinessFailureRetainsReplacement(t *testing.T) { + for _, test := range []struct { + name string + cancelBody string + persistFailure bool + rollback bool + tokenWriteBody string + wantToken string + }{ + { + name: "durable pending cancellation authorizes rollback", cancelBody: `[[ "$3" == "new-runner-token" ]] || return 1; return 0`, rollback: true, + tokenWriteBody: `printf 'new-runner-token\n' > "$ACTION_RUNNER_TOKEN_FILE"`, wantToken: "old:token\n", + }, + { + name: "activation committed or cancel indeterminate", cancelBody: `[[ "$3" == "new-runner-token" ]] || return 1; return 1`, + tokenWriteBody: `printf 'new-runner-token\n' > "$ACTION_RUNNER_TOKEN_FILE"`, wantToken: "new-runner-token\n", + }, + { + name: "replacement token durable write failure", cancelBody: "return 1", persistFailure: true, + tokenWriteBody: `: > "$ACTION_RUNNER_TOKEN_FILE"`, wantToken: "", + }, + } { + t.Run(test.name, func(t *testing.T) { + testInstallSHActionRunnerReadinessFailureRetainsReplacement(t, test.cancelBody, test.persistFailure, test.rollback, test.tokenWriteBody, test.wantToken) + }) + } +} + +func testInstallSHActionRunnerReadinessFailureRetainsReplacement(t *testing.T, cancelBody string, persistFailure, rollback bool, tokenWriteBody, wantToken string) { + t.Helper() + cancelFunction := `cancel_pending_action_runner_credential() { ` + cancelBody + `; }` + persistFunction := extractInstallShellFunction(t, "persist_action_runner_replacement_token") + if persistFailure { + persistFunction = `persist_action_runner_replacement_token() { return 1; }` + } + root := t.TempDir() + binPath := filepath.Join(root, "bin", "pulse-agent-runner") + unitPath := filepath.Join(root, "systemd", "pulse-agent-runner.service") + envPath := filepath.Join(root, "config", "runner.env") + tokenPath := filepath.Join(root, "config", "token") + stateDir := filepath.Join(root, "state") + for _, path := range []string{binPath, unitPath, envPath, tokenPath} { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + mustWrite(t, path, "old:"+filepath.Base(path)+"\n") + } + if err := os.MkdirAll(stateDir, 0o700); err != nil { + t.Fatal(err) + } + newBinary := filepath.Join(root, "new-runner") + mustWrite(t, newBinary, "new:runner\n") + serviceLog := filepath.Join(root, "systemctl.log") + + script := ` +set -u +ACTION_RUNNER_NAME="pulse-agent-runner" +ACTION_RUNNER_BINARY_PATH="` + binPath + `" +ACTION_RUNNER_SERVICE_UNIT="` + unitPath + `" +ACTION_RUNNER_ENV_FILE="` + envPath + `" +ACTION_RUNNER_TOKEN_FILE="` + tokenPath + `" +ACTION_RUNNER_HEALTH_FILE="` + filepath.Join(stateDir, "health.json") + `" +ACTION_RUNNER_STATE_DIR="` + stateDir + `" +ACTION_RUNNER_CONFIG_DIR="` + filepath.Dir(tokenPath) + `" +PRIVILEGE_HELPER_DIR="` + filepath.Join(root, "helper") + `" +TMP_ACTION_RUNNER_BIN="` + newBinary + `" +ACTION_TOKEN="new-runner-token" +ACTION_RUNNER_ACTIVATION_NONCE="" +HOSTNAME_OVERRIDE="host-1.local" +STATE_DIR="` + filepath.Join(root, "collector-state") + `" +PULSE_URL="http://127.0.0.1:7655" +INSECURE="true" +CURL_CA_BUNDLE="" +EXIT_GENERAL=1 +generate_action_runner_activation_nonce() { printf '%064d\n' 0; } +restore_selinux_contexts() { return 0; } +write_action_runner_config() { + ` + tokenWriteBody + ` + printf 'new:env\n' > "$ACTION_RUNNER_ENV_FILE" +} +render_action_runner_service_unit() { printf 'new:unit\n' > "$1"; } +install() { + local source="${@: -2:1}" + local destination="${@: -1}" + mkdir -p "$(dirname "$destination")" + cp "$source" "$destination" +} +chown() { return 0; } +chmod() { return 0; } +stat() { + case "$1:$2" in + '-c:%u') printf '0\n' ;; + '-c:%a') printf '600\n' ;; + *) command stat "$@" ;; + esac +} +systemctl() { + printf '%s\n' "$*" >> "` + serviceLog + `" + return 0 +} +action_runner_health_matches_activation() { return 1; } +resolve_action_runner_agent_id() { printf 'agent-1\n'; } +` + cancelFunction + ` +sleep() { return 0; } +log_error() { printf 'ERROR: %s\n' "$1" >&2; } +log_info() { printf 'INFO: %s\n' "$1"; } +fail() { printf 'FAIL: %s\n' "$1" >&2; exit "$2"; } +` + persistFunction + ` +` + extractInstallShellFunction(t, "provision_action_runner") + ` +provision_action_runner +` + out, err := exec.Command("bash", "-c", script).CombinedOutput() + if err == nil { + t.Fatalf("post-commit readiness failure unexpectedly succeeded:\n%s", out) + } + if rollback { + if !strings.Contains(string(out), "rolling back runner-only files") || strings.Contains(string(out), "retained durably") { + t.Fatalf("204 cancellation did not exclusively authorize predecessor restore:\n%s", out) + } + } else if persistFailure { + if strings.Contains(string(out), "retained durably") || !strings.Contains(string(out), "re-enrollment") { + t.Fatalf("durable token failure did not fail closed for re-enrollment:\n%s", out) + } + } else if !strings.Contains(string(out), "replacement credential and runtime were retained durably") || !strings.Contains(string(out), "repair") { + t.Fatalf("missing repair-required result:\n%s", out) + } + if !rollback && !strings.Contains(string(out), "did not durably confirm cancellation") { + t.Fatalf("missing atomic cancellation diagnostic:\n%s", out) + } + wantBinary, wantUnit, wantEnv := "new:runner\n", "new:unit\n", "new:env\n" + if rollback { + wantBinary, wantUnit, wantEnv = "old:pulse-agent-runner\n", "old:pulse-agent-runner.service\n", "old:runner.env\n" + } + for path, want := range map[string]string{ + binPath: wantBinary, + unitPath: wantUnit, + envPath: wantEnv, + tokenPath: wantToken, + } { + data, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatalf("read retained %s: %v", path, readErr) + } + if string(data) != want { + t.Fatalf("retained %s = %q, want %q", path, data, want) + } + } + backups, err := filepath.Glob(filepath.Join(root, "**", "*.pulse-install-backup.*")) + if err != nil { + t.Fatal(err) + } + if persistFailure && len(backups) == 0 { + t.Fatal("durable token failure removed every predecessor backup before repair") + } + if !persistFailure && len(backups) != 0 { + t.Fatalf("revoked predecessor backups retained: %v", backups) + } + serviceCalls, err := os.ReadFile(serviceLog) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(serviceCalls), "stop pulse-agent-runner.service") { + t.Fatalf("replacement was not stopped before classification: %s", serviceCalls) + } + if persistFailure && strings.Contains(string(serviceCalls), "enable --now pulse-agent-runner.service") { + t.Fatalf("runner restarted without a durably retained replacement credential: %s", serviceCalls) + } + if !persistFailure && !strings.Contains(string(serviceCalls), "enable --now pulse-agent-runner.service") { + t.Fatalf("replacement was not stopped for classification and restarted for repair: %s", serviceCalls) + } +} + +func TestInstallSHActionRunnerAtomicCancelUsesPrivateGoTransportAndRequires204(t *testing.T) { + const token = "runner-cancel-secret" + root := t.TempDir() + runnerBinary := filepath.Join(root, "pulse-agent-runner") + build := exec.Command("go", "build", "-o", runnerBinary, "./cmd/pulse-agent-runner") + build.Dir = filepath.Dir(repoFile("go.mod")) + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build action runner: %v\n%s", err, output) + } + caSource := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + defer caSource.Close() + caFile := filepath.Join(root, "runner-ca.pem") + if err := os.WriteFile(caFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caSource.Certificate().Raw}), 0o600); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + statusCode int + wantOK bool + }{{"durable cancellation", http.StatusNoContent, true}, {"activation committed", http.StatusConflict, false}} { + t.Run(tc.name, func(t *testing.T) { + var gotAuthorization, gotMethod, gotBody string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuthorization = r.Header.Get("Authorization") + gotMethod = r.Method + body, _ := io.ReadAll(r.Body) + gotBody = string(body) + w.WriteHeader(tc.statusCode) + })) + defer server.Close() + configDir := filepath.Join(root, strings.ReplaceAll(tc.name, " ", "-")) + if err := os.MkdirAll(configDir, 0o700); err != nil { + t.Fatal(err) + } + argsLog := filepath.Join(configDir, "args.log") + wrapper := filepath.Join(configDir, "runner-wrapper") + mustWrite(t, wrapper, "#!/bin/bash\nprintf '%s\\n' \"$@\" > \""+argsLog+"\"\nexec \""+runnerBinary+"\" \"$@\"\n") + if err := os.Chmod(wrapper, 0o755); err != nil { + t.Fatal(err) + } + script := ` +set -euo pipefail +ACTION_RUNNER_BINARY_PATH="` + wrapper + `" +ACTION_RUNNER_CONFIG_DIR="` + configDir + `" +PULSE_URL="` + server.URL + `" +CURL_CA_BUNDLE="` + caFile + `" +SERVER_FINGERPRINT="` + strings.Repeat("ab", 32) + `" +chown() { return 0; } +sync() { return 0; } +` + extractInstallShellFunction(t, "action_runner_url_uses_loopback_http") + ` +` + extractInstallShellFunction(t, "action_runner_url_transport_allowed") + ` +` + extractInstallShellFunction(t, "cancel_pending_action_runner_credential") + ` +cancel_pending_action_runner_credential "agent-1" "host-1.local" "` + token + `" +` + out, err := exec.Command("bash", "-c", script).CombinedOutput() + if (err == nil) != tc.wantOK { + t.Fatalf("cancel result error=%v, want success=%v\n%s", err, tc.wantOK, out) + } + if gotAuthorization != "Bearer "+token || gotMethod != http.MethodDelete || gotBody != "" { + t.Fatalf("cancel request auth=%q method=%q body=%q", gotAuthorization, gotMethod, gotBody) + } + if strings.Contains(string(out), token) { + t.Fatalf("cancel credential leaked in output: %s", out) + } + args, readErr := os.ReadFile(argsLog) + if readErr != nil || !strings.Contains(string(args), "--cacert\n"+caFile) || !strings.Contains(string(args), "--server-fingerprint\n"+strings.Repeat("ab", 32)) { + t.Fatalf("runner lifecycle CA/pin args = %q, error=%v", args, readErr) + } + }) + } +} + func TestInstallSHActionRunnerSelfRevokeUsesPrivateCredential(t *testing.T) { const token = "runner-secret-that-must-not-appear-in-output" var gotAuthorization string @@ -6108,12 +6534,17 @@ func TestInstallSHActionRunnerSelfRevokeUsesPrivateCredential(t *testing.T) { http.Error(w, "bad request", http.StatusBadRequest) return } - w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, `{"success":true}`) + w.WriteHeader(http.StatusNoContent) })) defer server.Close() root := t.TempDir() + runnerBinary := filepath.Join(root, "pulse-agent-runner") + build := exec.Command("go", "build", "-o", runnerBinary, "./cmd/pulse-agent-runner") + build.Dir = filepath.Dir(repoFile("go.mod")) + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build action runner: %v\n%s", err, output) + } envFile := filepath.Join(root, "runner.env") tokenFile := filepath.Join(root, "token") mustWrite(t, tokenFile, token+"\n") @@ -6126,16 +6557,20 @@ func TestInstallSHActionRunnerSelfRevokeUsesPrivateCredential(t *testing.T) { `PULSE_AGENT_RUNNER_AGENT_ID="agent-secure-runtime"`, `PULSE_AGENT_RUNNER_AGENT_ID_FILE="` + filepath.Join(root, "missing-agent-id") + `"`, `PULSE_AGENT_RUNNER_TOKEN_FILE="` + tokenFile + `"`, + `PULSE_INSECURE="true"`, }, "\n")+"\n") script := ` set -euo pipefail ACTION_RUNNER_ENV_FILE="` + envFile + `" +ACTION_RUNNER_BINARY_PATH="` + runnerBinary + `" stat() { if [[ "$1" == "-c" && "$2" == "%a" ]]; then printf '600\n'; return 0; fi command stat "$@" } ` + extractInstallShellFunction(t, "read_action_runner_env_value") + ` +` + extractInstallShellFunction(t, "action_runner_url_uses_loopback_http") + ` +` + extractInstallShellFunction(t, "action_runner_url_transport_allowed") + ` ` + extractInstallShellFunction(t, "revoke_action_runner_credential") + ` revoke_action_runner_credential printf 'revoked\n' @@ -6158,6 +6593,58 @@ printf 'revoked\n' } } +func TestInstallSHActionRunnerUninstallRetainsRecoveryMaterialWhenRevokeIsUnconfirmed(t *testing.T) { + root := t.TempDir() + paths := []string{ + filepath.Join(root, "bin", "pulse-agent-runner"), + filepath.Join(root, "systemd", "pulse-agent-runner.service"), + filepath.Join(root, "config", "runner.env"), + filepath.Join(root, "config", "token"), + filepath.Join(root, "state", "health.json"), + } + for _, path := range paths { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + mustWrite(t, path, "retained\n") + } + serviceLog := filepath.Join(root, "systemctl.log") + script := ` +set -u +ACTION_RUNNER_NAME="pulse-agent-runner" +ACTION_RUNNER_BINARY_PATH="` + paths[0] + `" +ACTION_RUNNER_SERVICE_UNIT="` + paths[1] + `" +ACTION_RUNNER_ENV_FILE="` + paths[2] + `" +ACTION_RUNNER_TOKEN_FILE="` + paths[3] + `" +ACTION_RUNNER_CONFIG_DIR="` + filepath.Dir(paths[3]) + `" +ACTION_RUNNER_STATE_DIR="` + filepath.Dir(paths[4]) + `" +EXIT_GENERAL=1 +systemctl() { printf '%s\n' "$*" >> "` + serviceLog + `"; return 0; } +revoke_action_runner_credential() { return 1; } +log_error() { printf 'ERROR: %s\n' "$1" >&2; } +log_info() { printf 'INFO: %s\n' "$1"; } +fail() { printf 'FAIL: %s\n' "$1" >&2; exit "$2"; } +` + extractInstallShellFunction(t, "teardown_action_runner_service") + ` +teardown_action_runner_service +` + out, err := exec.Command("bash", "-c", script).CombinedOutput() + if err == nil || !strings.Contains(string(out), "successful credential revocation") || !strings.Contains(string(out), "retained for a safe retry") { + t.Fatalf("unconfirmed revoke did not fail closed: %v\n%s", err, out) + } + for _, path := range paths { + if _, statErr := os.Stat(path); statErr != nil { + t.Fatalf("recovery material %s was removed: %v", path, statErr) + } + } + serviceCalls, readErr := os.ReadFile(serviceLog) + if readErr != nil { + t.Fatal(readErr) + } + if !strings.Contains(string(serviceCalls), "stop pulse-agent-runner.service") || !strings.Contains(string(serviceCalls), "disable pulse-agent-runner.service") { + t.Fatalf("runner was not stopped and disabled before failed revoke: %s", serviceCalls) + } +} + func TestInstallSHSafeProfileDurablyReducesCollectorAuthority(t *testing.T) { const token = "collector-secret-that-must-not-appear-in-output" var gotAuthorization string @@ -6176,18 +6663,31 @@ func TestInstallSHSafeProfileDurablyReducesCollectorAuthority(t *testing.T) { defer server.Close() stateDir := t.TempDir() - mustWrite(t, filepath.Join(stateDir, "runtime.token"), token+"\n") + tokenPath := filepath.Join(stateDir, "runtime.token") + mustWrite(t, tokenPath, token+"\n") + if err := os.Chmod(tokenPath, 0o640); err != nil { + t.Fatal(err) + } + pulseAgent := buildPulseAgentLifecycleBinary(t) script := ` set -euo pipefail STATE_DIR="` + stateDir + `" +RUNTIME_TOKEN_FILE="" +COLLECTOR_LIFECYCLE_BINARY_PATH="` + pulseAgent + `" +LEAST_PRIVILEGE_USER="$(id -un)" PULSE_TOKEN="" AGENT_ID="agent-secure-runtime" -HOSTNAME_OVERRIDE="secure-runtime.lab" +HOSTNAME_OVERRIDE="" PULSE_URL="` + server.URL + `" INSECURE="false" CURL_CA_BUNDLE="" +SERVER_FINGERPRINT="" +hostname() { printf 'Secure-Runtime.Lab.\n'; } log_info() { printf '%s\n' "$*"; } +` + extractCollectorLifecycleShellFunctions(t, false) + ` +` + extractInstallShellFunction(t, "resolve_safe_profile_hostname") + ` ` + extractInstallShellFunction(t, "reduce_safe_profile_collector_authority") + ` +resolve_safe_profile_hostname reduce_safe_profile_collector_authority ` out, err := exec.Command("bash", "-c", script).CombinedOutput() @@ -6205,6 +6705,69 @@ reduce_safe_profile_collector_authority } } +func TestInstallSHCollectorLifecycleRejectsActiveMITMForgedSuccess(t *testing.T) { + const token = "collector-mitm-secret" + var authorizationSeen bool + mitm := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "" { + authorizationSeen = true + } + if r.URL.Path == "/api/agents/collector/reduce-authority" { + w.WriteHeader(http.StatusNoContent) + return + } + _, _ = w.Write([]byte(`{"success":true,"agent":{"id":"agent-secure-runtime","hostname":"secure-runtime.lab","lastSeen":"2026-08-30T12:00:02Z"}}`)) + })) + defer mitm.Close() + + stateDir := t.TempDir() + tokenPath := filepath.Join(stateDir, "runtime.token") + mustWrite(t, tokenPath, token) + if err := os.Chmod(tokenPath, 0o640); err != nil { + t.Fatal(err) + } + pulseAgent := buildPulseAgentLifecycleBinary(t) + script := ` +set -u +STATE_DIR="` + stateDir + `" +RUNTIME_TOKEN_FILE="" +COLLECTOR_LIFECYCLE_BINARY_PATH="` + pulseAgent + `" +LEAST_PRIVILEGE_USER="$(id -un)" +PULSE_TOKEN="" +AGENT_ID="agent-secure-runtime" +HOSTNAME_OVERRIDE="secure-runtime.lab" +PULSE_URL="` + mitm.URL + `" +INSECURE="true" +CURL_CA_BUNDLE="" +SERVER_FINGERPRINT="` + strings.Repeat("00", 32) + `" +log_info() { printf '%s\n' "$*"; } +` + extractCollectorLifecycleShellFunctions(t, true) + ` +` + extractInstallShellFunction(t, "reduce_safe_profile_collector_authority") + ` +if reduce_safe_profile_collector_authority; then + printf 'forged reduction accepted\n' + exit 3 +fi +if verify_agent_server_registration "2026-08-30T12:00:01Z"; then + printf 'forged registration accepted\n' + exit 4 +fi +printf 'forged success rejected\n' +` + out, err := exec.Command("bash", "-c", script).CombinedOutput() + if err != nil { + t.Fatalf("MITM rejection script: %v\n%s", err, out) + } + if !strings.Contains(string(out), "forged success rejected") { + t.Fatalf("missing forged-success rejection: %s", out) + } + if strings.Contains(string(out), token) { + t.Fatalf("collector bearer leaked in output: %s", out) + } + if authorizationSeen { + t.Fatal("MITM handler received the collector Authorization header") + } +} + func TestInstallSHRendersHardenedActionRunnerUnit(t *testing.T) { root := t.TempDir() unitPath := filepath.Join(root, "pulse-agent-runner.service") diff --git a/scripts/installtests/safe_profile_migration_test.go b/scripts/installtests/safe_profile_migration_test.go index 1fe6dcfb9..f81ef2602 100644 --- a/scripts/installtests/safe_profile_migration_test.go +++ b/scripts/installtests/safe_profile_migration_test.go @@ -17,6 +17,8 @@ func safeProfileInspectFunctions(t *testing.T) string { return extractInstallShellFunction(t, "safe_profile_platform_supported") + "\n" + extractInstallShellFunction(t, "safe_profile_detect_current_profile") + "\n" + extractInstallShellFunction(t, "safe_profile_unit_property") + "\n" + + extractInstallShellFunction(t, "systemd_effective_unit_property") + "\n" + + extractInstallShellFunction(t, "systemd_effective_unit_unoverridden") + "\n" + extractInstallShellFunction(t, "safe_profile_effective_unit_unoverridden") + "\n" + extractInstallShellFunction(t, "safe_profile_inspect") } @@ -24,6 +26,8 @@ func safeProfileInspectFunctions(t *testing.T) string { func safeProfileTransactionFunctions(t *testing.T) string { t.Helper() return extractInstallShellFunction(t, "safe_profile_detect_current_profile") + "\n" + + extractInstallShellFunction(t, "systemd_effective_unit_property") + "\n" + + extractInstallShellFunction(t, "systemd_effective_unit_unoverridden") + "\n" + extractInstallShellFunction(t, "safe_profile_effective_unit_unoverridden") + "\n" + extractInstallShellFunction(t, "safe_profile_snapshot_entry") + "\n" + extractInstallShellFunction(t, "safe_profile_manifest_value") + "\n" + @@ -36,6 +40,21 @@ func safeProfileTransactionFunctions(t *testing.T) string { extractInstallShellFunction(t, "safe_profile_commit_transaction") } +func safeProfileEffectiveSystemdFunctions(t *testing.T) string { + t.Helper() + return extractInstallShellFunction(t, "safe_profile_unit_property") + "\n" + + extractInstallShellFunction(t, "systemd_effective_unit_property") + "\n" + + extractInstallShellFunction(t, "systemd_effective_unit_unoverridden") + "\n" + + extractInstallShellFunction(t, "systemd_effective_exec_argv") + "\n" + + extractInstallShellFunction(t, "systemd_effective_exec_exact") + "\n" + + extractInstallShellFunction(t, "systemd_effective_words_equal") + "\n" + + extractInstallShellFunction(t, "systemd_effective_common_hardening") + "\n" + + extractInstallShellFunction(t, "safe_profile_verify_helper_effective_target") + "\n" + + extractInstallShellFunction(t, "action_runner_verify_effective_target") + "\n" + + extractInstallShellFunction(t, "safe_profile_effective_unit_unoverridden") + "\n" + + extractInstallShellFunction(t, "safe_profile_verify_effective_target") +} + func TestSafeProfileInspectIsReadOnlyAndReportsDifferences(t *testing.T) { root := t.TempDir() binDir := filepath.Join(root, "bin") @@ -271,6 +290,104 @@ func TestSafeProfileFailsClosedOnEffectiveSystemdOverrides(t *testing.T) { } } +func TestSafeProfileValidatesEveryEffectiveSystemdBoundary(t *testing.T) { + testCases := []struct { + name, unit, property, value string + }{ + {name: "collector drop-in", unit: "pulse-agent.service", property: "DropInPaths", value: "/etc/systemd/system/pulse-agent.service.d/override.conf"}, + {name: "collector executable", unit: "pulse-agent.service", property: "ExecStart", value: "{ path=/tmp/collector ; argv[]=/tmp/collector ; }"}, + {name: "collector hardening", unit: "pulse-agent.service", property: "NoNewPrivileges", value: "no"}, + {name: "helper service fragment", unit: "pulse-agent-helper.service", property: "FragmentPath", value: "/usr/lib/systemd/system/pulse-agent-helper.service"}, + {name: "helper service drop-in", unit: "pulse-agent-helper.service", property: "DropInPaths", value: "/etc/systemd/system/pulse-agent-helper.service.d/override.conf"}, + {name: "helper executable", unit: "pulse-agent-helper.service", property: "ExecStart", value: "{ path=/tmp/helper ; argv[]=/tmp/helper ; }"}, + {name: "helper private network", unit: "pulse-agent-helper.service", property: "PrivateNetwork", value: "no"}, + {name: "helper common hardening", unit: "pulse-agent-helper.service", property: "ProtectKernelModules", value: "no"}, + {name: "helper task limit", unit: "pulse-agent-helper.service", property: "TasksMax", value: "infinity"}, + {name: "helper descriptor limit", unit: "pulse-agent-helper.service", property: "LimitNOFILE", value: "1048576"}, + {name: "helper memory limit", unit: "pulse-agent-helper.service", property: "MemoryMax", value: "infinity"}, + {name: "helper address families", unit: "pulse-agent-helper.service", property: "RestrictAddressFamilies", value: "AF_UNIX AF_INET"}, + {name: "helper environment", unit: "pulse-agent-helper.service", property: "Environment", value: "PULSE_URL=https://attacker.invalid"}, + {name: "helper writable paths", unit: "pulse-agent-helper.service", property: "ReadWritePaths", value: "/"}, + {name: "helper socket fragment", unit: "pulse-agent-helper.socket", property: "FragmentPath", value: "/usr/lib/systemd/system/pulse-agent-helper.socket"}, + {name: "helper socket drop-in", unit: "pulse-agent-helper.socket", property: "DropInPaths", value: "/etc/systemd/system/pulse-agent-helper.socket.d/override.conf"}, + {name: "helper socket mode", unit: "pulse-agent-helper.socket", property: "SocketMode", value: "0666"}, + {name: "helper socket target", unit: "pulse-agent-helper.socket", property: "Listen", value: "/tmp/attacker.sock (Stream)"}, + {name: "runner fragment", unit: "pulse-agent-runner.service", property: "FragmentPath", value: "/usr/lib/systemd/system/pulse-agent-runner.service"}, + {name: "runner drop-in", unit: "pulse-agent-runner.service", property: "DropInPaths", value: "/etc/systemd/system/pulse-agent-runner.service.d/override.conf"}, + {name: "runner executable", unit: "pulse-agent-runner.service", property: "ExecStart", value: "{ path=/tmp/runner ; argv[]=/tmp/runner ; }"}, + {name: "runner environment file", unit: "pulse-agent-runner.service", property: "EnvironmentFiles", value: "/tmp/attacker.env (ignore_errors=no)"}, + {name: "runner address families", unit: "pulse-agent-runner.service", property: "RestrictAddressFamilies", value: "AF_UNIX AF_INET AF_INET6 AF_NETLINK"}, + {name: "runner common hardening", unit: "pulse-agent-runner.service", property: "LockPersonality", value: "no"}, + {name: "runner filesystem", unit: "pulse-agent-runner.service", property: "ProtectSystem", value: "strict"}, + } + + functions := safeProfileEffectiveSystemdFunctions(t) + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + script := effectiveSystemdHarness(t, testCase.unit, testCase.property, testCase.value) + "\n" + functions + ` +if safe_profile_verify_effective_target; then + echo 'unsafe effective systemd profile was accepted' >&2 + exit 1 +fi +` + if out, err := exec.Command("bash", "-c", script).CombinedOutput(); err != nil { + t.Fatalf("effective override rehearsal: %v\n%s", err, out) + } + }) + } + + t.Run("canonical profile", func(t *testing.T) { + script := effectiveSystemdHarness(t, "", "", "") + "\n" + functions + "\nsafe_profile_verify_effective_target\n" + if out, err := exec.Command("bash", "-c", script).CombinedOutput(); err != nil { + t.Fatalf("canonical effective profile rejected: %v\n%s", err, out) + } + }) +} + +func TestActionRunnerProvisionChecksEffectiveUnitBeforeStart(t *testing.T) { + provision := extractInstallShellFunction(t, "provision_action_runner") + validation := strings.Index(provision, "action_runner_verify_effective_target") + enable := strings.Index(provision, `systemctl enable "${ACTION_RUNNER_NAME}.service"`) + restart := strings.Index(provision, `systemctl restart "${ACTION_RUNNER_NAME}.service"`) + if validation < 0 || enable < 0 || restart < 0 || validation > enable || validation > restart { + t.Fatal("action-runner effective systemd validation does not precede service enable/restart") + } +} + +func TestTypedHelperProvisionChecksEffectiveUnitsBeforeSocketActivation(t *testing.T) { + provision := extractInstallShellFunction(t, "provision_typed_privileged_helper") + validation := strings.Index(provision, "safe_profile_verify_helper_effective_target") + enable := strings.Index(provision, `systemctl enable --now "${PRIVILEGED_HELPER_NAME}.socket"`) + if validation < 0 || enable < 0 || validation > enable { + t.Fatal("typed-helper effective systemd validation does not precede socket activation") + } +} + +func TestTypedHelperUnitRendersBoundedResources(t *testing.T) { + unitPath := filepath.Join(t.TempDir(), "pulse-agent-helper.service") + render := extractInstallShellFunction(t, "render_privileged_helper_service_unit") + script := ` +set -euo pipefail +PRIVILEGED_HELPER_NAME=pulse-agent-helper +PRIVILEGED_HELPER_UPDATE_QUARANTINE_DIR=/var/lib/pulse-agent/update-quarantine +PRIVILEGED_HELPER_STATE_DIR=/var/lib/pulse-agent-helper +` + render + ` +render_privileged_helper_service_unit "` + unitPath + `" /usr/local/lib/pulse-agent/pulse-agent-helper +` + if out, err := exec.Command("bash", "-c", script).CombinedOutput(); err != nil { + t.Fatalf("render typed-helper unit: %v\n%s", err, out) + } + content, err := os.ReadFile(unitPath) + if err != nil { + t.Fatal(err) + } + for _, directive := range []string{"TasksMax=64", "LimitNOFILE=256", "MemoryMax=256M"} { + if !strings.Contains(string(content), directive+"\n") { + t.Errorf("typed-helper unit omitted %s", directive) + } + } +} + func TestSafeProfileRegistrationMustAdvanceLastSeen(t *testing.T) { functions := extractInstallShellFunction(t, "verify_agent_server_registration") script := ` @@ -278,18 +395,29 @@ set -euo pipefail AGENT_ID=agent-1 HOSTNAME_OVERRIDE= PULSE_URL=https://pulse.example -INSECURE=false -CURL_CA_BUNDLE= AGENT_REGISTRATION_LAST_SEEN= -url_encode() { printf '%s' "$1"; } -curl_with_pulse_token() { printf '%s\n200\n' "$LOOKUP_BODY"; } +LOOKUP_LAST_SEEN= +LOOKUP_RC=0 +run_collector_lifecycle_command() { + test "$1" = collector-verify-registration + shift + test "$1" = --agent-id + test "$2" = agent-1 + test "$3" = --previous-last-seen + test "$4" = '2026-08-30T10:00:00Z' + if [[ "$LOOKUP_RC" -ne 0 ]]; then + return "$LOOKUP_RC" + fi + printf '%s\n' "$LOOKUP_LAST_SEEN" +} ` + functions + ` -LOOKUP_BODY='{"agent":{"id":"agent-1","lastSeen":"2026-08-30T10:00:00Z"}}' +LOOKUP_RC=1 if verify_agent_server_registration '2026-08-30T10:00:00Z'; then echo 'stale registration was accepted' >&2 exit 1 fi -LOOKUP_BODY='{"agent":{"id":"agent-1","lastSeen":"2026-08-30T10:00:31Z"}}' +LOOKUP_RC=0 +LOOKUP_LAST_SEEN='2026-08-30T10:00:31Z' verify_agent_server_registration '2026-08-30T10:00:00Z' test "$AGENT_REGISTRATION_LAST_SEEN" = '2026-08-30T10:00:31Z' ` @@ -558,6 +686,97 @@ stat() { ` + safeProfileTransactionFunctions(t) + "\n" } +func effectiveSystemdHarness(t *testing.T, overrideUnit, overrideProperty, overrideValue string) string { + t.Helper() + root := t.TempDir() + collectorUnit := filepath.Join(root, "pulse-agent.service") + helperServiceUnit := filepath.Join(root, "pulse-agent-helper.service") + helperSocketUnit := filepath.Join(root, "pulse-agent-helper.socket") + runnerUnit := filepath.Join(root, "pulse-agent-runner.service") + for _, path := range []string{collectorUnit, helperServiceUnit, helperSocketUnit, runnerUnit} { + mustWrite(t, path, "fixture unit\n") + } + return ` +set -euo pipefail +AGENT_NAME=pulse-agent +BINARY_NAME=pulse-agent +INSTALL_DIR=/usr/local/bin +LEAST_PRIVILEGE_USER=pulse-agent +SAFE_PROFILE_COLLECTOR_UNIT="` + collectorUnit + `" +PRIVILEGED_HELPER_NAME=pulse-agent-helper +PRIVILEGED_HELPER_SERVICE_UNIT="` + helperServiceUnit + `" +PRIVILEGED_HELPER_SOCKET_UNIT="` + helperSocketUnit + `" +PRIVILEGED_HELPER_BINARY_PATH=/usr/local/lib/pulse-agent/pulse-agent-helper +PRIVILEGED_HELPER_SOCKET_PATH=/run/pulse-agent/helper.sock +PRIVILEGED_HELPER_UPDATE_QUARANTINE_DIR=/var/lib/pulse-agent/update-quarantine +PRIVILEGED_HELPER_STATE_DIR=/var/lib/pulse-agent-helper +ACTION_RUNNER_NAME=pulse-agent-runner +ACTION_RUNNER_SERVICE_UNIT="` + runnerUnit + `" +ACTION_RUNNER_BINARY_PATH=/usr/local/lib/pulse-agent/pulse-agent-runner +ACTION_RUNNER_ENV_FILE=/etc/pulse-agent-runner/runner.env +ACTION_RUNNER_STATE_DIR=/var/lib/pulse-agent-runner +OVERRIDE_UNIT='` + overrideUnit + `' +OVERRIDE_PROPERTY='` + overrideProperty + `' +OVERRIDE_VALUE='` + overrideValue + `' +systemctl() { + [[ "${1:-}" == show ]] + local unit="${2:-}" + local property="${4:-}" + if [[ "$unit" == "$OVERRIDE_UNIT" && "$property" == "$OVERRIDE_PROPERTY" ]]; then + printf '%s\n' "$OVERRIDE_VALUE" + return 0 + fi + case "$unit:$property" in + pulse-agent.service:FragmentPath) printf '%s\n' "$SAFE_PROFILE_COLLECTOR_UNIT" ;; + pulse-agent.service:DropInPaths|pulse-agent.service:AmbientCapabilities) printf '\n' ;; + pulse-agent.service:User) printf 'pulse-agent\n' ;; + pulse-agent.service:ExecStart) printf '{ path=/usr/local/bin/pulse-agent ; argv[]=/usr/local/bin/pulse-agent --enable-host --command-authority monitoring-only ; }\n' ;; + pulse-agent.service:Environment) printf 'PULSE_AGENT_HELPER_SOCKET=/run/pulse-agent/helper.sock\n' ;; + pulse-agent.service:UMask) printf '0077\n' ;; + pulse-agent.service:NoNewPrivileges|pulse-agent.service:PrivateTmp|pulse-agent.service:ProtectKernelTunables|pulse-agent.service:ProtectKernelModules|pulse-agent.service:ProtectControlGroups|pulse-agent.service:LockPersonality|pulse-agent.service:RestrictSUIDSGID) printf 'yes\n' ;; + pulse-agent.service:PrivateDevices) printf 'no\n' ;; + pulse-agent.service:SystemCallArchitectures) printf 'native\n' ;; + pulse-agent-helper.service:FragmentPath) printf '%s\n' "$PRIVILEGED_HELPER_SERVICE_UNIT" ;; + pulse-agent-helper.service:DropInPaths|pulse-agent-helper.service:AmbientCapabilities|pulse-agent-helper.service:Environment|pulse-agent-helper.service:EnvironmentFiles) printf '\n' ;; + pulse-agent-helper.service:ExecStart) printf '{ path=/usr/local/lib/pulse-agent/pulse-agent-helper ; argv[]=/usr/local/lib/pulse-agent/pulse-agent-helper ; }\n' ;; + pulse-agent-helper.service:User|pulse-agent-helper.service:Group) printf 'root\n' ;; + pulse-agent-helper.service:UMask) printf '0077\n' ;; + pulse-agent-helper.service:NoNewPrivileges|pulse-agent-helper.service:PrivateTmp|pulse-agent-helper.service:ProtectKernelTunables|pulse-agent-helper.service:ProtectKernelModules|pulse-agent-helper.service:ProtectControlGroups|pulse-agent-helper.service:LockPersonality|pulse-agent-helper.service:RestrictSUIDSGID|pulse-agent-helper.service:PrivateNetwork|pulse-agent-helper.service:ProtectHome) printf 'yes\n' ;; + pulse-agent-helper.service:PrivateDevices) printf 'no\n' ;; + pulse-agent-helper.service:SystemCallArchitectures) printf 'native\n' ;; + pulse-agent-helper.service:ProtectSystem) printf 'strict\n' ;; + pulse-agent-helper.service:RestrictAddressFamilies) printf 'AF_UNIX\n' ;; + pulse-agent-helper.service:TasksMax) printf '64\n' ;; + pulse-agent-helper.service:LimitNOFILE) printf '256\n' ;; + pulse-agent-helper.service:MemoryMax) printf '268435456\n' ;; + pulse-agent-helper.service:ReadOnlyPaths) printf '/var/lib/pulse-agent/update-quarantine\n' ;; + pulse-agent-helper.service:ReadWritePaths) printf '/usr/local/bin /var/lib/pulse-agent-helper\n' ;; + pulse-agent-helper.socket:FragmentPath) printf '%s\n' "$PRIVILEGED_HELPER_SOCKET_UNIT" ;; + pulse-agent-helper.socket:DropInPaths) printf '\n' ;; + pulse-agent-helper.socket:SocketUser) printf 'root\n' ;; + pulse-agent-helper.socket:SocketGroup) printf 'pulse-agent\n' ;; + pulse-agent-helper.socket:SocketMode) printf '0660\n' ;; + pulse-agent-helper.socket:DirectoryMode) printf '0755\n' ;; + pulse-agent-helper.socket:RemoveOnStop) printf 'yes\n' ;; + pulse-agent-helper.socket:Listen) printf '/run/pulse-agent/helper.sock (Stream)\n' ;; + pulse-agent-runner.service:FragmentPath) printf '%s\n' "$ACTION_RUNNER_SERVICE_UNIT" ;; + pulse-agent-runner.service:DropInPaths|pulse-agent-runner.service:AmbientCapabilities) printf '\n' ;; + pulse-agent-runner.service:ExecStart) printf '{ path=/usr/local/lib/pulse-agent/pulse-agent-runner ; argv[]=/usr/local/lib/pulse-agent/pulse-agent-runner ; }\n' ;; + pulse-agent-runner.service:User|pulse-agent-runner.service:Group) printf 'root\n' ;; + pulse-agent-runner.service:UMask) printf '0077\n' ;; + pulse-agent-runner.service:NoNewPrivileges|pulse-agent-runner.service:PrivateTmp|pulse-agent-runner.service:ProtectKernelTunables|pulse-agent-runner.service:ProtectKernelModules|pulse-agent-runner.service:ProtectControlGroups|pulse-agent-runner.service:LockPersonality|pulse-agent-runner.service:RestrictSUIDSGID|pulse-agent-runner.service:ProtectHome) printf 'yes\n' ;; + pulse-agent-runner.service:PrivateDevices) printf 'no\n' ;; + pulse-agent-runner.service:SystemCallArchitectures) printf 'native\n' ;; + pulse-agent-runner.service:PrivateNetwork|pulse-agent-runner.service:ProtectSystem) printf 'no\n' ;; + pulse-agent-runner.service:RestrictAddressFamilies) printf 'AF_INET6 AF_UNIX AF_INET\n' ;; + pulse-agent-runner.service:ReadWritePaths) printf '/var/lib/pulse-agent-runner\n' ;; + pulse-agent-runner.service:EnvironmentFiles) printf '/etc/pulse-agent-runner/runner.env (ignore_errors=no)\n' ;; + *) printf '\n' ;; + esac +} +` +} + func mustMkdirAll(t *testing.T, dirs ...string) { t.Helper() for _, dir := range dirs { diff --git a/scripts/installtests/secure_runtime_systemd_lab_test.go b/scripts/installtests/secure_runtime_systemd_lab_test.go index bfaa93d08..fd13596b2 100644 --- a/scripts/installtests/secure_runtime_systemd_lab_test.go +++ b/scripts/installtests/secure_runtime_systemd_lab_test.go @@ -43,9 +43,9 @@ package installtests // PULSE_SECURE_RUNTIME_HELPER="$repo/.lab-artifacts/pulse-agent-helper" \ // PULSE_SECURE_RUNTIME_RUNNER="$repo/.lab-artifacts/pulse-agent-runner" \ // PULSE_SECURE_RUNTIME_RECEIPT=/tmp/secure-runtime-receipt.json \ -// PULSE_SECURE_RUNTIME_RECEIPT_RECORD_PATH=docs/release-control/v6/internal/records/secure-agent-runtime-systemd-receipt-v5.json \ +// PULSE_SECURE_RUNTIME_RECEIPT_RECORD_PATH=docs/release-control/v6/internal/records/secure-agent-runtime-systemd-receipt-v6.json \ // PULSE_SECURE_RUNTIME_TRANSCRIPT=/tmp/secure-runtime-transcript.jsonl \ -// PULSE_SECURE_RUNTIME_TRANSCRIPT_RECORD_PATH=docs/release-control/v6/internal/records/secure-agent-runtime-systemd-transcript-v5.jsonl \ +// PULSE_SECURE_RUNTIME_TRANSCRIPT_RECORD_PATH=docs/release-control/v6/internal/records/secure-agent-runtime-systemd-transcript-v6.jsonl \ // sh -c 'cd "$1/scripts/installtests" && exec "$1/.lab-artifacts/installtests-linux-arm64.test" -test.run "^TestSecureRuntimeSystemdLab$" -test.count=1 -test.v' sh "$repo" // // Use the VM's GOARCH in place of arm64 when qualifying another architecture. @@ -70,6 +70,7 @@ import ( "os/exec" "path/filepath" "runtime" + "sort" "strconv" "strings" "sync" @@ -129,6 +130,12 @@ var secureRuntimeScenarioClaims = map[string][]string{ "legacy_root_command_capable_install": {"legacy_root_command_authority_observed"}, "read_only_inspect": {"inspection_left_stable_files_unchanged"}, "drop_in_fail_closed_rehearsal": {"drop_in_rejected_before_mutation"}, + "helper_service_override_rejection": {"helper_service_effective_override_detected"}, + "helper_resource_limit_override_rejection": { + "helper_resource_limits_enforced", + "helper_resource_limit_override_detected", + }, + "helper_socket_override_rejection": {"helper_socket_effective_override_detected"}, "safe_profile_apply": { "collector_non_root", "collector_monitoring_only", @@ -155,7 +162,12 @@ var secureRuntimeScenarioClaims = map[string][]string{ "prior_active_binary_restored_from_rollback_slot", "collector_reporting_resumed_after_helper_recovery", }, - "separate_action_runner_install": {"action_runner_registered_separately"}, + "separate_action_runner_install": {"action_runner_registered_separately"}, + "action_runner_override_rejection": {"action_runner_effective_override_detected"}, + "helper_network_namespace_isolation": { + "helper_host_interface_tcp_denied", + "helper_network_namespace_isolated", + }, "typed_action_receipt": { "typed_mutation_verified", "terminal_receipt_replayed", @@ -225,7 +237,14 @@ func (f *secureRuntimeLabFixture) admitActionRunner(secret, agentID, hostname st func (f *secureRuntimeLabFixture) validateActionRunnerSession(admission agentexec.AgentAdmission) bool { f.mu.Lock() defer f.mu.Unlock() - return !f.actionRevoked && admission == f.actionAdmissionLocked() + expected := f.actionAdmissionLocked() + return !f.actionRevoked && + admission.OrganizationID == expected.OrganizationID && + admission.TokenID == expected.TokenID && + admission.AgentID == expected.AgentID && + admission.Hostname == expected.Hostname && + admission.RuntimeRole == expected.RuntimeRole && + admission.ActionCapability == expected.ActionCapability } func (f *secureRuntimeLabFixture) actionAdmissionLocked() agentexec.AgentAdmission { @@ -921,6 +940,21 @@ func TestSecureRuntimeSourceManifestCoversTransitiveProviders(t *testing.T) { "internal/securityutil/secure_storage_dir.go", "pkg/auth/scopes.go", "pkg/securityutil/httpurl.go", + "scripts/release_control/secure_runtime_attestation.py", + "scripts/release_control/secure_runtime_attestation_v6.py", + ".github/workflows/build-release-candidate.yml", + ".github/workflows/compile-release-payload.yml", + ".github/workflows/create-release.yml", + "scripts/build-release-binaries.sh", + "scripts/build-release.sh", + "scripts/release_asset_common.sh", + "scripts/release_build_targets.sh", + "scripts/release_candidate_manifest.py", + "scripts/release_ldflags.sh", + "scripts/release_update_key.go", + "scripts/require-safe-gh-attestation.sh", + "scripts/validate-release.sh", + "scripts/verify-github-release-integrity.sh", } { if _, ok := hashes[required]; !ok { t.Fatalf("secure-runtime source manifest omitted transitive boundary source %s", required) @@ -1016,7 +1050,7 @@ func TestSecureRuntimeSystemdLab(t *testing.T) { startedAt := time.Now().UTC() sourceManifest, sourceHashes := secureRuntimeLoadSourceBoundary(t, runtime.GOARCH) receipt := secureRuntimeLabReceipt{ - SchemaVersion: 5, + SchemaVersion: 6, RecordPath: recordPath, StartedAt: startedAt.Format(time.RFC3339Nano), SourceManifest: sourceManifest, @@ -1181,6 +1215,30 @@ func TestSecureRuntimeSystemdLab(t *testing.T) { secureRuntimeWaitForReports(t, fixture, len(reportsBeforeContinuity)+1, 20*time.Second) pass("final_safe_profile_apply", "collector continued reporting after committed migration", map[string]any{"collector_service_user": "pulse-agent", "continuity_report_observed": true}) + helperServiceRejection := secureRuntimeExerciseUnitOverrideDetection(t, + "pulse-agent-helper.service", "Service", "PrivateNetwork=false", false) + pass("helper_service_override_rejection", "effective helper service validation rejected an existing systemd drop-in", map[string]any{ + "override_directive": "PrivateNetwork=false", + "validation_error": helperServiceRejection, + }) + helperResourceRejection := secureRuntimeExerciseUnitOverrideDetection(t, + "pulse-agent-helper.service", "Service", "TasksMax=infinity", false) + pass("helper_resource_limit_override_rejection", "effective helper service validation enforced bounded task, descriptor, and memory resources and rejected an unbounded task override", map[string]any{ + "override_directive": "TasksMax=infinity", + "tasks_max": "64", + "limit_nofile": "256", + "memory_max_bytes": "268435456", + "validation_error": helperResourceRejection, + }) + helperSocketRejection := secureRuntimeExerciseUnitOverrideDetection(t, + "pulse-agent-helper.socket", "Socket", "SocketMode=0666", false) + pass("helper_socket_override_rejection", "effective helper socket validation rejected an existing systemd drop-in", map[string]any{ + "override_directive": "SocketMode=0666", + "validation_error": helperSocketRejection, + }) + helperNetworkObservations := secureRuntimeAssertHelperOutboundNetworkDenied(t) + pass("helper_network_namespace_isolation", "the helper network namespace could not establish TCP to a host-interface canary that was reachable from the host namespace", helperNetworkObservations) + collectorV2SHA256 := secureRuntimeHash(collectorV2) collectorV3SHA256 := secureRuntimeHash(collectorV3) collectorV4SHA256 := secureRuntimeHash(collectorV4) @@ -1292,6 +1350,12 @@ func TestSecureRuntimeSystemdLab(t *testing.T) { } secureRuntimeWaitForReports(t, fixture, len(reportsBeforeRunner)+1, 20*time.Second) pass("separate_action_runner_install", "root action runner registered and activated independently while the collector remained non-root and reporting", map[string]any{"runner_service_user": "root", "collector_service_user": "pulse-agent", "fixture_activation_requests": 1}) + runnerOverrideRejection := secureRuntimeExerciseUnitOverrideDetection(t, + "pulse-agent-runner.service", "Service", "EnvironmentFile=-/tmp/unsafe-runner.env", true) + pass("action_runner_override_rejection", "effective action-runner validation rejected an existing systemd drop-in", map[string]any{ + "override_directive": "EnvironmentFile=-/tmp/unsafe-runner.env", + "validation_error": runnerOverrideRejection, + }) actionContext, cancelAction := context.WithTimeout(agentexec.WithOrganizationID(context.Background(), secureRuntimeLabOrgID), 30*time.Second) defer cancelAction() @@ -1645,13 +1709,13 @@ func secureRuntimeLoadSourceBoundary(t *testing.T, targetArch string) (secureRun if err != nil { t.Fatalf("resolve repository root: %v", err) } - manifestRelative := "scripts/release_control/secure_runtime_source_manifest_v5.json" + manifestRelative := "scripts/release_control/secure_runtime_source_manifest_v6.json" manifestRaw := secureRuntimeReadFile(t, filepath.Join(repoRoot, filepath.FromSlash(manifestRelative))) var manifest secureRuntimeSourceManifest if err := json.Unmarshal(manifestRaw, &manifest); err != nil { t.Fatalf("decode secure-runtime source manifest: %v", err) } - if manifest.SchemaVersion != 1 || manifest.ManifestID != "secure-runtime-linux-v5" || manifest.TargetOS != "linux" { + if manifest.SchemaVersion != 1 || manifest.ManifestID != "secure-runtime-linux-v6" || manifest.TargetOS != "linux" { t.Fatalf("unsupported secure-runtime source manifest: %+v", manifest) } sourceHashes := make(map[string]string) @@ -1850,17 +1914,227 @@ func secureRuntimeCommand(t *testing.T, timeout time.Duration, name string, args func secureRuntimeSystemdProperty(t *testing.T, property string) string { t.Helper() - return secureRuntimeCommand(t, 10*time.Second, "systemctl", "show", "pulse-agent.service", "--property="+property, "--value") + return secureRuntimeUnitProperty(t, "pulse-agent.service", property) } func secureRuntimeActionRunnerSystemdProperty(t *testing.T, property string) string { t.Helper() - return secureRuntimeCommand(t, 10*time.Second, "systemctl", "show", "pulse-agent-runner.service", "--property="+property, "--value") + return secureRuntimeUnitProperty(t, "pulse-agent-runner.service", property) +} + +func secureRuntimeUnitProperty(t *testing.T, unit, property string) string { + t.Helper() + return secureRuntimeCommand(t, 10*time.Second, "systemctl", "show", unit, "--property="+property, "--value") +} + +func secureRuntimeReadUnitProperty(unit, property string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, "systemctl", "show", unit, "--property="+property, "--value").CombinedOutput() + if err != nil { + return "", fmt.Errorf("systemctl show %s %s: %w: %s", unit, property, err, strings.TrimSpace(string(out))) + } + return strings.TrimSpace(string(out)), nil +} + +func secureRuntimeCheckUnitProperty(unit, property, expected string) error { + actual, err := secureRuntimeReadUnitProperty(unit, property) + if err != nil { + return err + } + if actual != expected { + return fmt.Errorf("%s effective %s = %q, want %q", unit, property, actual, expected) + } + return nil +} + +func secureRuntimeCheckUnitWordSet(unit, property string, expected ...string) error { + actual, err := secureRuntimeReadUnitProperty(unit, property) + if err != nil { + return err + } + actualWords := strings.Fields(actual) + expectedWords := append([]string(nil), expected...) + sort.Strings(actualWords) + sort.Strings(expectedWords) + if strings.Join(actualWords, "\x00") != strings.Join(expectedWords, "\x00") { + return fmt.Errorf("%s effective %s words = %q, want %q", unit, property, actualWords, expectedWords) + } + return nil +} + +func secureRuntimeExecStartArgv(value string) []string { + const marker = "argv[]=" + start := strings.Index(value, marker) + if start < 0 { + return nil + } + value = value[start+len(marker):] + if end := strings.Index(value, " ;"); end >= 0 { + value = value[:end] + } + return strings.Fields(strings.TrimSpace(value)) +} + +func secureRuntimeCheckExactExecStart(unit, expectedBinary string) error { + actual, err := secureRuntimeReadUnitProperty(unit, "ExecStart") + if err != nil { + return err + } + argv := secureRuntimeExecStartArgv(actual) + if len(argv) != 1 || argv[0] != expectedBinary { + return fmt.Errorf("%s effective ExecStart argv = %q, want only %q", unit, argv, expectedBinary) + } + return nil +} + +func secureRuntimeCheckInstallerUnitBoundary(unit, expectedFragment string) error { + if err := secureRuntimeCheckUnitProperty(unit, "FragmentPath", expectedFragment); err != nil { + return err + } + return secureRuntimeCheckUnitProperty(unit, "DropInPaths", "") +} + +func secureRuntimeCheckSafeProfileSystemd(includeRunner bool) error { + for _, check := range []struct { + unit, property, expected string + }{ + {"pulse-agent.service", "FragmentPath", "/etc/systemd/system/pulse-agent.service"}, + {"pulse-agent.service", "DropInPaths", ""}, + {"pulse-agent.service", "User", "pulse-agent"}, + {"pulse-agent.service", "AmbientCapabilities", ""}, + {"pulse-agent.service", "UMask", "0077"}, + {"pulse-agent.service", "NoNewPrivileges", "yes"}, + {"pulse-agent.service", "PrivateTmp", "yes"}, + {"pulse-agent.service", "PrivateDevices", "no"}, + {"pulse-agent.service", "ProtectKernelTunables", "yes"}, + {"pulse-agent.service", "ProtectKernelModules", "yes"}, + {"pulse-agent.service", "ProtectControlGroups", "yes"}, + {"pulse-agent.service", "LockPersonality", "yes"}, + {"pulse-agent.service", "RestrictSUIDSGID", "yes"}, + {"pulse-agent.service", "SystemCallArchitectures", "native"}, + {"pulse-agent-helper.service", "FragmentPath", "/etc/systemd/system/pulse-agent-helper.service"}, + {"pulse-agent-helper.service", "DropInPaths", ""}, + {"pulse-agent-helper.service", "User", "root"}, + {"pulse-agent-helper.service", "Group", "root"}, + {"pulse-agent-helper.service", "AmbientCapabilities", ""}, + {"pulse-agent-helper.service", "UMask", "0077"}, + {"pulse-agent-helper.service", "NoNewPrivileges", "yes"}, + {"pulse-agent-helper.service", "PrivateTmp", "yes"}, + {"pulse-agent-helper.service", "PrivateDevices", "no"}, + {"pulse-agent-helper.service", "PrivateNetwork", "yes"}, + {"pulse-agent-helper.service", "ProtectSystem", "strict"}, + {"pulse-agent-helper.service", "ProtectHome", "yes"}, + {"pulse-agent-helper.service", "ProtectKernelTunables", "yes"}, + {"pulse-agent-helper.service", "ProtectKernelModules", "yes"}, + {"pulse-agent-helper.service", "ProtectControlGroups", "yes"}, + {"pulse-agent-helper.service", "LockPersonality", "yes"}, + {"pulse-agent-helper.service", "RestrictSUIDSGID", "yes"}, + {"pulse-agent-helper.service", "SystemCallArchitectures", "native"}, + {"pulse-agent-helper.service", "TasksMax", "64"}, + {"pulse-agent-helper.service", "LimitNOFILE", "256"}, + {"pulse-agent-helper.service", "MemoryMax", "268435456"}, + {"pulse-agent-helper.socket", "FragmentPath", "/etc/systemd/system/pulse-agent-helper.socket"}, + {"pulse-agent-helper.socket", "DropInPaths", ""}, + {"pulse-agent-helper.socket", "SocketUser", "root"}, + {"pulse-agent-helper.socket", "SocketGroup", "pulse-agent"}, + {"pulse-agent-helper.socket", "SocketMode", "0660"}, + {"pulse-agent-helper.socket", "DirectoryMode", "0755"}, + {"pulse-agent-helper.socket", "RemoveOnStop", "yes"}, + } { + if err := secureRuntimeCheckUnitProperty(check.unit, check.property, check.expected); err != nil { + return err + } + } + collectorExec, err := secureRuntimeReadUnitProperty("pulse-agent.service", "ExecStart") + if err != nil { + return err + } + collectorArgv := secureRuntimeExecStartArgv(collectorExec) + if len(collectorArgv) == 0 || collectorArgv[0] != "/usr/local/bin/pulse-agent" { + return fmt.Errorf("pulse-agent.service effective ExecStart argv = %q", collectorArgv) + } + for _, argument := range collectorArgv[1:] { + if argument == "--enable-commands" { + return errors.New("pulse-agent.service effective ExecStart retained --enable-commands") + } + } + collectorEnvironment, err := secureRuntimeReadUnitProperty("pulse-agent.service", "Environment") + if err != nil { + return err + } + if !strings.Contains(collectorEnvironment, "PULSE_AGENT_HELPER_SOCKET=/run/pulse-agent/helper.sock") { + return fmt.Errorf("pulse-agent.service effective Environment lacks the typed-helper socket: %q", collectorEnvironment) + } + if err := secureRuntimeCheckExactExecStart("pulse-agent-helper.service", "/usr/local/lib/pulse-agent/pulse-agent-helper"); err != nil { + return err + } + if err := secureRuntimeCheckUnitWordSet("pulse-agent-helper.service", "RestrictAddressFamilies", "AF_UNIX"); err != nil { + return err + } + listen, err := secureRuntimeReadUnitProperty("pulse-agent-helper.socket", "Listen") + if err != nil { + return err + } + if !strings.Contains(listen, "/run/pulse-agent/helper.sock") || !strings.Contains(listen, "Stream") { + return fmt.Errorf("pulse-agent-helper.socket effective Listen = %q", listen) + } + if !includeRunner { + return nil + } + for _, check := range []struct { + property, expected string + }{ + {"FragmentPath", "/etc/systemd/system/pulse-agent-runner.service"}, + {"DropInPaths", ""}, + {"User", "root"}, + {"Group", "root"}, + {"AmbientCapabilities", ""}, + {"UMask", "0077"}, + {"NoNewPrivileges", "yes"}, + {"PrivateTmp", "yes"}, + {"PrivateDevices", "no"}, + {"PrivateNetwork", "no"}, + {"ProtectHome", "yes"}, + {"ProtectSystem", "no"}, + {"ProtectKernelTunables", "yes"}, + {"ProtectKernelModules", "yes"}, + {"ProtectControlGroups", "yes"}, + {"LockPersonality", "yes"}, + {"RestrictSUIDSGID", "yes"}, + {"SystemCallArchitectures", "native"}, + } { + if err := secureRuntimeCheckUnitProperty("pulse-agent-runner.service", check.property, check.expected); err != nil { + return err + } + } + if err := secureRuntimeCheckExactExecStart("pulse-agent-runner.service", "/usr/local/lib/pulse-agent/pulse-agent-runner"); err != nil { + return err + } + if err := secureRuntimeCheckUnitWordSet("pulse-agent-runner.service", "RestrictAddressFamilies", "AF_UNIX", "AF_INET", "AF_INET6"); err != nil { + return err + } + environmentFiles, err := secureRuntimeReadUnitProperty("pulse-agent-runner.service", "EnvironmentFiles") + if err != nil { + return err + } + if environmentFiles != "/etc/pulse-agent-runner/runner.env (ignore_errors=no)" { + return fmt.Errorf("pulse-agent-runner.service effective EnvironmentFiles = %q", environmentFiles) + } + return nil +} + +func secureRuntimeAssertSafeProfileSystemd(t *testing.T, includeRunner bool) { + t.Helper() + if err := secureRuntimeCheckSafeProfileSystemd(includeRunner); err != nil { + t.Fatal(err) + } } func secureRuntimeAssertActionRunnerInstalled(t *testing.T) { t.Helper() secureRuntimeCommand(t, 10*time.Second, "systemctl", "is-active", "pulse-agent-runner.service") + secureRuntimeAssertSafeProfileSystemd(t, true) if user := secureRuntimeActionRunnerSystemdProperty(t, "User"); user != "root" { t.Fatalf("action-runner systemd User = %q, want root", user) } @@ -1961,6 +2235,7 @@ func secureRuntimeAssertRootMonitoringProfile(t *testing.T) { func secureRuntimeAssertSafeProfile(t *testing.T) { t.Helper() + secureRuntimeAssertSafeProfileSystemd(t, false) if user := secureRuntimeSystemdProperty(t, "User"); user != "pulse-agent" { t.Fatalf("safe collector systemd User = %q, want pulse-agent", user) } @@ -1997,6 +2272,135 @@ func secureRuntimeAssertSafeProfile(t *testing.T) { secureRuntimeCommand(t, 10*time.Second, "systemctl", "is-active", "pulse-agent-helper.socket") } +func secureRuntimeExerciseUnitOverrideDetection(t *testing.T, unit, section, directive string, includeRunner bool) string { + t.Helper() + dropInDir := filepath.Join("/etc/systemd/system", unit+".d") + dropInPath := filepath.Join(dropInDir, "secure-runtime-adversarial.conf") + if err := os.MkdirAll(dropInDir, 0o755); err != nil { + t.Fatalf("create %s drop-in directory: %v", unit, err) + } + if err := os.WriteFile(dropInPath, []byte("["+section+"]\n"+directive+"\n"), 0o644); err != nil { + t.Fatalf("write %s adversarial drop-in: %v", unit, err) + } + removed := false + t.Cleanup(func() { + if !removed { + _ = os.Remove(dropInPath) + _ = os.Remove(dropInDir) + _ = exec.Command("systemctl", "daemon-reload").Run() + } + }) + secureRuntimeCommand(t, 20*time.Second, "systemctl", "daemon-reload") + err := secureRuntimeCheckSafeProfileSystemd(includeRunner) + if err == nil { + t.Fatalf("effective systemd validation accepted %s drop-in %q", unit, directive) + } + dropIns, propertyErr := secureRuntimeReadUnitProperty(unit, "DropInPaths") + if propertyErr != nil { + t.Fatal(propertyErr) + } + if !strings.Contains(dropIns, dropInPath) { + t.Fatalf("%s effective DropInPaths = %q, want %s", unit, dropIns, dropInPath) + } + if err := os.Remove(dropInPath); err != nil { + t.Fatalf("remove %s adversarial drop-in: %v", unit, err) + } + _ = os.Remove(dropInDir) + removed = true + secureRuntimeCommand(t, 20*time.Second, "systemctl", "daemon-reload") + secureRuntimeAssertSafeProfileSystemd(t, includeRunner) + return err.Error() +} + +func secureRuntimeNonLoopbackIPv4(t *testing.T) string { + t.Helper() + interfaces, err := net.Interfaces() + if err != nil { + t.Fatalf("list host interfaces: %v", err) + } + for _, networkInterface := range interfaces { + if networkInterface.Flags&net.FlagUp == 0 || networkInterface.Flags&net.FlagLoopback != 0 { + continue + } + addresses, err := networkInterface.Addrs() + if err != nil { + continue + } + for _, address := range addresses { + var ip net.IP + switch typed := address.(type) { + case *net.IPNet: + ip = typed.IP + case *net.IPAddr: + ip = typed.IP + } + if ipv4 := ip.To4(); ipv4 != nil && !ipv4.IsLoopback() { + return ipv4.String() + } + } + } + t.Fatal("disposable systemd host has no non-loopback IPv4 address for the helper network canary") + return "" +} + +func secureRuntimeAssertHelperOutboundNetworkDenied(t *testing.T) map[string]any { + t.Helper() + secureRuntimeAssertHelperProtocol(t) + hostIP := secureRuntimeNonLoopbackIPv4(t) + listener, err := net.Listen("tcp4", "0.0.0.0:0") + if err != nil { + t.Fatalf("listen for helper network-isolation canary: %v", err) + } + server := &http.Server{Handler: http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.WriteHeader(http.StatusNoContent) + })} + serveDone := make(chan error, 1) + go func() { serveDone <- server.Serve(listener) }() + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = server.Shutdown(ctx) + <-serveDone + }) + port := listener.Addr().(*net.TCPAddr).Port + canaryURL := fmt.Sprintf("http://%s/secure-runtime-network-canary", net.JoinHostPort(hostIP, strconv.Itoa(port))) + hostClient := &http.Client{ + Timeout: 5 * time.Second, + Transport: &http.Transport{ + Proxy: nil, + }, + } + response, err := hostClient.Get(canaryURL) + if err != nil { + t.Fatalf("host network could not reach its canary %s: %v", canaryURL, err) + } + _ = response.Body.Close() + if response.StatusCode != http.StatusNoContent { + t.Fatalf("host network canary status = %s", response.Status) + } + mainPID := secureRuntimeUnitProperty(t, "pulse-agent-helper.service", "MainPID") + if mainPID == "" || mainPID == "0" { + t.Fatalf("typed helper has no live MainPID: %q", mainPID) + } + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, + "nsenter", "--target", mainPID, "--net", "--", + "curl", "--noproxy", "*", "-fsS", "--connect-timeout", "2", "--max-time", "3", canaryURL, + ).CombinedOutput() + secureRuntimeAssertNoCredentialExposure(t, out) + secureRuntimeRecordCommandOutput("helper namespace outbound TCP canary", out) + if err == nil { + t.Fatalf("helper network namespace reached host-interface canary %s", canaryURL) + } + return map[string]any{ + "canary_scope": "host-interface-tcp", + "host_canary_reachable": true, + "helper_namespace_connection": "denied", + "helper_main_pid": mainPID, + } +} + func secureRuntimeStableFileIdentity(t *testing.T, path string) secureRuntimeFileIdentity { t.Helper() info, err := os.Stat(path) diff --git a/scripts/release_control/canonical_completion_guard_test.py b/scripts/release_control/canonical_completion_guard_test.py index 31a3022a2..9336367df 100644 --- a/scripts/release_control/canonical_completion_guard_test.py +++ b/scripts/release_control/canonical_completion_guard_test.py @@ -768,6 +768,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase): "test_prefixes": [], "exact_files": [ "internal/agentupdate/coverage_test.go", + "internal/hostagent/action_runner_client_test.go", "internal/hostagent/agent_flushbuffer_test.go", "internal/hostagent/agent_metrics_test.go", "internal/hostagent/agent_new_test.go", @@ -2430,6 +2431,7 @@ None yet. "frontend-modern/src/components/Alerts/__tests__/ThresholdsTable.test.tsx", "frontend-modern/src/features/alerts/AlertDeliveryHealthCard.test.tsx", "frontend-modern/src/features/alerts/__tests__/AlertDeadManDestinationSection.test.tsx", + "frontend-modern/src/features/alerts/__tests__/AlertIncidentSynthesisSummary.test.tsx", "frontend-modern/src/features/alerts/__tests__/AlertIntentPolicyPanel.test.tsx", "frontend-modern/src/features/alerts/__tests__/OverviewTab.emptystate.test.tsx", "frontend-modern/src/features/alerts/__tests__/OverviewTab.timelineerror.test.tsx", @@ -2440,6 +2442,7 @@ None yet. "frontend-modern/src/features/alerts/__tests__/useAlertDestinationsState.test.tsx", "frontend-modern/src/features/alerts/__tests__/useAlertDestinationsTabState.test.tsx", "frontend-modern/src/features/alerts/__tests__/useAlertOverridesState.test.tsx", + "frontend-modern/src/features/alerts/__tests__/useAlertOverviewState.test.tsx", "frontend-modern/src/features/alerts/identity.test.ts", "frontend-modern/src/features/alerts/thresholds/__tests__/helpers.test.ts", "frontend-modern/src/features/alerts/thresholds/hooks/__tests__/truenasThresholdPersistence.test.tsx", diff --git a/scripts/release_control/secure_runtime_attestation_v6.py b/scripts/release_control/secure_runtime_attestation_v6.py new file mode 100644 index 000000000..0c6a1891b --- /dev/null +++ b/scripts/release_control/secure_runtime_attestation_v6.py @@ -0,0 +1,870 @@ +#!/usr/bin/env python3 +"""Verify schema-v6 secure-runtime systemd evidence and provenance.""" + +from __future__ import annotations + +import argparse +import base64 +import binascii +import contextlib +import hashlib +import json +import math +import os +import re +import stat +import subprocess +import sys +import tempfile +from pathlib import Path, PurePosixPath +from typing import Any, Iterator, Sequence + +import secure_runtime_attestation as v5 + + +RECEIPT_SCHEMA_VERSION = 6 +ATTESTATION_SCHEMA_VERSION = 6 +SOURCE_MANIFEST_PATH = "scripts/release_control/secure_runtime_source_manifest_v6.json" +SOURCE_MANIFEST_SCHEMA_VERSION = 1 +SOURCE_MANIFEST_ID = "secure-runtime-linux-v6" +ATTESTATION_TOOL_PATH = "scripts/release_control/secure_runtime_attestation_v6.py" +CANONICAL_REPOSITORY = "rcourtman/Pulse" +CANONICAL_ORIGIN_URL = "https://github.com/rcourtman/Pulse.git" +CANONICAL_MAIN_REF = "origin/main" +ASSEMBLY_SIGNER_WORKFLOW = "github.com/rcourtman/Pulse/.github/workflows/build-release-candidate.yml" +COMPILER_SIGNER_WORKFLOW = "github.com/rcourtman/Pulse/.github/workflows/compile-release-payload.yml" +RELEASE_TAG_RE = re.compile(r"^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[1-9][0-9]*$") +UPDATE_KEY_FINGERPRINT_RE = re.compile(r"^SHA256:[A-Za-z0-9+/]{43}=$") +GO_VERSION_RE = re.compile(r"^go1\.[0-9]+(?:\.[0-9]+)?$") +BUILD_CONTRACT_NAME = "secure-runtime-build-contract-v1.json" +CHECKSUMS_NAME = "checksums.txt" +ASSEMBLY_PROVENANCE_NAME = "release-build-provenance.sigstore.json" +COMPILER_PROVENANCE_NAME = "secure-runtime-compiler-provenance.sigstore.json" + +REQUIRED_SCENARIOS = ( + "legacy_root_command_capable_install", + "read_only_inspect", + "drop_in_fail_closed_rehearsal", + "safe_profile_apply", + "explicit_safe_profile_rollback", + "automatic_failure_rollback", + "ordinary_update_non_migration", + "final_safe_profile_apply", + "helper_service_override_rejection", + "helper_resource_limit_override_rejection", + "helper_socket_override_rejection", + "helper_network_namespace_isolation", + "helper_update_authoritative_commit", + "helper_update_watchdog_rollback", + "helper_update_interrupted_recovery", + "separate_action_runner_install", + "action_runner_override_rejection", + "typed_action_receipt", + "action_runner_credential_rotation", + "action_runner_self_revoke", +) +SCENARIO_REQUIRED_CLAIMS = { + **v5.SCENARIO_REQUIRED_CLAIMS, + "helper_service_override_rejection": {"helper_service_effective_override_detected"}, + "helper_resource_limit_override_rejection": { + "helper_resource_limits_enforced", + "helper_resource_limit_override_detected", + }, + "helper_socket_override_rejection": {"helper_socket_effective_override_detected"}, + "helper_network_namespace_isolation": { + "helper_host_interface_tcp_denied", + "helper_network_namespace_isolated", + }, + "action_runner_override_rejection": {"action_runner_effective_override_detected"}, +} +SCENARIO_REQUIRED_OBSERVATIONS = { + **v5.SCENARIO_REQUIRED_OBSERVATIONS, + "helper_service_override_rejection": {"override_directive": "PrivateNetwork=false"}, + "helper_resource_limit_override_rejection": { + "override_directive": "TasksMax=infinity", + "tasks_max": "64", + "limit_nofile": "256", + "memory_max_bytes": "268435456", + }, + "helper_socket_override_rejection": {"override_directive": "SocketMode=0666"}, + "helper_network_namespace_isolation": { + "canary_scope": "host-interface-tcp", + "host_canary_reachable": True, + "helper_namespace_connection": "denied", + }, + "action_runner_override_rejection": { + "override_directive": "EnvironmentFile=-/tmp/unsafe-runner.env" + }, +} + + +@contextlib.contextmanager +def v6_contract() -> Iterator[None]: + replacements = { + "RECEIPT_SCHEMA_VERSION": RECEIPT_SCHEMA_VERSION, + "SOURCE_MANIFEST_PATH": SOURCE_MANIFEST_PATH, + "SOURCE_MANIFEST_SCHEMA_VERSION": SOURCE_MANIFEST_SCHEMA_VERSION, + "SOURCE_MANIFEST_ID": SOURCE_MANIFEST_ID, + "REQUIRED_SCENARIOS": REQUIRED_SCENARIOS, + "SCENARIO_REQUIRED_CLAIMS": SCENARIO_REQUIRED_CLAIMS, + "SCENARIO_REQUIRED_OBSERVATIONS": SCENARIO_REQUIRED_OBSERVATIONS, + } + previous = {name: getattr(v5, name) for name in replacements} + try: + for name, value in replacements.items(): + setattr(v5, name, value) + yield + finally: + for name, value in previous.items(): + setattr(v5, name, value) + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + try: + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + except OSError as exc: + raise v5.AttestationError(f"unable to read {path}: {exc}") from exc + return digest.hexdigest() + + +def load_json_object(path: Path, label: str) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise v5.AttestationError(f"unable to read {label} {path}: {exc}") from exc + if not isinstance(value, dict): + raise v5.AttestationError(f"{label} must be a JSON object") + return value + + +def run_checked(command: Sequence[str], *, cwd: Path, label: str) -> subprocess.CompletedProcess[bytes]: + try: + return subprocess.run(command, cwd=cwd, check=True, capture_output=True) + except (OSError, subprocess.CalledProcessError) as exc: + detail = "" + if isinstance(exc, subprocess.CalledProcessError): + detail = exc.stderr.decode("utf-8", errors="replace").strip() + suffix = f": {detail}" if detail else "" + raise v5.AttestationError(f"{label} failed{suffix}") from exc + + +def parse_remote_refs(raw: bytes) -> dict[str, str]: + result: dict[str, str] = {} + for line in raw.decode("ascii", errors="strict").splitlines(): + fields = line.split("\t") + if len(fields) != 2 or not v5.COMMIT_RE.fullmatch(fields[0]): + raise v5.AttestationError("remote ref query returned malformed output") + if fields[1] in result: + raise v5.AttestationError("remote ref query returned duplicate refs") + result[fields[1]] = fields[0] + return result + + +def verify_canonical_main_identity(checkout: Path, main_ref: str) -> str: + if main_ref != CANONICAL_MAIN_REF: + raise v5.AttestationError("committed-main classification requires canonical origin/main") + origin = v5.run_git(checkout, "remote", "get-url", "origin").stdout.decode().strip() + if origin != CANONICAL_ORIGIN_URL: + raise v5.AttestationError("origin remote URL is not the canonical Pulse repository URL") + local_main = v5.resolve_commit(checkout, CANONICAL_MAIN_REF, "canonical origin/main") + remote = v5.run_git(checkout, "ls-remote", "origin", "refs/heads/main") + remote_refs = parse_remote_refs(remote.stdout) + if remote_refs != {"refs/heads/main": local_main}: + raise v5.AttestationError("canonical origin/main does not match the remote main commit") + return local_main + + +def verify_release_candidate_tag_identity( + checkout: Path, + qualified_commit: str, + tag: str, + repository: str, +) -> dict[str, str]: + if not RELEASE_TAG_RE.fullmatch(tag): + raise v5.AttestationError("release candidate identity must be an exact vX.Y.Z-rc.N tag") + if repository != CANONICAL_REPOSITORY: + raise v5.AttestationError("release candidate repository is not the canonical Pulse repository") + origin = v5.run_git(checkout, "remote", "get-url", "origin").stdout.decode().strip() + if origin != CANONICAL_ORIGIN_URL: + raise v5.AttestationError("origin remote URL is not the canonical Pulse repository URL") + ref = f"refs/tags/{tag}" + local_object = v5.run_git(checkout, "rev-parse", "--verify", ref).stdout.decode().strip() + if not v5.COMMIT_RE.fullmatch(local_object): + raise v5.AttestationError("release candidate tag object is invalid") + object_type = v5.run_git(checkout, "cat-file", "-t", ref).stdout.decode().strip() + if object_type != "tag": + raise v5.AttestationError("release candidate tag must be an annotated tag, not a lightweight ref") + tag_body = v5.run_git(checkout, "cat-file", "tag", ref).stdout.decode("utf-8", errors="strict") + if ( + f"\ntag {tag}\n" not in tag_body + or not tag_body.endswith(f"\n\nRelease {tag}\n") + or "BEGIN PGP SIGNATURE" in tag_body + or "BEGIN SSH SIGNATURE" in tag_body + ): + raise v5.AttestationError( + "release candidate tag object is not the canonical unsigned workflow tag; tag signatures are not authority" + ) + peeled_commit = v5.resolve_commit(checkout, ref, "release candidate tag") + if peeled_commit != qualified_commit: + raise v5.AttestationError("release candidate tag does not resolve to the qualified commit") + remote = v5.run_git( + checkout, + "ls-remote", + "--tags", + "origin", + ref, + f"{ref}^{{}}", + ) + remote_refs = parse_remote_refs(remote.stdout) + if remote_refs != {ref: local_object, f"{ref}^{{}}": qualified_commit}: + raise v5.AttestationError("remote release candidate tag object or peeled commit does not match locally") + return { + "tag": tag, + "tag_object": local_object, + "peeled_commit": qualified_commit, + "origin_url": origin, + "tag_authority": "immutable-signed-github-release-packet", + } + + +def parse_checksums(path: Path) -> dict[str, str]: + result: dict[str, str] = {} + try: + lines = path.read_text(encoding="utf-8").splitlines() + except OSError as exc: + raise v5.AttestationError(f"unable to read release checksums: {exc}") from exc + for line_number, line in enumerate(lines, 1): + match = re.fullmatch(r"([0-9a-f]{64}) ([A-Za-z0-9][A-Za-z0-9._-]*)", line) + if not match: + raise v5.AttestationError(f"release checksums line {line_number} is not canonical") + digest, name = match.groups() + if name in result: + raise v5.AttestationError(f"release checksums contain duplicate asset {name}") + result[name] = digest + if not result: + raise v5.AttestationError("release checksums are empty") + return result + + +def require_canonical_sidecar(path: Path, expected_name: str) -> Path: + if path.name != expected_name: + raise v5.AttestationError(f"release sidecar must be a regular {expected_name} file") + try: + path_stat = path.lstat() + except OSError as exc: + raise v5.AttestationError(f"unable to inspect release sidecar {path}: {exc}") from exc + if stat.S_ISLNK(path_stat.st_mode) or not stat.S_ISREG(path_stat.st_mode): + raise v5.AttestationError(f"release sidecar must be a regular {expected_name} file") + return Path(os.path.abspath(path)) + + +def copy_immutable_input(source: Path, destination: Path, label: str) -> str: + source = Path(os.path.abspath(source)) + try: + source_lstat = source.lstat() + except OSError as exc: + raise v5.AttestationError(f"{label} changed before it could be copied") from exc + if stat.S_ISLNK(source_lstat.st_mode) or not stat.S_ISREG(source_lstat.st_mode): + raise v5.AttestationError(f"{label} must be a regular file") + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + try: + source_fd = os.open(source, flags) + except OSError as exc: + raise v5.AttestationError(f"unable to open {label} {source}: {exc}") from exc + digest = hashlib.sha256() + try: + opened_stat = os.fstat(source_fd) + if ( + not stat.S_ISREG(opened_stat.st_mode) + or (opened_stat.st_dev, opened_stat.st_ino) != (source_lstat.st_dev, source_lstat.st_ino) + ): + raise v5.AttestationError(f"{label} changed before it could be copied") + try: + with os.fdopen(source_fd, "rb", closefd=False) as source_handle, destination.open("xb") as target: + for chunk in iter(lambda: source_handle.read(1024 * 1024), b""): + digest.update(chunk) + target.write(chunk) + target.flush() + os.fsync(target.fileno()) + except OSError as exc: + raise v5.AttestationError(f"unable to snapshot {label}: {exc}") from exc + completed_stat = os.fstat(source_fd) + try: + path_after = source.lstat() + except OSError as exc: + raise v5.AttestationError(f"{label} changed while it was copied") from exc + stable_identity = (opened_stat.st_dev, opened_stat.st_ino, opened_stat.st_size, opened_stat.st_mtime_ns) + if ( + stable_identity + != (completed_stat.st_dev, completed_stat.st_ino, completed_stat.st_size, completed_stat.st_mtime_ns) + or (path_after.st_dev, path_after.st_ino) != (opened_stat.st_dev, opened_stat.st_ino) + or stat.S_ISLNK(path_after.st_mode) + ): + raise v5.AttestationError(f"{label} changed while it was copied") + finally: + os.close(source_fd) + destination.chmod(0o400) + copied_digest = sha256_file(destination) + if copied_digest != digest.hexdigest(): + raise v5.AttestationError(f"{label} snapshot digest is inconsistent") + return copied_digest + + +def copy_release_sidecar(source: Path, destination: Path, expected_name: str) -> str: + source = require_canonical_sidecar(source, expected_name) + return copy_immutable_input(source, destination, f"release sidecar {expected_name}") + + +@contextlib.contextmanager +def immutable_release_sidecar_snapshot( + checksums_path: Path, + assembly_provenance_path: Path, + compiler_provenance_path: Path, + build_contract_path: Path, +) -> Iterator[tuple[dict[str, Path], dict[str, str]]]: + sources = { + CHECKSUMS_NAME: checksums_path, + ASSEMBLY_PROVENANCE_NAME: assembly_provenance_path, + COMPILER_PROVENANCE_NAME: compiler_provenance_path, + BUILD_CONTRACT_NAME: build_contract_path, + } + with tempfile.TemporaryDirectory(prefix="pulse-secure-runtime-release-") as temporary: + snapshot_root = Path(temporary) + snapshot_root.chmod(0o700) + snapshots: dict[str, Path] = {} + digests: dict[str, str] = {} + for name, source in sources.items(): + snapshot = snapshot_root / name + digests[name] = copy_release_sidecar(source, snapshot, name) + snapshots[name] = snapshot + yield snapshots, digests + + +def verify_release_sidecar_snapshot_unchanged( + snapshots: dict[str, Path], expected_digests: dict[str, str] +) -> None: + for name, path in snapshots.items(): + if sha256_file(path) != expected_digests[name]: + raise v5.AttestationError(f"private release sidecar snapshot {name} changed during verification") + + +@contextlib.contextmanager +def immutable_artifact_snapshot( + artifacts: dict[str, Path], +) -> Iterator[tuple[dict[str, Path], dict[str, str]]]: + if set(artifacts) != set(v5.ARTIFACT_ARGUMENTS): + raise v5.AttestationError("qualification artifact set is incomplete") + with tempfile.TemporaryDirectory(prefix="pulse-secure-runtime-artifacts-") as temporary: + snapshot_root = Path(temporary) + snapshot_root.chmod(0o700) + snapshots: dict[str, Path] = {} + digests: dict[str, str] = {} + for name in v5.ARTIFACT_ARGUMENTS: + snapshot = snapshot_root / name + digests[name] = copy_immutable_input( + artifacts[name], snapshot, f"qualification artifact {name}" + ) + snapshots[name] = snapshot + yield snapshots, digests + for name, path in snapshots.items(): + if sha256_file(path) != digests[name]: + raise v5.AttestationError( + f"private qualification artifact snapshot {name} changed during verification" + ) + + +def verify_release_build_contract( + *, + path: Path, + tag: str, + qualified_commit: str, + repository: str, + expected_update_key_fingerprint: str, + receipt: dict[str, Any], + artifact_hashes: dict[str, str], + checksums: dict[str, str], +) -> dict[str, Any]: + contract = load_json_object(path, "secure-runtime build contract") + if contract.get("schema_version") != 1: + raise v5.AttestationError("secure-runtime build contract schema_version must be 1") + expected_version = tag[1:] + expected_identity = { + "repository": repository, + "assembly_signer_workflow": ASSEMBLY_SIGNER_WORKFLOW, + "compiler_signer_workflow": COMPILER_SIGNER_WORKFLOW, + "compiler_runner_trust": "github-hosted-deny-self-hosted", + "tag": tag, + "version": expected_version, + "source_sha": qualified_commit, + "update_key_fingerprint": expected_update_key_fingerprint, + } + for key, expected in expected_identity.items(): + if contract.get(key) != expected: + raise v5.AttestationError(f"secure-runtime build contract {key} does not match the release") + if not UPDATE_KEY_FINGERPRINT_RE.fullmatch(expected_update_key_fingerprint): + raise v5.AttestationError("expected release update-key fingerprint is invalid") + update_public_keys = contract.get("update_public_keys") + if not isinstance(update_public_keys, str) or "," in update_public_keys: + raise v5.AttestationError("secure-runtime build contract must bind one release update public key") + try: + update_public_key = base64.b64decode(update_public_keys, validate=True) + except (binascii.Error, ValueError) as exc: + raise v5.AttestationError("secure-runtime build contract update public key is invalid") from exc + if len(update_public_key) != 32: + raise v5.AttestationError("secure-runtime build contract update public key is not raw Ed25519") + derived_fingerprint = "SHA256:" + base64.b64encode(hashlib.sha256(update_public_key).digest()).decode("ascii") + if derived_fingerprint != expected_update_key_fingerprint: + raise v5.AttestationError("secure-runtime build contract update public key fingerprint does not match") + if checksums.get(BUILD_CONTRACT_NAME) != sha256_file(path): + raise v5.AttestationError("secure-runtime build contract is not digest-bound by release checksums") + artifacts = contract.get("artifacts") + if not isinstance(artifacts, dict) or set(artifacts) != set(v5.ARTIFACT_ARGUMENTS): + raise v5.AttestationError("secure-runtime build contract artifact set is incomplete") + receipt_versions = receipt.get("artifact_versions") + if not isinstance(receipt_versions, dict): + raise v5.AttestationError("receipt artifact_versions are unavailable") + verified: dict[str, Any] = {} + seen_release_assets: set[str] = set() + for name, expected_package in v5.EXPECTED_ARTIFACT_PACKAGES.items(): + entry = artifacts[name] + if not isinstance(entry, dict): + raise v5.AttestationError(f"secure-runtime build contract artifact {name} is invalid") + release_asset = entry.get("release_asset") + if ( + not isinstance(release_asset, str) + or PurePosixPath(release_asset).name != release_asset + or release_asset in seen_release_assets + ): + raise v5.AttestationError(f"secure-runtime build contract release asset for {name} is invalid") + seen_release_assets.add(release_asset) + if entry.get("sha256") != artifact_hashes[name] or checksums.get(release_asset) != artifact_hashes[name]: + raise v5.AttestationError(f"artifact {name} is not bound to the signed release checksums") + build = entry.get("build") + if not isinstance(build, dict): + raise v5.AttestationError(f"artifact {name} has no exact build contract") + required_build = { + "tool": "go build", + "package": expected_package, + "target_os": "linux", + "target_arch": receipt.get("architecture"), + "cgo_enabled": 0, + "trimpath": True, + "buildvcs": False, + "build_args": ["-buildvcs=false", "-trimpath"], + "update_key_fingerprint": expected_update_key_fingerprint, + } + for key, expected in required_build.items(): + if build.get(key) != expected: + raise v5.AttestationError(f"artifact {name} build field {key} does not match the hosted compiler contract") + if not GO_VERSION_RE.fullmatch(str(build.get("go_version", ""))): + raise v5.AttestationError(f"artifact {name} Go toolchain is invalid") + artifact_version = build.get("version") + if not isinstance(artifact_version, str) or not artifact_version: + raise v5.AttestationError(f"artifact {name} version is invalid") + normalized_artifact_version = artifact_version.removeprefix("v") + if name.startswith("collector_v"): + receipt_version = receipt_versions.get(name) + if ( + not isinstance(receipt_version, str) + or normalized_artifact_version != receipt_version.removeprefix("v") + ): + raise v5.AttestationError(f"artifact {name} version does not match the receipt") + elif normalized_artifact_version != expected_version: + raise v5.AttestationError(f"artifact {name} version does not match the release") + expected_ldflags = "" + if name != "runner": + embedded_version = artifact_version if artifact_version.startswith("v") else f"v{artifact_version}" + expected_ldflags = ( + f"-s -w -X main.Version={embedded_version} " + "-X github.com/rcourtman/pulse-go-rewrite/internal/updatesignature." + f"EmbeddedTrustedPublicKeys={update_public_keys}" + ) + if build.get("ldflags") != expected_ldflags: + raise v5.AttestationError(f"artifact {name} ldflags do not match the canonical release invocation") + ldflags_sha256 = hashlib.sha256(expected_ldflags.encode()).hexdigest() + if build.get("ldflags_sha256") != ldflags_sha256: + raise v5.AttestationError(f"artifact {name} ldflags digest is invalid") + verified[name] = { + "release_asset": release_asset, + "sha256": artifact_hashes[name], + "package": expected_package, + "go_version": build["go_version"], + "buildvcs": False, + "trimpath": True, + "ldflags_sha256": ldflags_sha256, + "version": artifact_version, + "update_key_fingerprint": expected_update_key_fingerprint, + } + return verified + + +def verify_release_candidate_packet( + *, + checkout: Path, + qualified_commit: str, + tag: str, + repository: str, + release_id: str, + checksums_path: Path, + assembly_provenance_path: Path, + compiler_provenance_path: Path, + build_contract_path: Path, + expected_update_key_fingerprint: str, + receipt: dict[str, Any], + artifacts: dict[str, Path], + artifact_hashes: dict[str, str], +) -> dict[str, Any]: + tag_identity = verify_release_candidate_tag_identity(checkout, qualified_commit, tag, repository) + if not re.fullmatch(r"[1-9][0-9]*", release_id): + raise v5.AttestationError("release id must be a positive GitHub release id") + integrity_script = checkout / "scripts/verify-github-release-integrity.sh" + run_checked( + [str(integrity_script), tag, repository, release_id, qualified_commit], + cwd=checkout, + label="immutable GitHub release integrity verification", + ) + with immutable_release_sidecar_snapshot( + checksums_path, + assembly_provenance_path, + compiler_provenance_path, + build_contract_path, + ) as (snapshots, snapshot_digests): + checksums_snapshot = snapshots[CHECKSUMS_NAME] + assembly_provenance_snapshot = snapshots[ASSEMBLY_PROVENANCE_NAME] + compiler_provenance_snapshot = snapshots[COMPILER_PROVENANCE_NAME] + build_contract_snapshot = snapshots[BUILD_CONTRACT_NAME] + for sidecar in snapshots.values(): + run_checked( + [ + "gh", + "release", + "verify-asset", + tag, + str(sidecar), + "--repo", + repository, + "--format", + "json", + ], + cwd=checkout, + label=f"release attestation verification for {sidecar.name}", + ) + run_checked( + [ + "gh", + "attestation", + "verify", + str(checksums_snapshot), + "--repo", + repository, + "--signer-workflow", + ASSEMBLY_SIGNER_WORKFLOW, + "--source-digest", + qualified_commit, + "--deny-self-hosted-runners", + "--predicate-type", + "https://slsa.dev/provenance/v1", + "--bundle", + str(assembly_provenance_snapshot), + ], + cwd=checkout, + label="hosted candidate-assembly provenance verification", + ) + for artifact_name, artifact_path in artifacts.items(): + run_checked( + [ + "gh", + "attestation", + "verify", + str(artifact_path), + "--repo", + repository, + "--signer-workflow", + COMPILER_SIGNER_WORKFLOW, + "--source-digest", + qualified_commit, + "--deny-self-hosted-runners", + "--predicate-type", + "https://slsa.dev/provenance/v1", + "--bundle", + str(compiler_provenance_snapshot), + ], + cwd=checkout, + label=f"hosted compiler provenance verification for {artifact_name}", + ) + verify_release_sidecar_snapshot_unchanged(snapshots, snapshot_digests) + checksums = parse_checksums(checksums_snapshot) + build_identity = verify_release_build_contract( + path=build_contract_snapshot, + tag=tag, + qualified_commit=qualified_commit, + repository=repository, + expected_update_key_fingerprint=expected_update_key_fingerprint, + receipt=receipt, + artifact_hashes=artifact_hashes, + checksums=checksums, + ) + verify_release_sidecar_snapshot_unchanged(snapshots, snapshot_digests) + return { + **tag_identity, + "release_id": release_id, + "checksums_sha256": snapshot_digests[CHECKSUMS_NAME], + "assembly_provenance_sha256": snapshot_digests[ASSEMBLY_PROVENANCE_NAME], + "compiler_provenance_sha256": snapshot_digests[COMPILER_PROVENANCE_NAME], + "build_contract_sha256": snapshot_digests[BUILD_CONTRACT_NAME], + "assembly_signer_workflow": ASSEMBLY_SIGNER_WORKFLOW, + "compiler_signer_workflow": COMPILER_SIGNER_WORKFLOW, + "compiler_runner_trust": "github-hosted-deny-self-hosted", + "build_identity": build_identity, + "update_key_fingerprint": expected_update_key_fingerprint, + } + + +def _create_attestation_with_snapshotted_artifacts( + *, + checkout: Path, + commit: str, + main_ref: str, + receipt_path: Path, + receipt_record_path: str, + transcript_path: Path, + artifacts: dict[str, Path], + elapsed_seconds: float, + release_candidate_tag: str | None = None, + release_repository: str | None = None, + release_id: str | None = None, + release_checksums_path: Path | None = None, + release_assembly_provenance_path: Path | None = None, + release_compiler_provenance_path: Path | None = None, + release_build_contract_path: Path | None = None, + expected_release_update_key_fingerprint: str | None = None, +) -> dict[str, Any]: + checkout = checkout.resolve() + qualified_commit = v5.resolve_commit(checkout, commit, "qualified commit") + if commit != qualified_commit: + raise v5.AttestationError("qualified commit must be the full canonical commit SHA") + v5.require_detached_clean_checkout(checkout, qualified_commit) + main_commit = verify_canonical_main_identity(checkout, main_ref) + v5.require_ancestor(checkout, qualified_commit, CANONICAL_MAIN_REF) + with v6_contract(): + receipt, receipt_bytes = v5.load_receipt(receipt_path) + record_path = v5.canonical_repo_path(receipt_record_path, "receipt record path") + if receipt.get("record_path") != record_path: + raise v5.AttestationError("receipt record path is not bound inside the supplied receipt") + transcript_events, transcript_bytes = v5.load_and_verify_transcript(transcript_path, receipt) + v5.verify_scenarios(receipt, transcript_events) + v5.verify_runtime_claims(receipt) + source_hashes, source_manifest = v5.verify_source_hashes(checkout, qualified_commit, receipt) + attestation_tool_hash = sha256_file(Path(__file__)) + if source_hashes.get(ATTESTATION_TOOL_PATH) != attestation_tool_hash: + raise v5.AttestationError("executing schema-v6 attestation tool does not match the qualified commit") + artifact_hashes = v5.verify_artifacts(receipt, artifacts) + if not isinstance(elapsed_seconds, (float, int)) or not math.isfinite(elapsed_seconds) or elapsed_seconds <= 0: + raise v5.AttestationError("elapsed seconds must be positive") + + release_arguments = ( + release_repository, + release_id, + release_checksums_path, + release_assembly_provenance_path, + release_compiler_provenance_path, + release_build_contract_path, + expected_release_update_key_fingerprint, + ) + release_packet: dict[str, Any] | None = None + if release_candidate_tag is None: + if any(value is not None for value in release_arguments): + raise v5.AttestationError("release packet inputs require --release-candidate-tag") + artifact_build_identity = v5.verify_artifact_build_identity(receipt, artifacts, qualified_commit) + classification = "committed-main-artifact-bound-self-attested-systemd" + else: + if any(value is None for value in release_arguments): + raise v5.AttestationError( + "release-candidate classification requires repository, release id, signed checksums, hosted assembly and compiler provenance, build contract, and update-key fingerprint" + ) + release_packet = verify_release_candidate_packet( + checkout=checkout, + qualified_commit=qualified_commit, + tag=release_candidate_tag, + repository=str(release_repository), + release_id=str(release_id), + checksums_path=Path(release_checksums_path), + assembly_provenance_path=Path(release_assembly_provenance_path), + compiler_provenance_path=Path(release_compiler_provenance_path), + build_contract_path=Path(release_build_contract_path), + expected_update_key_fingerprint=str(expected_release_update_key_fingerprint), + receipt=receipt, + artifacts=artifacts, + artifact_hashes=artifact_hashes, + ) + artifact_build_identity = release_packet["build_identity"] + classification = "release-candidate-hosted-compiler-chain-artifact-bound-self-attested-systemd" + + return { + "schema_version": ATTESTATION_SCHEMA_VERSION, + "attestation_tool": ATTESTATION_TOOL_PATH, + "attestation_tool_sha256": attestation_tool_hash, + "proof_classification": classification, + "qualified_commit": qualified_commit, + "qualified_ref_at_run": release_candidate_tag or qualified_commit, + "main_ref_verified": main_ref, + "main_ref_commit_at_attestation": main_commit, + "qualified_commit_reachable_from_main": True, + "build_checkout": "detached-worktree", + "build_checkout_clean_except_lab_artifacts": True, + "disposable_vm_guard_receipt_claim_validated": True, + "execution_receipt_authentication": "none-secret-free-self-attestation", + "receipt": { + "record_path": record_path, + "sha256": v5.sha256_bytes(receipt_bytes), + "path_bound_inside_receipt": True, + }, + "transcript": { + "record_path": receipt["transcript"]["record_path"], + "sha256": v5.sha256_bytes(transcript_bytes), + "event_count": len(transcript_events), + "scenario_event_count": sum(event.get("kind") == "scenario_result" for event in transcript_events), + "command_output_event_count": sum(event.get("kind") == "command_output" for event in transcript_events), + "format": "jsonl-v1", + }, + "source_manifest": source_manifest, + "source_hashes_match_commit": True, + "source_hashes": source_hashes, + "artifact_hashes_match_receipt": True, + "artifact_hashes": artifact_hashes, + "artifact_build_identity": artifact_build_identity, + "release_packet": release_packet, + "host": { + "os": v5._os_name(receipt.get("os_release")), + "kernel": receipt.get("kernel"), + "systemd": receipt.get("systemd_version"), + "architecture": receipt.get("architecture"), + }, + "scenario_count": len(REQUIRED_SCENARIOS), + "all_scenarios_passed": True, + "test_elapsed_seconds": float(elapsed_seconds), + "default_changed": False, + "residual_proof": ( + ["representative-provider-and-appliance", "external-security-review"] + if release_packet + else [ + "exact-release-candidate-hosted-compiler-chain-artifacts", + "representative-provider-and-appliance", + "external-security-review", + ] + ), + } + + +def create_attestation( + *, + checkout: Path, + commit: str, + main_ref: str, + receipt_path: Path, + receipt_record_path: str, + transcript_path: Path, + artifacts: dict[str, Path], + elapsed_seconds: float, + release_candidate_tag: str | None = None, + release_repository: str | None = None, + release_id: str | None = None, + release_checksums_path: Path | None = None, + release_assembly_provenance_path: Path | None = None, + release_compiler_provenance_path: Path | None = None, + release_build_contract_path: Path | None = None, + expected_release_update_key_fingerprint: str | None = None, +) -> dict[str, Any]: + # Hash, inspect, and externally verify the same private artifact bytes. + # Caller-owned paths can otherwise be swapped between receipt hashing, + # Go build-identity inspection, and compiler provenance verification. + with immutable_artifact_snapshot(artifacts) as (snapshots, _): + return _create_attestation_with_snapshotted_artifacts( + checkout=checkout, + commit=commit, + main_ref=main_ref, + receipt_path=receipt_path, + receipt_record_path=receipt_record_path, + transcript_path=transcript_path, + artifacts=snapshots, + elapsed_seconds=elapsed_seconds, + release_candidate_tag=release_candidate_tag, + release_repository=release_repository, + release_id=release_id, + release_checksums_path=release_checksums_path, + release_assembly_provenance_path=release_assembly_provenance_path, + release_compiler_provenance_path=release_compiler_provenance_path, + release_build_contract_path=release_build_contract_path, + expected_release_update_key_fingerprint=expected_release_update_key_fingerprint, + ) + + +def parse_args(argv: Sequence[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--checkout", type=Path, required=True) + parser.add_argument("--commit", required=True) + parser.add_argument("--main-ref", default=CANONICAL_MAIN_REF) + parser.add_argument("--receipt", type=Path, required=True) + parser.add_argument("--receipt-record-path", required=True) + parser.add_argument("--transcript", type=Path, required=True) + parser.add_argument("--collector-v1", type=Path, required=True) + parser.add_argument("--collector-v2", type=Path, required=True) + parser.add_argument("--collector-v3", type=Path, required=True) + parser.add_argument("--collector-v4", type=Path, required=True) + parser.add_argument("--helper", type=Path, required=True) + parser.add_argument("--runner", type=Path, required=True) + parser.add_argument("--elapsed-seconds", type=float, required=True) + parser.add_argument("--release-candidate-tag") + parser.add_argument("--release-repository") + parser.add_argument("--release-id") + parser.add_argument("--release-checksums", type=Path) + parser.add_argument("--release-assembly-provenance", type=Path) + parser.add_argument("--release-compiler-provenance", type=Path) + parser.add_argument("--release-build-contract", type=Path) + parser.add_argument("--expected-release-update-key-fingerprint") + parser.add_argument("--output", type=Path, required=True) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + args = parse_args(sys.argv[1:] if argv is None else argv) + artifacts = { + "collector_v1": args.collector_v1, + "collector_v2": args.collector_v2, + "collector_v3": args.collector_v3, + "collector_v4": args.collector_v4, + "helper": args.helper, + "runner": args.runner, + } + try: + attestation = create_attestation( + checkout=args.checkout, + commit=args.commit, + main_ref=args.main_ref, + receipt_path=args.receipt, + receipt_record_path=args.receipt_record_path, + transcript_path=args.transcript, + artifacts=artifacts, + elapsed_seconds=args.elapsed_seconds, + release_candidate_tag=args.release_candidate_tag, + release_repository=args.release_repository, + release_id=args.release_id, + release_checksums_path=args.release_checksums, + release_assembly_provenance_path=args.release_assembly_provenance, + release_compiler_provenance_path=args.release_compiler_provenance, + release_build_contract_path=args.release_build_contract, + expected_release_update_key_fingerprint=args.expected_release_update_key_fingerprint, + ) + args.output.write_text(json.dumps(attestation, indent=2) + "\n", encoding="utf-8") + except v5.AttestationError as exc: + print(f"secure runtime schema-v6 attestation failed: {exc}", file=sys.stderr) + return 1 + print(f"secure runtime schema-v6 attestation passed: {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_control/secure_runtime_attestation_v6_test.py b/scripts/release_control/secure_runtime_attestation_v6_test.py new file mode 100644 index 000000000..928b1b960 --- /dev/null +++ b/scripts/release_control/secure_runtime_attestation_v6_test.py @@ -0,0 +1,512 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import base64 +import contextlib +import hashlib +import io +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import secure_runtime_attestation as v5 +from secure_runtime_attestation_v6 import ( + ATTESTATION_SCHEMA_VERSION, + ASSEMBLY_PROVENANCE_NAME, + ASSEMBLY_SIGNER_WORKFLOW, + BUILD_CONTRACT_NAME, + CANONICAL_MAIN_REF, + CANONICAL_ORIGIN_URL, + CANONICAL_REPOSITORY, + CHECKSUMS_NAME, + COMPILER_PROVENANCE_NAME, + COMPILER_SIGNER_WORKFLOW, + RECEIPT_SCHEMA_VERSION, + REQUIRED_SCENARIOS, + SCENARIO_REQUIRED_CLAIMS, + SCENARIO_REQUIRED_OBSERVATIONS, + SOURCE_MANIFEST_ID, + SOURCE_MANIFEST_PATH, + copy_release_sidecar, + create_attestation, + immutable_artifact_snapshot, + parse_args, + verify_release_build_contract, + verify_canonical_main_identity, + verify_release_candidate_packet, + verify_release_candidate_tag_identity, +) + + +class SecureRuntimeAttestationV6Test(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.tag = "v6.5.0-rc.1" + self.commit = "b" * 40 + self.tag_object = "a" * 40 + self.update_public_keys = base64.b64encode(bytes(range(32))).decode("ascii") + self.fingerprint = "SHA256:" + base64.b64encode( + hashlib.sha256(bytes(range(32))).digest() + ).decode("ascii") + self.receipt = { + "architecture": "arm64", + "artifact_versions": { + "collector_v1": "v6.5.0-lab.1", + "collector_v2": "v6.5.0-lab.2", + "collector_v3": "v6.5.0-lab.3", + "collector_v4": "v6.5.0-lab.4", + }, + } + self.artifact_hashes = { + name: hashlib.sha256(name.encode()).hexdigest() + for name in v5.ARTIFACT_ARGUMENTS + } + self.artifacts = {} + for name in v5.ARTIFACT_ARGUMENTS: + path = self.root / f"artifact-{name}" + path.write_bytes(name.encode()) + self.artifacts[name] = path + + def tearDown(self) -> None: + self.temporary.cleanup() + + def build_contract(self) -> dict: + artifacts = {} + for name, package in v5.EXPECTED_ARTIFACT_PACKAGES.items(): + version = self.receipt["artifact_versions"].get(name, self.tag[1:]) + ldflags = "" + if name != "runner": + embedded_version = version if version.startswith("v") else f"v{version}" + ldflags = ( + f"-s -w -X main.Version={embedded_version} " + "-X github.com/rcourtman/pulse-go-rewrite/internal/updatesignature." + f"EmbeddedTrustedPublicKeys={self.update_public_keys}" + ) + artifacts[name] = { + "release_asset": f"pulse-secure-runtime-{name}-linux-arm64", + "sha256": self.artifact_hashes[name], + "build": { + "tool": "go build", + "package": package, + "target_os": "linux", + "target_arch": "arm64", + "cgo_enabled": 0, + "trimpath": True, + "buildvcs": False, + "build_args": ["-buildvcs=false", "-trimpath"], + "go_version": "go1.25.1", + "ldflags": ldflags, + "ldflags_sha256": hashlib.sha256(ldflags.encode()).hexdigest(), + "version": version, + "update_key_fingerprint": self.fingerprint, + }, + } + return { + "schema_version": 1, + "repository": CANONICAL_REPOSITORY, + "assembly_signer_workflow": ASSEMBLY_SIGNER_WORKFLOW, + "compiler_signer_workflow": COMPILER_SIGNER_WORKFLOW, + "compiler_runner_trust": "github-hosted-deny-self-hosted", + "tag": self.tag, + "version": self.tag[1:], + "source_sha": self.commit, + "update_key_fingerprint": self.fingerprint, + "update_public_keys": self.update_public_keys, + "artifacts": artifacts, + } + + def write_contract_and_checksums(self, contract: dict | None = None) -> tuple[Path, Path, dict[str, str]]: + contract_path = self.root / BUILD_CONTRACT_NAME + contract_path.write_text(json.dumps(contract or self.build_contract(), sort_keys=True), encoding="utf-8") + checksums = { + entry["release_asset"]: entry["sha256"] + for entry in (contract or self.build_contract())["artifacts"].values() + } + checksums[BUILD_CONTRACT_NAME] = hashlib.sha256(contract_path.read_bytes()).hexdigest() + checksums_path = self.root / CHECKSUMS_NAME + checksums_path.write_text( + "".join(f"{digest} {name}\n" for name, digest in sorted(checksums.items())), + encoding="utf-8", + ) + return contract_path, checksums_path, checksums + + def git_result(self, *arguments: str) -> subprocess.CompletedProcess[bytes]: + command = tuple(arguments) + ref = f"refs/tags/{self.tag}" + if command == ("remote", "get-url", "origin"): + output = CANONICAL_ORIGIN_URL + "\n" + elif command == ("rev-parse", "--verify", ref): + output = self.tag_object + "\n" + elif command == ("cat-file", "-t", ref): + output = "tag\n" + elif command == ("cat-file", "tag", ref): + output = ( + f"object {self.commit}\ntype commit\ntag {self.tag}\n" + "tagger github-actions[bot] 1 +0000\n" + f"\nRelease {self.tag}\n" + ) + elif command == ("rev-parse", "--verify", f"{ref}^{{commit}}"): + output = self.commit + "\n" + elif command == ("ls-remote", "--tags", "origin", ref, f"{ref}^{{}}"): + output = f"{self.tag_object}\t{ref}\n{self.commit}\t{ref}^{{}}\n" + else: + raise AssertionError(f"unexpected git call {command}") + return subprocess.CompletedProcess(command, 0, output.encode(), b"") + + def test_v6_is_a_new_contract_without_redefining_v5(self) -> None: + self.assertEqual(RECEIPT_SCHEMA_VERSION, 6) + self.assertEqual(ATTESTATION_SCHEMA_VERSION, 6) + self.assertEqual(SOURCE_MANIFEST_ID, "secure-runtime-linux-v6") + self.assertTrue(SOURCE_MANIFEST_PATH.endswith("_v6.json")) + self.assertEqual(v5.RECEIPT_SCHEMA_VERSION, 5) + self.assertEqual(len(v5.REQUIRED_SCENARIOS), 15) + self.assertEqual(len(REQUIRED_SCENARIOS), 20) + + def test_v6_requires_override_and_helper_namespace_claims(self) -> None: + self.assertEqual( + SCENARIO_REQUIRED_CLAIMS["helper_network_namespace_isolation"], + {"helper_host_interface_tcp_denied", "helper_network_namespace_isolated"}, + ) + self.assertEqual( + SCENARIO_REQUIRED_OBSERVATIONS["helper_network_namespace_isolation"], + { + "canary_scope": "host-interface-tcp", + "host_canary_reachable": True, + "helper_namespace_connection": "denied", + }, + ) + self.assertEqual( + SCENARIO_REQUIRED_OBSERVATIONS["helper_resource_limit_override_rejection"], + { + "override_directive": "TasksMax=infinity", + "tasks_max": "64", + "limit_nofile": "256", + "memory_max_bytes": "268435456", + }, + ) + + def test_v6_manifest_binds_the_trusted_candidate_build_and_release_pipeline(self) -> None: + manifest = json.loads((Path(__file__).resolve().parents[2] / SOURCE_MANIFEST_PATH).read_text()) + exact_paths = set(manifest["exact_paths"]) + recursive_roots = set(manifest["recursive_roots"]) + required = { + ".github/workflows/build-release-candidate.yml", + ".github/workflows/compile-release-payload.yml", + ".github/workflows/create-release.yml", + "scripts/build-release-binaries.sh", + "scripts/build-release.sh", + "scripts/release_asset_common.sh", + "scripts/release_build_targets.sh", + "scripts/release_candidate_manifest.py", + "scripts/release_ldflags.sh", + "scripts/release_update_key.go", + "scripts/require-safe-gh-attestation.sh", + "scripts/validate-release.sh", + "scripts/verify-github-release-integrity.sh", + } + self.assertEqual(required - exact_paths, set()) + self.assertEqual( + {"internal/collectorlifecycle", "pkg/tlsutil"} - recursive_roots, + set(), + ) + + def test_release_sidecar_snapshot_rejects_symlink_before_resolution(self) -> None: + source = self.root / "source-checksums.txt" + source.write_text("contents\n", encoding="utf-8") + symlink = self.root / CHECKSUMS_NAME + symlink.symlink_to(source) + with self.assertRaisesRegex(v5.AttestationError, "regular checksums.txt"): + copy_release_sidecar(symlink, self.root / "copy" / CHECKSUMS_NAME, CHECKSUMS_NAME) + + def test_release_sidecar_snapshot_rejects_source_path_swap(self) -> None: + source = self.root / CHECKSUMS_NAME + source.write_text("original\n", encoding="utf-8") + destination_root = self.root / "private" + destination_root.mkdir(mode=0o700) + real_fstat = os.fstat + calls = 0 + + def swap_after_open(file_descriptor): + nonlocal calls + result = real_fstat(file_descriptor) + calls += 1 + if calls == 1: + source.rename(self.root / "original-checksums.txt") + source.write_text("replacement\n", encoding="utf-8") + return result + + with ( + mock.patch("secure_runtime_attestation_v6.os.fstat", side_effect=swap_after_open), + self.assertRaisesRegex(v5.AttestationError, "changed while it was copied"), + ): + copy_release_sidecar(source, destination_root / CHECKSUMS_NAME, CHECKSUMS_NAME) + + def test_create_attestation_uses_private_immutable_artifact_copies(self) -> None: + observed: dict[str, bytes] = {} + + def inspect_snapshots(**kwargs): + for name, path in kwargs["artifacts"].items(): + self.assertNotEqual(path, self.artifacts[name]) + observed[name] = path.read_bytes() + self.artifacts[name].write_bytes(b"swapped-after-snapshot") + self.assertEqual(path.read_bytes(), name.encode()) + return {"proof_classification": "test"} + + with mock.patch( + "secure_runtime_attestation_v6._create_attestation_with_snapshotted_artifacts", + side_effect=inspect_snapshots, + ): + result = create_attestation( + checkout=self.root, + commit=self.commit, + main_ref=CANONICAL_MAIN_REF, + receipt_path=self.root / "receipt.json", + receipt_record_path="record.json", + transcript_path=self.root / "transcript.jsonl", + artifacts=self.artifacts, + elapsed_seconds=1, + ) + self.assertEqual(result["proof_classification"], "test") + self.assertEqual(observed, {name: name.encode() for name in v5.ARTIFACT_ARGUMENTS}) + + def test_private_artifact_snapshot_mutation_is_rejected(self) -> None: + with self.assertRaisesRegex(v5.AttestationError, "snapshot collector_v1 changed"): + with immutable_artifact_snapshot(self.artifacts) as (snapshots, _): + snapshots["collector_v1"].chmod(0o600) + snapshots["collector_v1"].write_bytes(b"mutated") + + def test_rejects_head_branch_and_non_rc_release_identities(self) -> None: + for value in ("HEAD", "main", "refs/heads/main", "refs/tags/v6.5.0-rc.1", "v6.5.0"): + with self.subTest(value=value), self.assertRaisesRegex(v5.AttestationError, "exact vX.Y.Z-rc.N"): + verify_release_candidate_tag_identity(self.root, self.commit, value, CANONICAL_REPOSITORY) + + def test_committed_main_identity_requires_canonical_remote_main(self) -> None: + def canonical(_checkout, *args, **_kwargs): + command = tuple(args) + if command == ("remote", "get-url", "origin"): + output = CANONICAL_ORIGIN_URL + "\n" + elif command == ("rev-parse", "--verify", f"{CANONICAL_MAIN_REF}^{{commit}}"): + output = self.commit + "\n" + elif command == ("ls-remote", "origin", "refs/heads/main"): + output = f"{self.commit}\trefs/heads/main\n" + else: + raise AssertionError(f"unexpected git call {command}") + return subprocess.CompletedProcess(args, 0, output.encode(), b"") + + with mock.patch.object(v5, "run_git", side_effect=canonical): + self.assertEqual( + verify_canonical_main_identity(self.root, CANONICAL_MAIN_REF), + self.commit, + ) + for caller_ref in ("HEAD", "main", "refs/heads/main", "scratch"): + with self.subTest(caller_ref=caller_ref), self.assertRaisesRegex( + v5.AttestationError, "canonical origin/main" + ): + verify_canonical_main_identity(self.root, caller_ref) + + def moved_remote(_checkout, *args, **kwargs): + result = canonical(_checkout, *args, **kwargs) + if tuple(args) == ("ls-remote", "origin", "refs/heads/main"): + return subprocess.CompletedProcess(args, 0, f"{'c' * 40}\trefs/heads/main\n".encode(), b"") + return result + + with ( + mock.patch.object(v5, "run_git", side_effect=moved_remote), + self.assertRaisesRegex(v5.AttestationError, "does not match the remote main commit"), + ): + verify_canonical_main_identity(self.root, CANONICAL_MAIN_REF) + + def test_accepts_canonical_annotated_tag_only_as_release_packet_identity(self) -> None: + with mock.patch.object(v5, "run_git", side_effect=lambda _checkout, *args, **_kwargs: self.git_result(*args)): + identity = verify_release_candidate_tag_identity( + self.root, self.commit, self.tag, CANONICAL_REPOSITORY + ) + self.assertEqual(identity["tag_object"], self.tag_object) + self.assertEqual(identity["peeled_commit"], self.commit) + self.assertEqual(identity["tag_authority"], "immutable-signed-github-release-packet") + + def test_rejects_wrong_origin_lightweight_moved_and_signed_tag_objects(self) -> None: + base = lambda _checkout, *args, **_kwargs: self.git_result(*args) + cases = { + "wrong origin": (("remote", "get-url", "origin"), b"https://github.com/attacker/Pulse.git\n", "origin remote"), + "lightweight": (("cat-file", "-t", f"refs/tags/{self.tag}"), b"commit\n", "annotated tag"), + "moved": ( + ("ls-remote", "--tags", "origin", f"refs/tags/{self.tag}", f"refs/tags/{self.tag}^{{}}"), + f"{'c' * 40}\trefs/tags/{self.tag}\n{self.commit}\trefs/tags/{self.tag}^{{}}\n".encode(), + "does not match locally", + ), + "wrong-key signed tag": ( + ("cat-file", "tag", f"refs/tags/{self.tag}"), + ( + f"object {self.commit}\ntype commit\ntag {self.tag}\ntagger attacker 1 +0000\n\n" + f"Release {self.tag}\n-----BEGIN PGP SIGNATURE-----\nwrong-key\n" + ).encode(), + "tag signatures are not authority", + ), + } + for name, (target_call, replacement, message) in cases.items(): + def dispatch(_checkout, *args, **kwargs): + if tuple(args) == target_call: + return subprocess.CompletedProcess(args, 0, replacement, b"") + return base(_checkout, *args, **kwargs) + + with self.subTest(name=name), mock.patch.object(v5, "run_git", side_effect=dispatch): + with self.assertRaisesRegex(v5.AttestationError, message): + verify_release_candidate_tag_identity( + self.root, self.commit, self.tag, CANONICAL_REPOSITORY + ) + + def test_accepts_buildvcs_stripped_artifacts_only_with_exact_signed_contract(self) -> None: + contract_path, _, checksums = self.write_contract_and_checksums() + verified = verify_release_build_contract( + path=contract_path, + tag=self.tag, + qualified_commit=self.commit, + repository=CANONICAL_REPOSITORY, + expected_update_key_fingerprint=self.fingerprint, + receipt=self.receipt, + artifact_hashes=self.artifact_hashes, + checksums=checksums, + ) + self.assertEqual(set(verified), set(v5.ARTIFACT_ARGUMENTS)) + self.assertTrue(all(item["buildvcs"] is False for item in verified.values())) + self.assertNotIn("main.Version=vv", self.build_contract()["artifacts"]["collector_v1"]["build"]["ldflags"]) + + def test_rejects_local_vcs_stamped_or_wrong_key_build_contract(self) -> None: + for name, mutate, message in ( + ( + "vcs stamped", + lambda contract: contract["artifacts"]["collector_v4"]["build"].__setitem__("buildvcs", True), + "build field buildvcs", + ), + ( + "wrong update key", + lambda contract: contract.__setitem__("update_key_fingerprint", "SHA256:" + "B" * 43 + "="), + "update_key_fingerprint", + ), + ( + "missing ldflags", + lambda contract: contract["artifacts"]["helper"]["build"].__setitem__("ldflags_sha256", "0" * 64), + "ldflags digest", + ), + ): + contract = self.build_contract() + mutate(contract) + contract_path, _, checksums = self.write_contract_and_checksums(contract) + with self.subTest(name=name), self.assertRaisesRegex(v5.AttestationError, message): + verify_release_build_contract( + path=contract_path, + tag=self.tag, + qualified_commit=self.commit, + repository=CANONICAL_REPOSITORY, + expected_update_key_fingerprint=self.fingerprint, + receipt=self.receipt, + artifact_hashes=self.artifact_hashes, + checksums=checksums, + ) + + def test_release_packet_requires_hosted_assembly_and_compiler_provenance(self) -> None: + contract_path, checksums_path, _ = self.write_contract_and_checksums() + assembly_provenance_path = self.root / ASSEMBLY_PROVENANCE_NAME + assembly_provenance_path.write_text("{}\n", encoding="utf-8") + compiler_provenance_path = self.root / COMPILER_PROVENANCE_NAME + compiler_provenance_path.write_text("{}\n", encoding="utf-8") + calls: list[list[str]] = [] + + def record(command, **_kwargs): + calls.append(list(command)) + return subprocess.CompletedProcess(command, 0, b"{}\n", b"") + + with ( + mock.patch( + "secure_runtime_attestation_v6.verify_release_candidate_tag_identity", + return_value={ + "tag": self.tag, + "tag_object": self.tag_object, + "peeled_commit": self.commit, + "origin_url": CANONICAL_ORIGIN_URL, + "tag_authority": "immutable-signed-github-release-packet", + }, + ), + mock.patch("secure_runtime_attestation_v6.subprocess.run", side_effect=record), + ): + packet = verify_release_candidate_packet( + checkout=self.root, + qualified_commit=self.commit, + tag=self.tag, + repository=CANONICAL_REPOSITORY, + release_id="12345", + checksums_path=checksums_path, + assembly_provenance_path=assembly_provenance_path, + compiler_provenance_path=compiler_provenance_path, + build_contract_path=contract_path, + expected_update_key_fingerprint=self.fingerprint, + receipt=self.receipt, + artifacts=self.artifacts, + artifact_hashes=self.artifact_hashes, + ) + self.assertEqual(packet["assembly_signer_workflow"], ASSEMBLY_SIGNER_WORKFLOW) + self.assertEqual(packet["compiler_signer_workflow"], COMPILER_SIGNER_WORKFLOW) + self.assertEqual(packet["compiler_runner_trust"], "github-hosted-deny-self-hosted") + self.assertTrue(any(Path(call[0]).name == "verify-github-release-integrity.sh" for call in calls)) + self.assertEqual(sum(call[:3] == ["gh", "release", "verify-asset"] for call in calls), 4) + provenance_calls = [call for call in calls if call[:3] == ["gh", "attestation", "verify"]] + self.assertEqual(len(provenance_calls), 1 + len(v5.ARTIFACT_ARGUMENTS)) + self.assertTrue(all("--deny-self-hosted-runners" in call for call in provenance_calls)) + self.assertIn(ASSEMBLY_SIGNER_WORKFLOW, provenance_calls[0]) + self.assertTrue(all(COMPILER_SIGNER_WORKFLOW in call for call in provenance_calls[1:])) + + def test_release_packet_rejects_private_snapshot_swap_during_verification(self) -> None: + contract_path, checksums_path, _ = self.write_contract_and_checksums() + assembly_provenance_path = self.root / ASSEMBLY_PROVENANCE_NAME + assembly_provenance_path.write_text("{}\n", encoding="utf-8") + compiler_provenance_path = self.root / COMPILER_PROVENANCE_NAME + compiler_provenance_path.write_text("{}\n", encoding="utf-8") + + def mutate_snapshot(command, **_kwargs): + if command[:3] == ["gh", "release", "verify-asset"] and Path(command[4]).name == CHECKSUMS_NAME: + snapshot = Path(command[4]) + snapshot.chmod(0o600) + snapshot.write_text("swapped\n", encoding="utf-8") + return subprocess.CompletedProcess(command, 0, b"{}\n", b"") + + with ( + mock.patch( + "secure_runtime_attestation_v6.verify_release_candidate_tag_identity", + return_value={"tag": self.tag}, + ), + mock.patch("secure_runtime_attestation_v6.subprocess.run", side_effect=mutate_snapshot), + self.assertRaisesRegex(v5.AttestationError, "changed during verification"), + ): + verify_release_candidate_packet( + checkout=self.root, + qualified_commit=self.commit, + tag=self.tag, + repository=CANONICAL_REPOSITORY, + release_id="12345", + checksums_path=checksums_path, + assembly_provenance_path=assembly_provenance_path, + compiler_provenance_path=compiler_provenance_path, + build_contract_path=contract_path, + expected_update_key_fingerprint=self.fingerprint, + receipt=self.receipt, + artifacts=self.artifacts, + artifact_hashes=self.artifact_hashes, + ) + + def test_cli_does_not_accept_v5_release_candidate_ref_shortcut(self) -> None: + with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + parse_args(["--release-candidate-ref", self.tag]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release_control/secure_runtime_source_manifest_v6.json b/scripts/release_control/secure_runtime_source_manifest_v6.json new file mode 100644 index 000000000..c8aebd4fe --- /dev/null +++ b/scripts/release_control/secure_runtime_source_manifest_v6.json @@ -0,0 +1,78 @@ +{ + "schema_version": 1, + "manifest_id": "secure-runtime-linux-v6", + "target_os": "linux", + "description": "Production source boundary for the secure collector, typed helper, action runner, effective systemd profile enforcement, signed update trust and build inputs, control-plane admission, and schema-v6 qualification harness.", + "exact_paths": [ + ".github/workflows/build-release-candidate.yml", + ".github/workflows/compile-release-payload.yml", + ".github/workflows/create-release.yml", + "VERSION", + "go.mod", + "go.sum", + "internal/models/converters.go", + "internal/models/models.go", + "internal/models/models_frontend.go", + "internal/monitoring/monitor.go", + "internal/monitoring/monitor_agents.go", + "internal/unifiedresources/adapters.go", + "internal/unifiedresources/types.go", + "internal/unifiedresources/views.go", + "pkg/agents/docker/report.go", + "scripts/install.sh", + "scripts/install.ps1", + "scripts/install-container-agent.sh", + "scripts/install-docker.sh", + "scripts/install-mcp.sh", + "scripts/install-mcp.ps1", + "scripts/installtests/secure_runtime_systemd_lab_test.go", + "scripts/backfill-release-assets.sh", + "scripts/build-release-binaries.sh", + "scripts/build-release.sh", + "scripts/check-github-release-immutability.sh", + "scripts/package-helm-chart.sh", + "scripts/prepare-release-container-context.sh", + "scripts/pulse-auto-update.sh", + "scripts/release_asset_common.sh", + "scripts/release_build_targets.sh", + "scripts/release_candidate_manifest.py", + "scripts/release_control/secure_runtime_attestation.py", + "scripts/release_control/secure_runtime_attestation_v6.py", + "scripts/release_control/secure_runtime_source_manifest_v6.json", + "scripts/release_ldflags.sh", + "scripts/release_update_key.go", + "scripts/render_installers.go", + "scripts/require-safe-gh-attestation.sh", + "scripts/validate-published-release.sh", + "scripts/validate-release.sh", + "scripts/verify-github-release-integrity.sh" + ], + "recursive_roots": [ + "cmd/pulse-agent", + "cmd/pulse-agent-helper", + "cmd/pulse-agent-runner", + "internal/actionrunner", + "internal/agentexec", + "internal/agenthelper", + "internal/agenttls", + "internal/agentupdate", + "internal/api", + "internal/collectorlifecycle", + "internal/config", + "internal/dockeragent", + "internal/hostagent", + "internal/operationreceipt", + "internal/securityutil", + "internal/updatesignature", + "pkg/auth", + "pkg/securityutil", + "pkg/tlsutil" + ], + "include_suffixes": [ + ".go", + ".tmpl" + ], + "exclude_suffixes": [ + "_test.go" + ] +} diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 1cd7ab08d..6951e2c9d 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -4652,6 +4652,7 @@ class SubsystemLookupTest(unittest.TestCase): match["verification_requirement"]["exact_files"], [ "internal/agentupdate/coverage_test.go", + "internal/hostagent/action_runner_client_test.go", "internal/hostagent/agent_flushbuffer_test.go", "internal/hostagent/agent_metrics_test.go", "internal/hostagent/agent_new_test.go",