Harden secure agent runtime boundaries

This commit is contained in:
Richard Courtman
2026-08-31 00:06:24 +01:00
parent caa2f77d23
commit 08f7c5f0d5
53 changed files with 6744 additions and 377 deletions
+3
View File
@@ -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/
+95 -15
View File
@@ -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)
}
+52 -1
View File
@@ -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)
}
}
@@ -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)
}
}
+113
View File
@@ -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
}
+116
View File
@@ -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
}
@@ -0,0 +1,10 @@
//go:build !windows
package main
import (
"os"
"strconv"
)
func collectorLifecycleTestOwnerUID() string { return strconv.Itoa(os.Geteuid()) }
@@ -0,0 +1,5 @@
//go:build windows
package main
func collectorLifecycleTestOwnerUID() string { return "" }
+10
View File
@@ -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 {
+34 -1
View File
@@ -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
@@ -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
@@ -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
@@ -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,
@@ -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
@@ -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",
@@ -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 —
@@ -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
+34 -1
View File
@@ -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
+13
View File
@@ -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
+180 -53
View File
@@ -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 {
+1 -1
View File
@@ -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")
}
+274
View File
@@ -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",
+72 -11
View File
@@ -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))
+201
View File
@@ -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()
+89 -22
View File
@@ -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()
+134 -4
View File
@@ -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()
+4 -2
View File
@@ -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 {
+105 -8
View File
@@ -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
+329 -4
View File
@@ -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{
+8 -1
View File
@@ -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)
}
+24
View File
@@ -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}}
+1
View File
@@ -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",
@@ -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)))
+2 -2
View File
@@ -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 {
+323
View File
@@ -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
}
+360
View File
@@ -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
}
@@ -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
}
@@ -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)
}
}
@@ -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
}
@@ -0,0 +1,10 @@
//go:build !windows
package collectorlifecycle
import "os"
func testTokenOwnerUID() *uint64 {
uid := uint64(os.Geteuid())
return &uid
}
@@ -0,0 +1,5 @@
//go:build windows
package collectorlifecycle
func testTokenOwnerUID() *uint64 { return nil }
+148 -2
View File
@@ -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)
}
@@ -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 {
+27 -9
View File
@@ -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 {
+518 -148
View File
@@ -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<http_code>" 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}"
+593 -30
View File
@@ -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")
@@ -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 {
@@ -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)
@@ -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",
@@ -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())
@@ -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] <github-actions[bot]@users.noreply.github.com> 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 <a@example.net> 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()
@@ -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"
]
}
@@ -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",