Qualify helper-backed agent update recovery

This commit is contained in:
Richard Courtman
2026-08-30 21:54:46 +01:00
parent 16a1574a18
commit a8bc2e044b
9 changed files with 925 additions and 50 deletions
+120
View File
@@ -2,8 +2,12 @@ package main
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"math"
"net"
"os"
"path/filepath"
"runtime"
@@ -15,6 +19,122 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/agenthelper"
)
type committerValidationProvider struct {
digest string
validation chan error
}
func (p *committerValidationProvider) Stage(context.Context, agenthelper.UpdateStageRequest) (agenthelper.UpdateStageResult, error) {
return agenthelper.UpdateStageResult{}, errors.New("unexpected stage operation")
}
func (p *committerValidationProvider) Activate(context.Context, agenthelper.UpdateActivateRequest) (agenthelper.UpdateResult, error) {
return agenthelper.UpdateResult{}, errors.New("unexpected activate operation")
}
func (p *committerValidationProvider) Commit(ctx context.Context, request agenthelper.UpdateCommitRequest) (agenthelper.UpdateResult, error) {
err := validatePulseAgentCommitter(ctx, p.digest)
p.validation <- err
if err != nil {
return agenthelper.UpdateResult{}, &agenthelper.ProviderError{
Code: agenthelper.ErrorStateConflict,
Message: err.Error(),
}
}
return agenthelper.UpdateResult{
Action: "committed",
ActivationID: request.ActivationID,
ActiveSHA256: request.CurrentSHA256,
}, nil
}
func (p *committerValidationProvider) Rollback(context.Context, agenthelper.UpdateRollbackRequest) (agenthelper.UpdateResult, error) {
return agenthelper.UpdateResult{}, errors.New("unexpected rollback operation")
}
func exerciseCommitterValidation(t *testing.T, digest string) (error, error) {
t.Helper()
socketPath := filepath.Join(t.TempDir(), "helper.sock")
listener, err := net.Listen("unix", socketPath)
if err != nil {
t.Fatalf("listen on helper socket: %v", err)
}
provider := &committerValidationProvider{
digest: digest,
validation: make(chan error, 1),
}
server, err := agenthelper.NewServer(agenthelper.ServerConfig{
AllowedUID: uint32(os.Getuid()),
PeerResolver: agenthelper.PlatformPeerResolver{},
Registry: agenthelper.NewRegistryWithProviders(nil, nil, agenthelper.Providers{Updates: provider}),
MaxOperationTimeout: 30 * time.Second,
})
if err != nil {
_ = listener.Close()
t.Fatalf("configure helper server: %v", err)
}
serverCtx, cancelServer := context.WithCancel(context.Background())
serverDone := make(chan error, 1)
go func() {
serverDone <- server.Serve(serverCtx, listener)
}()
client, err := agenthelper.NewClient(agenthelper.ClientConfig{
SocketPath: socketPath,
MaxDeadline: 30 * time.Second,
NewRequestID: func() (string, error) { return "committer-validation", nil },
})
if err != nil {
cancelServer()
_ = listener.Close()
<-serverDone
t.Fatalf("configure helper client: %v", err)
}
var result agenthelper.UpdateResult
_, callErr := client.Call(t.Context(), agenthelper.OperationAgentUpdateCommit, agenthelper.OperationVersion1, 30*time.Second, agenthelper.UpdateCommitRequest{
ActivationID: "committer-validation:0123456789abcdef",
CurrentSHA256: digest,
}, &result)
validationErr := <-provider.validation
cancelServer()
_ = listener.Close()
if err := <-serverDone; err != nil {
t.Fatalf("serve helper protocol: %v", err)
}
return callErr, validationErr
}
func TestValidatePulseAgentCommitterAcceptsCurrentExecutableDigest(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("/proc executable identity is Linux-specific")
}
data, err := os.ReadFile("/proc/self/exe")
if err != nil {
t.Fatalf("read current executable: %v", err)
}
digest := sha256.Sum256(data)
callErr, validationErr := exerciseCommitterValidation(t, fmt.Sprintf("%x", digest))
if validationErr != nil || callErr != nil {
t.Fatalf("current executable was rejected: validation=%v call=%v", validationErr, callErr)
}
}
func TestValidatePulseAgentCommitterRejectsIncorrectExecutableDigest(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("/proc executable identity is Linux-specific")
}
callErr, validationErr := exerciseCommitterValidation(t, strings.Repeat("0", sha256.Size*2))
if validationErr == nil || !strings.Contains(validationErr.Error(), "not executing the pending agent binary") {
t.Fatalf("incorrect executable digest validation error = %v", validationErr)
}
var remoteErr *agenthelper.RemoteError
if !errors.As(callErr, &remoteErr) || remoteErr.Code != agenthelper.ErrorStateConflict {
t.Fatalf("incorrect executable digest helper error = %v", callErr)
}
}
func TestInspectPulseAgentVersionRejectsWrongGoCommand(t *testing.T) {
executable, err := os.Executable()
if err != nil {
+52
View File
@@ -2,10 +2,13 @@ package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"io/fs"
"math"
"net/http"
@@ -126,11 +129,30 @@ func pendingUpdatePreviousVersion(pending *agentupdate.PendingPrivilegedUpdate)
return pending.PreviousVersion
}
func currentCollectorExecutableSHA256() (string, error) {
file, err := os.Open("/proc/self/exe")
if err != nil {
return "", fmt.Errorf("open current collector executable: %w", err)
}
defer file.Close()
hasher := sha256.New()
const maximumCollectorBytes = 100 * 1024 * 1024
written, err := io.Copy(hasher, io.LimitReader(file, maximumCollectorBytes+1))
if err != nil {
return "", fmt.Errorf("hash current collector executable: %w", err)
}
if written > maximumCollectorBytes {
return "", errors.New("current collector executable exceeds the bounded agent size")
}
return hex.EncodeToString(hasher.Sum(nil)), nil
}
func supervisePendingPrivilegedUpdate(
ctx context.Context,
update agentupdate.PrivilegedUpdate,
pending *agentupdate.PendingPrivilegedUpdate,
stateDir string,
runningSHA256 string,
locallyReady func() bool,
reportAccepted <-chan struct{},
pollInterval time.Duration,
@@ -158,6 +180,28 @@ func supervisePendingPrivilegedUpdate(
}
return fmt.Errorf("%w; pending update rolled back", reason)
}
runningSHA256 = strings.TrimSpace(runningSHA256)
switch {
case strings.EqualFold(runningSHA256, activation.RollbackSHA256):
rollbackCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
result, err := update.Rollback(rollbackCtx, activation)
cancel()
if err != nil {
return fmt.Errorf("confirm recovered helper update rollback: %w", err)
}
if result.Action != "rolled_back" || !strings.EqualFold(result.ActiveSHA256, activation.RollbackSHA256) {
return errors.New("typed helper returned an invalid recovered rollback result")
}
if err := agentupdate.ClearPendingPrivilegedUpdate(stateDir); err != nil {
return fmt.Errorf("clear recovered helper update handoff: %w", err)
}
if logger != nil {
logger.Info().Str("activation_id", activation.ActivationID).Msg("Cleared helper update handoff after durable rollback recovery")
}
return nil
case !strings.EqualFold(runningSHA256, activation.ActiveSHA256):
return errors.New("running collector executable does not match the pending update or its rollback identity")
}
deadlineDelay := time.Until(activation.RollbackDeadline)
if deadlineDelay <= 0 {
@@ -500,6 +544,13 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
if pendingUpdate != nil && privilegedUpdate == nil {
return errors.New("pending helper update cannot be verified without the typed privilege helper")
}
pendingExecutableSHA256 := ""
if pendingUpdate != nil {
pendingExecutableSHA256, err = currentCollectorExecutableSHA256()
if err != nil {
return fmt.Errorf("bind pending helper update to the running collector: %w", err)
}
}
pendingReportAccepted := make(chan struct{})
var pendingReportOnce sync.Once
@@ -713,6 +764,7 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
privilegedUpdate,
pendingUpdate,
cfg.StateDir,
pendingExecutableSHA256,
ready.Load,
pendingReportAccepted,
250*time.Millisecond,
+73 -3
View File
@@ -1922,7 +1922,7 @@ func TestPendingPrivilegedUpdateCommitsOnlyAfterReadinessAndAcceptedReport(t *te
var ready atomic.Bool
result := make(chan error, 1)
go func() {
result <- supervisePendingPrivilegedUpdate(context.Background(), stub, pending, stateDir, ready.Load, reportAccepted, time.Millisecond, nil)
result <- supervisePendingPrivilegedUpdate(context.Background(), stub, pending, stateDir, pending.Activation.ActiveSHA256, ready.Load, reportAccepted, time.Millisecond, nil)
}()
select {
case <-stub.commitCalls:
@@ -1951,6 +1951,76 @@ func TestPendingPrivilegedUpdateCommitsOnlyAfterReadinessAndAcceptedReport(t *te
}
}
func TestPendingPrivilegedUpdateRecognizesDurableHelperRollbackAndClearsHandoff(t *testing.T) {
stateDir := t.TempDir()
if err := internalSecurityutil.HardenPrivatePath(stateDir, 0o700); err != nil {
t.Fatal(err)
}
pending := testPendingUpdate(t, stateDir)
stub := &pendingUpdateSupervisorStub{
rollbackResult: agenthelper.UpdateResult{
Action: "rolled_back",
ActivationID: pending.Activation.ActivationID,
ActiveSHA256: pending.Activation.RollbackSHA256,
RollbackSHA256: pending.Activation.ActiveSHA256,
},
commitCalls: make(chan agenthelper.UpdateResult, 1),
rollbackCalls: make(chan agenthelper.UpdateResult, 1),
}
err := supervisePendingPrivilegedUpdate(
context.Background(), stub, pending, stateDir, pending.Activation.RollbackSHA256,
func() bool { return false }, make(chan struct{}), time.Millisecond, nil,
)
if err != nil {
t.Fatalf("recovered rollback supervisor error = %v", err)
}
select {
case activation := <-stub.rollbackCalls:
if activation != pending.Activation {
t.Fatalf("rollback activation = %#v", activation)
}
default:
t.Fatal("recovered rollback was not acknowledged through the helper")
}
select {
case <-stub.commitCalls:
t.Fatal("recovered rollback attempted to commit the superseded candidate")
default:
}
if loaded, loadErr := agentupdate.LoadPendingPrivilegedUpdate(stateDir); loadErr != nil || loaded != nil {
t.Fatalf("recovered rollback handoff = %#v, %v", loaded, loadErr)
}
}
func TestPendingPrivilegedUpdateRejectsUnrelatedRunningExecutable(t *testing.T) {
stateDir := t.TempDir()
if err := internalSecurityutil.HardenPrivatePath(stateDir, 0o700); err != nil {
t.Fatal(err)
}
pending := testPendingUpdate(t, stateDir)
stub := &pendingUpdateSupervisorStub{
commitCalls: make(chan agenthelper.UpdateResult, 1),
rollbackCalls: make(chan agenthelper.UpdateResult, 1),
}
err := supervisePendingPrivilegedUpdate(
context.Background(), stub, pending, stateDir, strings.Repeat("c", 64),
func() bool { return true }, make(chan struct{}), time.Millisecond, nil,
)
if err == nil || !strings.Contains(err.Error(), "does not match the pending update or its rollback identity") {
t.Fatalf("unrelated executable supervisor error = %v", err)
}
select {
case <-stub.commitCalls:
t.Fatal("unrelated executable attempted a commit")
case <-stub.rollbackCalls:
t.Fatal("unrelated executable attempted a rollback")
default:
}
if loaded, loadErr := agentupdate.LoadPendingPrivilegedUpdate(stateDir); loadErr != nil || loaded == nil || loaded.Activation != pending.Activation {
t.Fatalf("unrelated executable handoff = %#v, %v", loaded, loadErr)
}
}
func TestPendingPrivilegedUpdateCancellationRollsBackAndClearsHandoff(t *testing.T) {
stateDir := t.TempDir()
if err := internalSecurityutil.HardenPrivatePath(stateDir, 0o700); err != nil {
@@ -1969,7 +2039,7 @@ func TestPendingPrivilegedUpdateCancellationRollsBackAndClearsHandoff(t *testing
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := supervisePendingPrivilegedUpdate(ctx, stub, pending, stateDir, func() bool { return false }, make(chan struct{}), time.Millisecond, nil)
err := supervisePendingPrivilegedUpdate(ctx, stub, pending, stateDir, pending.Activation.ActiveSHA256, func() bool { return false }, make(chan struct{}), time.Millisecond, nil)
if err == nil || !strings.Contains(err.Error(), "pending update rolled back") {
t.Fatalf("supervisor error = %v", err)
}
@@ -1999,7 +2069,7 @@ func TestPendingPrivilegedUpdateRollbackFailurePreservesHandoff(t *testing.T) {
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := supervisePendingPrivilegedUpdate(ctx, stub, pending, stateDir, func() bool { return false }, make(chan struct{}), time.Millisecond, nil)
err := supervisePendingPrivilegedUpdate(ctx, stub, pending, stateDir, pending.Activation.ActiveSHA256, func() bool { return false }, make(chan struct{}), time.Millisecond, nil)
if err == nil || !strings.Contains(err.Error(), "typed helper rollback failed") {
t.Fatalf("supervisor error = %v", err)
}
@@ -2243,6 +2243,14 @@ agent inventory, registration state, or command-channel readiness.
7. Keep legacy Unified Agent compatibility names explicitly secondary when touching shared `internal/api/` runtime helpers: the legacy host-route family and `host-agent:*` scope names may remain as ingress or migration aliases, but they must not retake primary ownership in router state, live runtime scope checks, handler commentary, or operator-facing guidance.
8. Add or change the unified agent CLI entrypoint, version/help exit semantics, or startup argument/error routing through `cmd/pulse-agent/main.go`.
The CLI entrypoint owns propagation of persistence context into runtime-owned helpers. When installer-selected state roots differ from the default, `cmd/pulse-agent/main.go` must pass that exact `StateDir` through both the host-agent runtime and updater startup paths instead of letting one path silently fall back to `/var/lib/pulse-agent`.
Pending helper-update startup must bind the executing collector through the
bounded `/proc/self/exe` SHA-256 digest before acting on the durable
handoff. A process running the pending active digest may continue toward
report-gated commit. A process already running the recorded rollback digest
must acknowledge the helper's idempotent terminal rollback and clear the
handoff immediately instead of retrying commit until the old deadline. A
digest matching neither identity fails closed without committing,
rolling back, or deleting the recovery handoff.
The same runtime-owned boundary also owns Pulse control-plane URL validation for agent startup, remote config, updater continuity, and command transport. Public control-plane hostnames remain HTTPS/WSS, but self-hosted local control planes may use plain HTTP/WS when the host is loopback, a private, link-local, or carrier-grade NAT IP, a single-label LAN name, or a local DNS suffix such as `.local`, `.lan`, `.home`, `.home.arpa`, or `.internal`; installer-persisted local HTTP URLs must not be accepted by one runtime path and rejected by another.
Plaintext to a host that does not look local is available only as an explicit operator override: the `--allow-plaintext-http` flag (`PULSE_AGENT_ALLOW_PLAINTEXT_HTTP`) records process-wide consent through `securityutil.SetOperatorPlaintextHTTPConsent` before any module validates a URL, applies uniformly to every agent transport (HTTP and WS), warns at startup that the API token travels in cleartext, defaults closed, and is never emitted by generated install commands. It exists for self-hosted networks numbered from nominally public IP space; the Pulse server never sets it.
The unified agent CLI copy follows the same command-execution vocabulary as the install surface. `cmd/pulse-agent/main.go` may keep the `--enable-commands` flag name for compatibility, but the help text and inline comments must describe command execution as Pulse command execution for Patrol actions and governed Proxmox LXC Docker inventory rather than reviving AI auto-fix language.
@@ -583,6 +583,13 @@ the `white_label` branding entitlement.
pinned-fingerprint TLS clients keep one fail-closed security floor.
9. Change operator-facing Resource Privacy/Data Handling posture through `frontend-modern/src/components/Settings/DataHandlingPanel.tsx` and `frontend-modern/src/components/Settings/dataHandlingPanelModel.ts` together so resource classification, handling-boundary, redaction copy, and the route-backed/hidden-sidebar presentation stay governed as a trust surface. The panel must consume the compact backend-owned policy posture through `frontend-modern/src/hooks/useResourceStats.ts` and `ResourceAPI.getStats()`; it must not hydrate and adapt every unified-resource row merely to display aggregate privacy counts.
10. Change inside-guest runtime collection boundaries through `docs/AGENT_SECURITY.md`, `docs/UNIFIED_AGENT.md`, `cmd/pulse-agent/main.go`, `internal/api/router.go`, and `internal/config/config.go` together. Docker / Podman inventory inside a VM or LXC may come from a guest-local `pulse-agent` module or explicitly reported guest data; LXC Docker inventory may also be collected by a Proxmox host agent only through explicit server opt-in, with optional VMID allowlisting and a minimal summary command set that avoids `docker inspect`, environment, mount, file, command, and process collection. Local Unified Agent Docker / Podman disables must not be reversed by remote profile configuration, and self-test/update preflight that needs the live runtime token must pass it through a short-lived token file rather than argv. The `--enable-docker` help line is part of that operator privacy control, so it must remain "Enable Docker / Podman Agent module" instead of exposing internal collection-module wording. The `--enable-commands` help line and installer disclosure must identify Pulse command execution as disabled by default and required for Patrol actions or the explicit Proxmox LXC Docker inventory path, not as implicit guest access.
Helper-backed update recovery is part of the same local executable trust
boundary. On startup, a pending update handoff must be compared with the
SHA-256 identity of `/proc/self/exe`: the candidate identity may proceed
toward accepted-report commit, the rollback identity may only acknowledge
the helper's idempotent rolled-back state and clear the stale handoff, and
an unrelated executable identity must leave the handoff intact and fail
closed without mutation.
Node-local LXC filesystem capacity is a distinct automatic host telemetry
boundary, not inside-guest command authority. A PVE Unified Agent may use
bounded `pct list` plus `pct df` only for guests reported running and may
@@ -8,12 +8,23 @@ package installtests
// dedicated Colima profile before opting in:
//
// mkdir -p .lab-artifacts
// v1_ldflags="$(./scripts/release_ldflags.sh agent --version 6.2.0-lab.1)"
// v2_ldflags="$(./scripts/release_ldflags.sh agent --version 6.2.0-lab.2)"
// update_seed="$(openssl rand -base64 32)"
// update_public_key="$(go run ./scripts/release_update_key.go public-key --private-key "$update_seed")"
// v1_ldflags="$(./scripts/release_ldflags.sh agent --version 6.2.0-lab.1 --update-public-keys "$update_public_key")"
// v2_ldflags="$(./scripts/release_ldflags.sh agent --version 6.2.0-lab.2 --update-public-keys "$update_public_key")"
// v3_ldflags="$(./scripts/release_ldflags.sh agent --version 6.2.0-lab.3 --update-public-keys "$update_public_key")"
// v4_ldflags="$(./scripts/release_ldflags.sh agent --version 6.2.0-lab.4 --update-public-keys "$update_public_key")"
// CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$v1_ldflags" -o .lab-artifacts/pulse-agent-v1 ./cmd/pulse-agent
// CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$v2_ldflags" -o .lab-artifacts/pulse-agent-v2 ./cmd/pulse-agent
// CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o .lab-artifacts/pulse-agent-helper ./cmd/pulse-agent-helper
// CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$v3_ldflags" -o .lab-artifacts/pulse-agent-v3 ./cmd/pulse-agent
// CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$v4_ldflags" -o .lab-artifacts/pulse-agent-v4 ./cmd/pulse-agent
// CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$v4_ldflags" -o .lab-artifacts/pulse-agent-helper ./cmd/pulse-agent-helper
// CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o .lab-artifacts/pulse-agent-runner ./cmd/pulse-agent-runner
// go run ./scripts/release_update_key.go sign --private-key "$update_seed" --file .lab-artifacts/pulse-agent-v1 > .lab-artifacts/pulse-agent-v1.sig
// go run ./scripts/release_update_key.go sign --private-key "$update_seed" --file .lab-artifacts/pulse-agent-v2 > .lab-artifacts/pulse-agent-v2.sig
// go run ./scripts/release_update_key.go sign --private-key "$update_seed" --file .lab-artifacts/pulse-agent-v3 > .lab-artifacts/pulse-agent-v3.sig
// go run ./scripts/release_update_key.go sign --private-key "$update_seed" --file .lab-artifacts/pulse-agent-v4 > .lab-artifacts/pulse-agent-v4.sig
// unset update_seed
// CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go test -c -o .lab-artifacts/installtests-linux-arm64.test ./scripts/installtests
// colima start pulse-agent-qual --activate=false --mount "$PWD:w"
// colima ssh -p pulse-agent-qual -- sudo sh -c \
@@ -22,13 +33,19 @@ package installtests
// colima ssh -p pulse-agent-qual -- sudo env \
// PULSE_SECURE_RUNTIME_SYSTEMD_LAB=1 \
// PULSE_SECURE_RUNTIME_COLLECTOR_V1="$repo/.lab-artifacts/pulse-agent-v1" \
// PULSE_SECURE_RUNTIME_COLLECTOR_V1_SIGNATURE="$repo/.lab-artifacts/pulse-agent-v1.sig" \
// PULSE_SECURE_RUNTIME_COLLECTOR_V2="$repo/.lab-artifacts/pulse-agent-v2" \
// PULSE_SECURE_RUNTIME_COLLECTOR_V2_SIGNATURE="$repo/.lab-artifacts/pulse-agent-v2.sig" \
// PULSE_SECURE_RUNTIME_COLLECTOR_V3="$repo/.lab-artifacts/pulse-agent-v3" \
// PULSE_SECURE_RUNTIME_COLLECTOR_V3_SIGNATURE="$repo/.lab-artifacts/pulse-agent-v3.sig" \
// PULSE_SECURE_RUNTIME_COLLECTOR_V4="$repo/.lab-artifacts/pulse-agent-v4" \
// PULSE_SECURE_RUNTIME_COLLECTOR_V4_SIGNATURE="$repo/.lab-artifacts/pulse-agent-v4.sig" \
// 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-v4.json \
// PULSE_SECURE_RUNTIME_RECEIPT_RECORD_PATH=docs/release-control/v6/internal/records/secure-agent-runtime-systemd-receipt-v5.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-v4.jsonl \
// PULSE_SECURE_RUNTIME_TRANSCRIPT_RECORD_PATH=docs/release-control/v6/internal/records/secure-agent-runtime-systemd-transcript-v5.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.
@@ -77,6 +94,9 @@ const (
secureRuntimeRunnerSecretV2 = "d7f6f2550788213e0595f276b62b1df290f7e018ba74c03068c4afbe6efd7601"
secureRuntimeRunnerBindingV1 = "secure-runtime-runner-binding-v1"
secureRuntimeRunnerBindingV2 = "secure-runtime-runner-binding-v2"
secureRuntimeUpdateStatePath = "/var/lib/pulse-agent-helper/update-activation.json"
secureRuntimeUpdateHandoff = "/var/lib/pulse-agent/.pulse-agent-update-pending.json"
secureRuntimeUpdateLKGPath = "/usr/local/bin/pulse-agent.last-known-good"
)
var secureRuntimeInstalledPaths = []string{
@@ -119,6 +139,22 @@ var secureRuntimeScenarioClaims = map[string][]string{
"automatic_failure_rollback": {"failed_activation_restored_prior_runtime"},
"ordinary_update_non_migration": {"ordinary_update_preserved_selected_profile"},
"final_safe_profile_apply": {"collector_reporting_continued_after_migration"},
"helper_update_authoritative_commit": {
"signed_helper_activation_observed",
"activated_process_digest_bound",
"accepted_primary_report_gated_commit",
"update_handoff_cleared_after_commit",
},
"helper_update_watchdog_rollback": {
"helper_watchdog_rollback_observed",
"prior_active_binary_restored_from_rollback_slot",
"collector_reporting_resumed_after_watchdog_rollback",
},
"helper_update_interrupted_recovery": {
"helper_restart_recovered_pending_activation",
"prior_active_binary_restored_from_rollback_slot",
"collector_reporting_resumed_after_helper_recovery",
},
"separate_action_runner_install": {"action_runner_registered_separately"},
"typed_action_receipt": {
"typed_mutation_verified",
@@ -134,6 +170,7 @@ type secureRuntimeLabReport struct {
ReceivedAt time.Time
AgentID string
AgentVersion string
UpdatedFrom string
Hostname string
RunningAsRoot bool
ServiceUser string
@@ -145,10 +182,13 @@ type secureRuntimeLabReport struct {
type secureRuntimeLabFixture struct {
mu sync.Mutex
collector []byte
collectorSignature string
helper []byte
runner []byte
serverVersion string
reports []secureRuntimeLabReport
reportAttempts []secureRuntimeLabReport
rejectedVersions map[string]bool
lastSeen time.Time
freezeLastSeen bool
authFailures int
@@ -163,10 +203,11 @@ type secureRuntimeLabFixture struct {
authorityReductions int
}
func newSecureRuntimeLabFixture(collector, helper, runner []byte, version string) *secureRuntimeLabFixture {
func newSecureRuntimeLabFixture(collector []byte, collectorSignature string, helper, runner []byte, version string) *secureRuntimeLabFixture {
fixture := &secureRuntimeLabFixture{
collector: collector, helper: helper, runner: runner, serverVersion: version,
actionSecret: secureRuntimeRunnerSecretV1, actionBindingID: secureRuntimeRunnerBindingV1, actionPending: true,
collector: collector, collectorSignature: collectorSignature, helper: helper, runner: runner, serverVersion: version,
rejectedVersions: make(map[string]bool),
actionSecret: secureRuntimeRunnerSecretV1, actionBindingID: secureRuntimeRunnerBindingV1, actionPending: true,
}
fixture.actionServer = agentexec.NewServerWithAdmissionValidator(fixture.admitActionRunner, fixture.validateActionRunnerSession)
return fixture
@@ -225,10 +266,11 @@ func (f *secureRuntimeLabFixture) authorityReductionCount() int {
return f.authorityReductions
}
func (f *secureRuntimeLabFixture) setCollector(artifact []byte) {
func (f *secureRuntimeLabFixture) setCollector(artifact []byte, signature string) {
f.mu.Lock()
defer f.mu.Unlock()
f.collector = append([]byte(nil), artifact...)
f.collectorSignature = strings.TrimSpace(signature)
}
func (f *secureRuntimeLabFixture) setServerVersion(version string) {
@@ -243,15 +285,27 @@ func (f *secureRuntimeLabFixture) setFrozen(frozen bool) {
f.freezeLastSeen = frozen
}
func (f *secureRuntimeLabFixture) setVersionReportsAccepted(version string, accepted bool) {
f.mu.Lock()
defer f.mu.Unlock()
f.rejectedVersions[strings.TrimSpace(version)] = !accepted
}
func (f *secureRuntimeLabFixture) snapshot() ([]secureRuntimeLabReport, time.Time, int, []string) {
f.mu.Lock()
defer f.mu.Unlock()
return append([]secureRuntimeLabReport(nil), f.reports...), f.lastSeen, f.authFailures, append([]string(nil), f.requestFailures...)
}
func (f *secureRuntimeLabFixture) attemptSnapshot() []secureRuntimeLabReport {
f.mu.Lock()
defer f.mu.Unlock()
return append([]secureRuntimeLabReport(nil), f.reportAttempts...)
}
func (f *secureRuntimeLabFixture) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/version":
case r.URL.Path == "/api/version" || r.URL.Path == "/api/agent/version":
f.mu.Lock()
version := f.serverVersion
f.mu.Unlock()
@@ -342,16 +396,22 @@ func (f *secureRuntimeLabFixture) serveArtifact(w http.ResponseWriter, r *http.R
}
f.mu.Lock()
artifact := append([]byte(nil), f.collector...)
signature := f.collectorSignature
switch artifactKind {
case "helper":
artifact = append([]byte(nil), f.helper...)
signature = ""
case "runner":
artifact = append([]byte(nil), f.runner...)
signature = ""
}
f.mu.Unlock()
sum := sha256.Sum256(artifact)
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("X-Checksum-Sha256", hex.EncodeToString(sum[:]))
if signature != "" {
w.Header().Set("X-Signature-Ed25519", signature)
}
w.Header().Set("Content-Length", strconv.Itoa(len(artifact)))
if r.Method == http.MethodGet {
_, _ = w.Write(artifact)
@@ -463,6 +523,7 @@ func (f *secureRuntimeLabFixture) handleReport(w http.ResponseWriter, r *http.Re
Agent struct {
ID string `json:"id"`
Version string `json:"version"`
UpdatedFrom string `json:"updatedFrom"`
Hostname string `json:"hostname"`
CommandsEnabled bool `json:"commandsEnabled"`
Privilege *struct {
@@ -484,6 +545,7 @@ func (f *secureRuntimeLabFixture) handleReport(w http.ResponseWriter, r *http.Re
ReceivedAt: time.Now().UTC(),
AgentID: strings.TrimSpace(payload.Agent.ID),
AgentVersion: strings.TrimSpace(payload.Agent.Version),
UpdatedFrom: strings.TrimSpace(payload.Agent.UpdatedFrom),
Hostname: strings.TrimSpace(payload.Agent.Hostname),
CommandsEnabled: payload.Agent.CommandsEnabled,
}
@@ -497,11 +559,18 @@ func (f *secureRuntimeLabFixture) handleReport(w http.ResponseWriter, r *http.Re
report.TypedHelper = payload.Agent.Privilege.TypedHelper
}
f.mu.Lock()
f.reportAttempts = append(f.reportAttempts, report)
rejected := f.rejectedVersions[report.AgentVersion]
serverVersion := f.serverVersion
if rejected {
f.mu.Unlock()
http.Error(w, "report version temporarily rejected by qualification gate", http.StatusServiceUnavailable)
return
}
f.reports = append(f.reports, report)
if !f.freezeLastSeen {
f.lastSeen = report.ReceivedAt
}
serverVersion := f.serverVersion
f.mu.Unlock()
writeSecureRuntimeJSON(w, http.StatusOK, map[string]any{
"success": true,
@@ -842,12 +911,16 @@ func TestSecureRuntimeSourceManifestCoversTransitiveProviders(t *testing.T) {
for _, required := range []string{
"internal/agenthelper/providers.go",
"internal/agenthelper/container_inventory.go",
"internal/agenttls/config.go",
"internal/hostagent/action_runner_client.go",
"internal/hostagent/action_runner_health_persistence_unix.go",
"internal/hostagent/package_updates.go",
"internal/api/action_runner_credentials.go",
"internal/api/router_routes_registration.go",
"internal/agentexec/server.go",
"internal/securityutil/secure_storage_dir.go",
"pkg/auth/scopes.go",
"pkg/securityutil/httpurl.go",
} {
if _, ok := hashes[required]; !ok {
t.Fatalf("secure-runtime source manifest omitted transitive boundary source %s", required)
@@ -861,7 +934,7 @@ func TestSecureRuntimeSourceManifestCoversTransitiveProviders(t *testing.T) {
}
func TestSecureRuntimeFixturePromotesPendingRunner(t *testing.T) {
fixture := newSecureRuntimeLabFixture(nil, nil, nil, "fixture")
fixture := newSecureRuntimeLabFixture(nil, "", nil, nil, "fixture")
defer fixture.actionServer.Shutdown()
server := httptest.NewServer(fixture)
defer server.Close()
@@ -910,11 +983,19 @@ func TestSecureRuntimeSystemdLab(t *testing.T) {
secureRuntimeRequireDisposableHost(t)
collectorV1 := secureRuntimeReadArtifact(t, "PULSE_SECURE_RUNTIME_COLLECTOR_V1")
collectorV1Signature := secureRuntimeReadSignature(t, "PULSE_SECURE_RUNTIME_COLLECTOR_V1_SIGNATURE")
collectorV2 := secureRuntimeReadArtifact(t, "PULSE_SECURE_RUNTIME_COLLECTOR_V2")
collectorV2Signature := secureRuntimeReadSignature(t, "PULSE_SECURE_RUNTIME_COLLECTOR_V2_SIGNATURE")
collectorV3 := secureRuntimeReadArtifact(t, "PULSE_SECURE_RUNTIME_COLLECTOR_V3")
collectorV3Signature := secureRuntimeReadSignature(t, "PULSE_SECURE_RUNTIME_COLLECTOR_V3_SIGNATURE")
collectorV4 := secureRuntimeReadArtifact(t, "PULSE_SECURE_RUNTIME_COLLECTOR_V4")
collectorV4Signature := secureRuntimeReadSignature(t, "PULSE_SECURE_RUNTIME_COLLECTOR_V4_SIGNATURE")
helper := secureRuntimeReadArtifact(t, "PULSE_SECURE_RUNTIME_HELPER")
runner := secureRuntimeReadArtifact(t, "PULSE_SECURE_RUNTIME_RUNNER")
collectorV1Version := secureRuntimeArtifactVersion(t, "PULSE_SECURE_RUNTIME_COLLECTOR_V1")
collectorV2Version := secureRuntimeArtifactVersion(t, "PULSE_SECURE_RUNTIME_COLLECTOR_V2")
collectorV3Version := secureRuntimeArtifactVersion(t, "PULSE_SECURE_RUNTIME_COLLECTOR_V3")
collectorV4Version := secureRuntimeArtifactVersion(t, "PULSE_SECURE_RUNTIME_COLLECTOR_V4")
if collectorV1Version == collectorV2Version {
t.Fatalf("collector V1 and V2 must have distinct --version output, both reported %q", collectorV1Version)
}
@@ -923,7 +1004,7 @@ func TestSecureRuntimeSystemdLab(t *testing.T) {
t.Fatalf("resolve installer path: %v", err)
}
fixture := newSecureRuntimeLabFixture(collectorV1, helper, runner, collectorV1Version)
fixture := newSecureRuntimeLabFixture(collectorV1, collectorV1Signature, helper, runner, collectorV1Version)
defer fixture.actionServer.Shutdown()
server := httptest.NewServer(fixture)
defer server.Close()
@@ -935,13 +1016,13 @@ func TestSecureRuntimeSystemdLab(t *testing.T) {
startedAt := time.Now().UTC()
sourceManifest, sourceHashes := secureRuntimeLoadSourceBoundary(t, runtime.GOARCH)
receipt := secureRuntimeLabReceipt{
SchemaVersion: 4,
SchemaVersion: 5,
RecordPath: recordPath,
StartedAt: startedAt.Format(time.RFC3339Nano),
SourceManifest: sourceManifest,
SourceHashes: sourceHashes,
ArtifactHashes: map[string]string{"collector_v1": secureRuntimeHash(collectorV1), "collector_v2": secureRuntimeHash(collectorV2), "helper": secureRuntimeHash(helper), "runner": secureRuntimeHash(runner)},
ArtifactVersions: map[string]string{"collector_v1": collectorV1Version, "collector_v2": collectorV2Version},
ArtifactHashes: map[string]string{"collector_v1": secureRuntimeHash(collectorV1), "collector_v2": secureRuntimeHash(collectorV2), "collector_v3": secureRuntimeHash(collectorV3), "collector_v4": secureRuntimeHash(collectorV4), "helper": secureRuntimeHash(helper), "runner": secureRuntimeHash(runner)},
ArtifactVersions: map[string]string{"collector_v1": collectorV1Version, "collector_v2": collectorV2Version, "collector_v3": collectorV3Version, "collector_v4": collectorV4Version},
DisposableVMGuardHash: secureRuntimeHash([]byte(secureRuntimeLabMarkerValue + "\n")),
Architecture: runtime.GOARCH,
}
@@ -1035,7 +1116,7 @@ func TestSecureRuntimeSystemdLab(t *testing.T) {
legacyBaseline := secureRuntimeStableSnapshot(t)
reportsBeforeApply, preApplyLastSeen, _, _ := fixture.snapshot()
fixture.setCollector(collectorV2)
fixture.setCollector(collectorV2, collectorV2Signature)
applyOutput := secureRuntimeRunInstaller(t, installerPath, server.URL, "--safe-profile-apply")
secureRuntimeWaitForReports(t, fixture, len(reportsBeforeApply)+1, 45*time.Second)
_, postApplyLastSeen, _, _ := fixture.snapshot()
@@ -1073,7 +1154,7 @@ func TestSecureRuntimeSystemdLab(t *testing.T) {
secureRuntimeAssertRootMonitoringProfile(t)
pass("automatic_failure_rollback", "frozen lastSeen prevented commit and restored binary/state identity without command authority", map[string]any{"activation_committed": false, "restored_profile": "root-monitoring"})
fixture.setCollector(collectorV2)
fixture.setCollector(collectorV2, collectorV2Signature)
reportsBeforeUpdate, _, _, _ := fixture.snapshot()
secureRuntimeRunInstaller(t, installerPath, server.URL, "--update")
secureRuntimeWaitForReports(t, fixture, len(reportsBeforeUpdate)+1, 45*time.Second)
@@ -1100,6 +1181,107 @@ 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})
collectorV2SHA256 := secureRuntimeHash(collectorV2)
collectorV3SHA256 := secureRuntimeHash(collectorV3)
collectorV4SHA256 := secureRuntimeHash(collectorV4)
preUpdatePID := secureRuntimeCollectorMainPID(t)
fixture.setVersionReportsAccepted(collectorV3Version, false)
fixture.setCollector(collectorV3, collectorV3Signature)
fixture.setServerVersion(collectorV3Version)
pendingV3 := secureRuntimeWaitForUpdateState(t, "pending", collectorV3SHA256, collectorV2SHA256, 45*time.Second)
secureRuntimeWaitForReportAttempt(t, fixture, collectorV3Version, collectorV2Version, 45*time.Second)
if pendingV3.ActivatorPID != preUpdatePID {
t.Fatalf("V3 activation peer PID = %d, want pre-exec collector PID %d", pendingV3.ActivatorPID, preUpdatePID)
}
postExecPID := secureRuntimeCollectorMainPID(t)
if postExecPID != preUpdatePID {
t.Fatalf("helper-backed exec changed systemd MainPID from %d to %d", preUpdatePID, postExecPID)
}
if got := secureRuntimeProcessExecutableHash(t, postExecPID); got != collectorV3SHA256 {
t.Fatalf("/proc/%d/exe digest = %s, want V3 %s", postExecPID, got, collectorV3SHA256)
}
secureRuntimeAssertUpdateBinaryIdentities(t, collectorV3SHA256, collectorV2SHA256)
if _, err := os.Stat(secureRuntimeUpdateHandoff); err != nil {
t.Fatalf("pending helper update handoff unavailable: %v", err)
}
fixture.setVersionReportsAccepted(collectorV3Version, true)
secureRuntimeWaitForAcceptedVersion(t, fixture, collectorV3Version, 1, 30*time.Second)
committedV3 := secureRuntimeWaitForUpdateState(t, "committed", collectorV3SHA256, collectorV2SHA256, 30*time.Second)
secureRuntimeWaitForFileAbsent(t, secureRuntimeUpdateHandoff, 15*time.Second)
if committedV3.ActivatorPID != postExecPID {
t.Fatalf("committed V3 activator PID = %d, want current collector PID %d", committedV3.ActivatorPID, postExecPID)
}
secureRuntimeAssertUpdateBinaryIdentities(t, collectorV3SHA256, collectorV2SHA256)
secureRuntimeAssertSafeProfile(t)
pass("helper_update_authoritative_commit", "signed helper activation retained the systemd process identity and committed only after a freshly accepted primary report", map[string]any{
"signature_verified": true,
"candidate_sha256": collectorV3SHA256,
"prior_sha256": collectorV2SHA256,
"target_sha256": collectorV3SHA256,
"last_known_good_sha256": collectorV2SHA256,
"activator_pid": pendingV3.ActivatorPID,
"committer_pid": committedV3.ActivatorPID,
"accepted_primary_report": true,
"update_action": "committed",
"handoff_cleared": true,
"reporting_continuity": true,
})
fixture.setVersionReportsAccepted(collectorV4Version, false)
fixture.setCollector(collectorV4, collectorV4Signature)
fixture.setServerVersion(collectorV4Version)
pendingV4Watchdog := secureRuntimeWaitForUpdateState(t, "pending", collectorV4SHA256, collectorV3SHA256, 45*time.Second)
secureRuntimeWaitForReportAttempt(t, fixture, collectorV4Version, collectorV3Version, 45*time.Second)
watchdogPID := secureRuntimeCollectorMainPID(t)
if pendingV4Watchdog.ActivatorPID != watchdogPID || secureRuntimeProcessExecutableHash(t, watchdogPID) != collectorV4SHA256 {
t.Fatalf("watchdog candidate identity mismatch: state=%+v pid=%d", pendingV4Watchdog, watchdogPID)
}
acceptedV3BeforeWatchdog := len(secureRuntimeWaitForAcceptedVersion(t, fixture, collectorV3Version, 1, 5*time.Second))
fixture.setServerVersion(collectorV3Version)
secureRuntimeCommand(t, 10*time.Second, "systemctl", "kill", "--kill-who=main", "--signal=STOP", "pulse-agent.service")
secureRuntimeWaitForUpdateState(t, "rolled_back", collectorV3SHA256, collectorV4SHA256, 150*time.Second)
secureRuntimeAssertUpdateBinaryIdentities(t, collectorV3SHA256, collectorV4SHA256)
secureRuntimeCommand(t, 10*time.Second, "systemctl", "kill", "--kill-who=main", "--signal=KILL", "pulse-agent.service")
secureRuntimeWaitForAcceptedVersion(t, fixture, collectorV3Version, acceptedV3BeforeWatchdog+1, 45*time.Second)
secureRuntimeWaitForFileAbsent(t, secureRuntimeUpdateHandoff, 30*time.Second)
secureRuntimeAssertSafeProfile(t)
pass("helper_update_watchdog_rollback", "the independent helper watchdog restored V3 after the unresponsive V4 collector missed its production rollback deadline", map[string]any{
"candidate_sha256": collectorV4SHA256,
"prior_sha256": collectorV3SHA256,
"target_sha256": collectorV3SHA256,
"last_known_good_sha256": collectorV4SHA256,
"update_action": "rolled_back",
"rollback_trigger": "watchdog",
"reporting_continuity": true,
})
fixture.setServerVersion(collectorV4Version)
pendingV4Recovery := secureRuntimeWaitForUpdateState(t, "pending", collectorV4SHA256, collectorV3SHA256, 45*time.Second)
recoveryPID := secureRuntimeCollectorMainPID(t)
if pendingV4Recovery.ActivatorPID != recoveryPID || secureRuntimeProcessExecutableHash(t, recoveryPID) != collectorV4SHA256 {
t.Fatalf("interrupted-recovery candidate identity mismatch: state=%+v pid=%d", pendingV4Recovery, recoveryPID)
}
fixture.setServerVersion(collectorV3Version)
secureRuntimeCommand(t, 10*time.Second, "systemctl", "kill", "--kill-who=main", "--signal=STOP", "pulse-agent.service")
acceptedV3BeforeRecovery := len(secureRuntimeWaitForAcceptedVersion(t, fixture, collectorV3Version, acceptedV3BeforeWatchdog+1, 5*time.Second))
secureRuntimeCommand(t, 20*time.Second, "systemctl", "restart", "pulse-agent-helper.service")
secureRuntimeWaitForUpdateState(t, "rolled_back", collectorV3SHA256, collectorV4SHA256, 20*time.Second)
secureRuntimeAssertUpdateBinaryIdentities(t, collectorV3SHA256, collectorV4SHA256)
secureRuntimeCommand(t, 10*time.Second, "systemctl", "kill", "--kill-who=main", "--signal=KILL", "pulse-agent.service")
secureRuntimeWaitForAcceptedVersion(t, fixture, collectorV3Version, acceptedV3BeforeRecovery+1, 45*time.Second)
secureRuntimeWaitForFileAbsent(t, secureRuntimeUpdateHandoff, 30*time.Second)
secureRuntimeAssertSafeProfile(t)
pass("helper_update_interrupted_recovery", "restarting only the helper recovered the durable pending activation before the stopped collector could participate", map[string]any{
"candidate_sha256": collectorV4SHA256,
"prior_sha256": collectorV3SHA256,
"target_sha256": collectorV3SHA256,
"last_known_good_sha256": collectorV4SHA256,
"update_action": "rolled_back",
"rollback_trigger": "helper-restart",
"reporting_continuity": true,
})
fixture.setCollector(collectorV3, collectorV3Signature)
reportsBeforeRunner, _, _, _ := fixture.snapshot()
secureRuntimeRunInstallerWithActionCredential(t, installerPath, server.URL, secureRuntimeRunnerSecretV1,
"--least-privilege", "--enable-privileged-helper", "--enable-action-runner")
@@ -1233,6 +1415,134 @@ func TestSecureRuntimeSystemdLab(t *testing.T) {
secureRuntimeWriteEvidence(t, &receipt, secureRuntimeFinalizeTranscript(&receipt))
}
type secureRuntimeUpdateState struct {
Action string `json:"action"`
ActivationID string `json:"activationId"`
ActiveSHA256 string `json:"activeSha256"`
RollbackSHA256 string `json:"rollbackSha256"`
RollbackDeadline time.Time `json:"rollbackDeadline"`
ActivatorPID int `json:"activatorPid"`
}
func secureRuntimeWaitForUpdateState(t *testing.T, action, activeSHA256, rollbackSHA256 string, timeout time.Duration) secureRuntimeUpdateState {
t.Helper()
deadline := time.Now().Add(timeout)
var (
state secureRuntimeUpdateState
lastErr error
)
for time.Now().Before(deadline) {
raw, err := os.ReadFile(secureRuntimeUpdateStatePath)
if err == nil {
err = json.Unmarshal(raw, &state)
}
lastErr = err
if err == nil && state.Action == action && strings.EqualFold(state.ActiveSHA256, activeSHA256) && strings.EqualFold(state.RollbackSHA256, rollbackSHA256) {
return state
}
time.Sleep(200 * time.Millisecond)
}
t.Fatalf("timed out waiting for helper update state action=%s active=%s rollback=%s; got=%+v err=%v", action, activeSHA256, rollbackSHA256, state, lastErr)
return secureRuntimeUpdateState{}
}
func secureRuntimeWaitForFileAbsent(t *testing.T, path string, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) {
return
}
time.Sleep(100 * time.Millisecond)
}
if _, err := os.Lstat(path); err == nil {
t.Fatalf("timed out waiting for %s to be removed", path)
} else {
t.Fatalf("inspect %s while waiting for removal: %v", path, err)
}
}
func secureRuntimeWaitForReportAttempt(t *testing.T, fixture *secureRuntimeLabFixture, version, updatedFrom string, timeout time.Duration) secureRuntimeLabReport {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
for _, report := range fixture.attemptSnapshot() {
if report.AgentVersion == version && report.UpdatedFrom == updatedFrom {
return report
}
}
time.Sleep(200 * time.Millisecond)
}
t.Fatalf("timed out waiting for report attempt version=%q updatedFrom=%q", version, updatedFrom)
return secureRuntimeLabReport{}
}
func secureRuntimeWaitForAcceptedVersion(t *testing.T, fixture *secureRuntimeLabFixture, version string, minimumCount int, timeout time.Duration) []secureRuntimeLabReport {
t.Helper()
deadline := time.Now().Add(timeout)
var matched []secureRuntimeLabReport
for time.Now().Before(deadline) {
reports, _, _, _ := fixture.snapshot()
matched = matched[:0]
for _, report := range reports {
if report.AgentVersion == version {
matched = append(matched, report)
}
}
if len(matched) >= minimumCount {
return append([]secureRuntimeLabReport(nil), matched...)
}
time.Sleep(200 * time.Millisecond)
}
t.Fatalf("timed out waiting for %d accepted reports from version %q; got %d", minimumCount, version, len(matched))
return nil
}
func secureRuntimeCollectorMainPID(t *testing.T) int {
t.Helper()
deadline := time.Now().Add(15 * time.Second)
stablePID := 0
stableSince := time.Time{}
lastPIDText := ""
for time.Now().Before(deadline) {
out, err := exec.Command("systemctl", "show", "pulse-agent.service", "--property=MainPID", "--value").Output()
lastPIDText = strings.TrimSpace(string(out))
pid, parseErr := strconv.Atoi(lastPIDText)
if err == nil && parseErr == nil && pid > 1 {
if _, statErr := os.Stat(fmt.Sprintf("/proc/%d/status", pid)); statErr == nil {
if pid != stablePID {
stablePID = pid
stableSince = time.Now()
} else if time.Since(stableSince) >= time.Second {
return pid
}
time.Sleep(100 * time.Millisecond)
continue
}
}
stablePID = 0
stableSince = time.Time{}
time.Sleep(100 * time.Millisecond)
}
t.Fatalf("pulse-agent MainPID did not remain positive and stable: last=%q", lastPIDText)
return 0
}
func secureRuntimeProcessExecutableHash(t *testing.T, pid int) string {
t.Helper()
return secureRuntimeHash(secureRuntimeReadFile(t, fmt.Sprintf("/proc/%d/exe", pid)))
}
func secureRuntimeAssertUpdateBinaryIdentities(t *testing.T, targetSHA256, lastKnownGoodSHA256 string) {
t.Helper()
if got := secureRuntimeHash(secureRuntimeReadFile(t, "/usr/local/bin/pulse-agent")); !strings.EqualFold(got, targetSHA256) {
t.Fatalf("installed collector hash = %s, want %s", got, targetSHA256)
}
if got := secureRuntimeHash(secureRuntimeReadFile(t, secureRuntimeUpdateLKGPath)); !strings.EqualFold(got, lastKnownGoodSHA256) {
t.Fatalf("last-known-good collector hash = %s, want %s", got, lastKnownGoodSHA256)
}
}
func secureRuntimeRequireDisposableHost(t *testing.T) {
t.Helper()
if runtime.GOOS != "linux" {
@@ -1295,6 +1605,26 @@ func secureRuntimeArtifactVersion(t *testing.T, envName string) string {
return version
}
func secureRuntimeReadSignature(t *testing.T, envName string) string {
t.Helper()
path := strings.TrimSpace(os.Getenv(envName))
if path == "" || !filepath.IsAbs(path) {
t.Fatalf("%s must name an absolute caller-built signature path", envName)
}
info, err := os.Lstat(path)
if err != nil {
t.Fatalf("inspect %s: %v", envName, err)
}
if !info.Mode().IsRegular() {
t.Fatalf("%s must be a regular signature file: %s (%s)", envName, path, info.Mode())
}
signature := strings.TrimSpace(string(secureRuntimeReadFile(t, path)))
if signature == "" || len(signature) > 4096 || strings.ContainsAny(signature, "\x00\r\n") {
t.Fatalf("%s contains an invalid detached signature", envName)
}
return signature
}
func secureRuntimeReadFile(t *testing.T, path string) []byte {
t.Helper()
data, err := os.ReadFile(path)
@@ -1315,13 +1645,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_v4.json"
manifestRelative := "scripts/release_control/secure_runtime_source_manifest_v5.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-v4" || manifest.TargetOS != "linux" {
if manifest.SchemaVersion != 1 || manifest.ManifestID != "secure-runtime-linux-v5" || manifest.TargetOS != "linux" {
t.Fatalf("unsupported secure-runtime source manifest: %+v", manifest)
}
sourceHashes := make(map[string]string)
@@ -1585,11 +1915,7 @@ func secureRuntimeCollectorHasArgument(argument string) bool {
func secureRuntimeCollectorProcessUID(t *testing.T) int {
t.Helper()
pidText := secureRuntimeSystemdProperty(t, "MainPID")
pid, err := strconv.Atoi(strings.TrimSpace(pidText))
if err != nil || pid <= 1 {
t.Fatalf("invalid pulse-agent MainPID %q", pidText)
}
pid := secureRuntimeCollectorMainPID(t)
status := string(secureRuntimeReadFile(t, fmt.Sprintf("/proc/%d/status", pid)))
for _, line := range strings.Split(status, "\n") {
fields := strings.Fields(line)
@@ -22,11 +22,11 @@ RFC3339_RE = re.compile(
r"^(?P<whole>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})"
r"(?:\.(?P<fraction>\d+))?(?P<offset>Z|[+-]\d{2}:\d{2})$"
)
RECEIPT_SCHEMA_VERSION = 4
ATTESTATION_SCHEMA_VERSION = 4
SOURCE_MANIFEST_PATH = "scripts/release_control/secure_runtime_source_manifest_v4.json"
RECEIPT_SCHEMA_VERSION = 5
ATTESTATION_SCHEMA_VERSION = 5
SOURCE_MANIFEST_PATH = "scripts/release_control/secure_runtime_source_manifest_v5.json"
SOURCE_MANIFEST_SCHEMA_VERSION = 1
SOURCE_MANIFEST_ID = "secure-runtime-linux-v4"
SOURCE_MANIFEST_ID = "secure-runtime-linux-v5"
REQUIRED_SCENARIOS = (
"legacy_root_command_capable_install",
"read_only_inspect",
@@ -36,6 +36,9 @@ REQUIRED_SCENARIOS = (
"automatic_failure_rollback",
"ordinary_update_non_migration",
"final_safe_profile_apply",
"helper_update_authoritative_commit",
"helper_update_watchdog_rollback",
"helper_update_interrupted_recovery",
"separate_action_runner_install",
"typed_action_receipt",
"action_runner_credential_rotation",
@@ -55,6 +58,22 @@ SCENARIO_REQUIRED_CLAIMS = {
"automatic_failure_rollback": {"failed_activation_restored_prior_runtime"},
"ordinary_update_non_migration": {"ordinary_update_preserved_selected_profile"},
"final_safe_profile_apply": {"collector_reporting_continued_after_migration"},
"helper_update_authoritative_commit": {
"signed_helper_activation_observed",
"activated_process_digest_bound",
"accepted_primary_report_gated_commit",
"update_handoff_cleared_after_commit",
},
"helper_update_watchdog_rollback": {
"helper_watchdog_rollback_observed",
"prior_active_binary_restored_from_rollback_slot",
"collector_reporting_resumed_after_watchdog_rollback",
},
"helper_update_interrupted_recovery": {
"helper_restart_recovered_pending_activation",
"prior_active_binary_restored_from_rollback_slot",
"collector_reporting_resumed_after_helper_recovery",
},
"separate_action_runner_install": {"action_runner_registered_separately"},
"typed_action_receipt": {
"typed_mutation_verified",
@@ -78,6 +97,23 @@ SCENARIO_REQUIRED_OBSERVATIONS = {
"automatic_failure_rollback": {"activation_committed": False, "restored_profile": "root-monitoring"},
"ordinary_update_non_migration": {"collector_v2_installed": True, "selected_profile": "root-monitoring"},
"final_safe_profile_apply": {"collector_service_user": "pulse-agent", "continuity_report_observed": True},
"helper_update_authoritative_commit": {
"signature_verified": True,
"accepted_primary_report": True,
"update_action": "committed",
"handoff_cleared": True,
"reporting_continuity": True,
},
"helper_update_watchdog_rollback": {
"update_action": "rolled_back",
"rollback_trigger": "watchdog",
"reporting_continuity": True,
},
"helper_update_interrupted_recovery": {
"update_action": "rolled_back",
"rollback_trigger": "helper-restart",
"reporting_continuity": True,
},
"separate_action_runner_install": {
"runner_service_user": "root",
"collector_service_user": "pulse-agent",
@@ -98,15 +134,30 @@ SCENARIO_REQUIRED_OBSERVATIONS = {
},
"action_runner_self_revoke": {"revocation_count": 1, "collector_continuity": True},
}
HELPER_UPDATE_SCENARIOS = (
"helper_update_authoritative_commit",
"helper_update_watchdog_rollback",
"helper_update_interrupted_recovery",
)
HELPER_UPDATE_DIGEST_OBSERVATIONS = (
"candidate_sha256",
"prior_sha256",
"target_sha256",
"last_known_good_sha256",
)
ARTIFACT_ARGUMENTS = {
"collector_v1": "collector_v1",
"collector_v2": "collector_v2",
"collector_v3": "collector_v3",
"collector_v4": "collector_v4",
"helper": "helper",
"runner": "runner",
}
EXPECTED_ARTIFACT_PACKAGES = {
"collector_v1": "github.com/rcourtman/pulse-go-rewrite/cmd/pulse-agent",
"collector_v2": "github.com/rcourtman/pulse-go-rewrite/cmd/pulse-agent",
"collector_v3": "github.com/rcourtman/pulse-go-rewrite/cmd/pulse-agent",
"collector_v4": "github.com/rcourtman/pulse-go-rewrite/cmd/pulse-agent",
"helper": "github.com/rcourtman/pulse-go-rewrite/cmd/pulse-agent-helper",
"runner": "github.com/rcourtman/pulse-go-rewrite/cmd/pulse-agent-runner",
}
@@ -440,7 +491,74 @@ def verify_scenarios(receipt: dict[str, Any], transcript_events: list[dict[str,
raise AttestationError(f"scenario {scenario['name']} completion is not bound to its transcript time")
names.append(scenario["name"])
if tuple(names) != REQUIRED_SCENARIOS:
raise AttestationError("receipt scenario set or order does not match the canonical 12-scenario qualification")
raise AttestationError(
f"receipt scenario set or order does not match the canonical {len(REQUIRED_SCENARIOS)}-scenario qualification"
)
verify_helper_update_observations(receipt, scenarios)
def verify_helper_update_observations(receipt: dict[str, Any], scenarios: list[dict[str, Any]]) -> None:
by_name = {scenario["name"]: scenario for scenario in scenarios}
observations: dict[str, dict[str, Any]] = {}
for scenario_name in HELPER_UPDATE_SCENARIOS:
scenario_observations = by_name[scenario_name]["evidence"]["observations"]
for key in HELPER_UPDATE_DIGEST_OBSERVATIONS:
value = scenario_observations.get(key)
if not isinstance(value, str) or not SHA256_RE.fullmatch(value):
raise AttestationError(
f"scenario {scenario_name} observation {key} is not a SHA-256 digest"
)
if scenario_observations["candidate_sha256"] == scenario_observations["prior_sha256"]:
raise AttestationError(f"scenario {scenario_name} does not prove a changed executable identity")
observations[scenario_name] = scenario_observations
committed = observations["helper_update_authoritative_commit"]
for key in ("activator_pid", "committer_pid"):
value = committed.get(key)
if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
raise AttestationError(
f"scenario helper_update_authoritative_commit observation {key} is not a positive PID"
)
if committed["activator_pid"] != committed["committer_pid"]:
raise AttestationError("helper update commit was not issued by the activated collector PID")
if (
committed["target_sha256"] != committed["candidate_sha256"]
or committed["last_known_good_sha256"] != committed["prior_sha256"]
):
raise AttestationError("authoritative helper update target/LKG identities are inconsistent")
committed_target = committed["target_sha256"]
for scenario_name in (
"helper_update_watchdog_rollback",
"helper_update_interrupted_recovery",
):
rolled_back = observations[scenario_name]
if (
rolled_back["prior_sha256"] != committed_target
or rolled_back["target_sha256"] != rolled_back["prior_sha256"]
or rolled_back["last_known_good_sha256"] != rolled_back["candidate_sha256"]
):
raise AttestationError(
f"scenario {scenario_name} target/LKG identities do not prove restoration of the committed collector"
)
artifact_hashes = receipt.get("artifact_hashes")
if not isinstance(artifact_hashes, dict):
raise AttestationError("receipt artifact_hashes are unavailable for helper update binding")
expected_transitions = {
"helper_update_authoritative_commit": ("collector_v2", "collector_v3"),
"helper_update_watchdog_rollback": ("collector_v3", "collector_v4"),
"helper_update_interrupted_recovery": ("collector_v3", "collector_v4"),
}
for scenario_name, (prior_artifact, candidate_artifact) in expected_transitions.items():
scenario_observations = observations[scenario_name]
if (
scenario_observations["prior_sha256"] != artifact_hashes.get(prior_artifact)
or scenario_observations["candidate_sha256"] != artifact_hashes.get(candidate_artifact)
):
raise AttestationError(
f"scenario {scenario_name} executable identities are not bound to the attested artifacts"
)
def verify_runtime_claims(receipt: dict[str, Any]) -> None:
@@ -521,7 +639,7 @@ def verify_source_hashes(
def verify_artifacts(receipt: dict[str, Any], artifacts: dict[str, Path]) -> dict[str, str]:
receipt_hashes = receipt.get("artifact_hashes")
if not isinstance(receipt_hashes, dict) or set(receipt_hashes) != set(ARTIFACT_ARGUMENTS):
raise AttestationError("receipt artifact_hashes must contain the canonical four artifacts")
raise AttestationError("receipt artifact_hashes must contain the canonical six artifacts")
verified: dict[str, str] = {}
for name in ARTIFACT_ARGUMENTS:
expected = receipt_hashes[name]
@@ -538,11 +656,12 @@ def verify_artifact_build_identity(
receipt: dict[str, Any], artifacts: dict[str, Path], qualified_commit: str
) -> dict[str, dict[str, str]]:
versions = receipt.get("artifact_versions")
if not isinstance(versions, dict) or set(versions) != {"collector_v1", "collector_v2"}:
raise AttestationError("receipt artifact_versions must identify both collector artifacts")
collector_names = {"collector_v1", "collector_v2", "collector_v3", "collector_v4"}
if not isinstance(versions, dict) or set(versions) != collector_names:
raise AttestationError("receipt artifact_versions must identify all four collector artifacts")
if any(not isinstance(value, str) or not value.strip() for value in versions.values()):
raise AttestationError("receipt collector artifact version is invalid")
if versions["collector_v1"] == versions["collector_v2"]:
if len(set(versions.values())) != len(collector_names):
raise AttestationError("collector qualification artifacts must have distinct versions")
verified: dict[str, dict[str, str]] = {}
@@ -698,6 +817,8 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
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)
@@ -46,8 +46,10 @@ class SecureRuntimeAttestationTest(unittest.TestCase):
"scripts/installtests/secure_runtime_systemd_lab_test.go",
"scripts/release_control/secure_runtime_attestation.py",
SOURCE_MANIFEST_PATH,
"scripts/release_ldflags.sh",
"scripts/release_update_key.go",
],
"recursive_roots": ["boundary"],
"recursive_roots": ["boundary", "internal/updatesignature"],
"include_suffixes": [".go"],
"exclude_suffixes": ["_test.go"],
}
@@ -57,7 +59,10 @@ class SecureRuntimeAttestationTest(unittest.TestCase):
"scripts/installtests/secure_runtime_systemd_lab_test.go": b"fixture harness\n",
"scripts/release_control/secure_runtime_attestation.py": Path(__file__).with_name("secure_runtime_attestation.py").read_bytes(),
SOURCE_MANIFEST_PATH: manifest_raw,
"scripts/release_ldflags.sh": b"#!/bin/sh\n",
"scripts/release_update_key.go": b"package main\n",
"boundary/runtime.go": b"package boundary\n",
"internal/updatesignature/signature.go": b"package updatesignature\n",
}
self.source_hashes = {}
for relative, value in sorted(source_values.items()):
@@ -102,6 +107,29 @@ class SecureRuntimeAttestationTest(unittest.TestCase):
event_id = f"event-{event_sequence:04d}"
claims = sorted(SCENARIO_REQUIRED_CLAIMS[name])
observations = dict(SCENARIO_REQUIRED_OBSERVATIONS[name])
if name == "helper_update_authoritative_commit":
observations.update(
{
"candidate_sha256": artifact_hashes["collector_v3"],
"prior_sha256": artifact_hashes["collector_v2"],
"target_sha256": artifact_hashes["collector_v3"],
"last_known_good_sha256": artifact_hashes["collector_v2"],
"activator_pid": 4321,
"committer_pid": 4321,
}
)
elif name in (
"helper_update_watchdog_rollback",
"helper_update_interrupted_recovery",
):
observations.update(
{
"candidate_sha256": artifact_hashes["collector_v4"],
"prior_sha256": artifact_hashes["collector_v3"],
"target_sha256": artifact_hashes["collector_v3"],
"last_known_good_sha256": artifact_hashes["collector_v4"],
}
)
scenarios.append(
{
"sequence": index,
@@ -139,10 +167,10 @@ class SecureRuntimeAttestationTest(unittest.TestCase):
self.receipt.write_text(
json.dumps(
{
"schema_version": 4,
"record_path": "records/receipt.json",
"schema_version": 5,
"record_path": "records/secure-runtime-receipt-v5.json",
"started_at": started_at,
"completed_at": "2026-08-30T10:00:13+00:00",
"completed_at": "2026-08-30T10:00:16+00:00",
"source_manifest": {
"schema_version": SOURCE_MANIFEST_SCHEMA_VERSION,
"manifest_id": SOURCE_MANIFEST_ID,
@@ -153,7 +181,12 @@ class SecureRuntimeAttestationTest(unittest.TestCase):
},
"source_hashes": self.source_hashes,
"artifact_hashes": artifact_hashes,
"artifact_versions": {"collector_v1": "1.0.0", "collector_v2": "1.1.0"},
"artifact_versions": {
"collector_v1": "1.0.0",
"collector_v2": "1.1.0",
"collector_v3": "1.2.0",
"collector_v4": "1.3.0",
},
"disposable_vm_guard_sha256": DISPOSABLE_VM_GUARD_SHA256,
"os_release": 'PRETTY_NAME="Fixture Linux"',
"kernel": "Linux fixture",
@@ -161,7 +194,7 @@ class SecureRuntimeAttestationTest(unittest.TestCase):
"architecture": "arm64",
"transcript": {
"format": "jsonl-v1",
"record_path": "records/transcript.jsonl",
"record_path": "records/secure-runtime-transcript-v5.jsonl",
"sha256": sha256_bytes(transcript_raw),
"event_count": len(transcript_events),
},
@@ -171,7 +204,7 @@ class SecureRuntimeAttestationTest(unittest.TestCase):
"action_receipt_kind": "pulse.host_storage_cleanup_result",
"report_count": 3,
"first_report_at": "2026-08-30T10:00:01+00:00",
"last_report_at": "2026-08-30T10:00:12+00:00",
"last_report_at": "2026-08-30T10:00:15+00:00",
"scenarios": scenarios,
},
indent=2,
@@ -208,7 +241,7 @@ class SecureRuntimeAttestationTest(unittest.TestCase):
"commit": self.commit,
"main_ref": "origin/main",
"receipt_path": self.receipt,
"receipt_record_path": "records/receipt.json",
"receipt_record_path": "records/secure-runtime-receipt-v5.json",
"transcript_path": self.transcript,
"artifacts": self.artifacts,
"elapsed_seconds": 113.43,
@@ -222,9 +255,10 @@ class SecureRuntimeAttestationTest(unittest.TestCase):
self.assertEqual(result["qualified_commit"], self.commit)
self.assertTrue(result["source_hashes_match_commit"])
self.assertTrue(result["artifact_hashes_match_receipt"])
self.assertEqual(result["scenario_count"], 12)
self.assertEqual(result["schema_version"], 5)
self.assertEqual(result["scenario_count"], 15)
self.assertTrue(result["receipt"]["path_bound_inside_receipt"])
self.assertEqual(result["transcript"]["event_count"], 13)
self.assertEqual(result["transcript"]["event_count"], 16)
self.assertEqual(result["source_manifest"]["manifest_id"], SOURCE_MANIFEST_ID)
self.assertEqual(len(result["attestation_tool_sha256"]), 64)
@@ -274,6 +308,13 @@ class SecureRuntimeAttestationTest(unittest.TestCase):
with self.assertRaisesRegex(AttestationError, "exactly match the governed boundary manifest"):
self.attest()
def test_rejects_source_set_that_omits_update_signature_boundary(self) -> None:
receipt = json.loads(self.receipt.read_text(encoding="utf-8"))
del receipt["source_hashes"]["internal/updatesignature/signature.go"]
self.receipt.write_text(json.dumps(receipt), encoding="utf-8")
with self.assertRaisesRegex(AttestationError, "exactly match the governed boundary manifest"):
self.attest()
def test_rejects_artifact_digest_mismatch(self) -> None:
self.artifacts["runner"].write_bytes(b"tampered\n")
with self.assertRaisesRegex(AttestationError, "artifact digest mismatch"):
@@ -338,18 +379,73 @@ class SecureRuntimeAttestationTest(unittest.TestCase):
with self.assertRaisesRegex(AttestationError, "required typed observations"):
self.attest()
def test_rejects_helper_update_without_accepted_report(self) -> None:
receipt = json.loads(self.receipt.read_text(encoding="utf-8"))
scenario = next(
item
for item in receipt["scenarios"]
if item["name"] == "helper_update_authoritative_commit"
)
scenario["evidence"]["observations"]["accepted_primary_report"] = False
self.receipt.write_text(json.dumps(receipt), encoding="utf-8")
with self.assertRaisesRegex(AttestationError, "required typed observations"):
self.attest()
def test_rejects_malformed_helper_update_digest(self) -> None:
self.rewrite_scenario_observations(
"helper_update_authoritative_commit",
lambda observations: observations.__setitem__("candidate_sha256", "not-a-digest"),
)
with self.assertRaisesRegex(AttestationError, "not a SHA-256 digest"):
self.attest()
def test_rejects_helper_update_commit_from_different_pid(self) -> None:
self.rewrite_scenario_observations(
"helper_update_authoritative_commit",
lambda observations: observations.__setitem__("committer_pid", 4322),
)
with self.assertRaisesRegex(AttestationError, "activated collector PID"):
self.attest()
def test_rejects_helper_update_commit_with_wrong_target_identity(self) -> None:
self.rewrite_scenario_observations(
"helper_update_authoritative_commit",
lambda observations: observations.__setitem__("target_sha256", "2" * 64),
)
with self.assertRaisesRegex(AttestationError, "target/LKG identities are inconsistent"):
self.attest()
def test_rejects_helper_update_identity_not_bound_to_artifact(self) -> None:
self.rewrite_scenario_observations(
"helper_update_authoritative_commit",
lambda observations: (
observations.__setitem__("prior_sha256", "f" * 64),
observations.__setitem__("last_known_good_sha256", "f" * 64),
),
)
with self.assertRaisesRegex(AttestationError, "not bound to the attested artifacts"):
self.attest()
def test_rejects_watchdog_rollback_with_wrong_lkg_identity(self) -> None:
self.rewrite_scenario_observations(
"helper_update_watchdog_rollback",
lambda observations: observations.__setitem__("last_known_good_sha256", "2" * 64),
)
with self.assertRaisesRegex(AttestationError, "restoration of the committed collector"):
self.attest()
def test_rejects_legacy_receipt_schema(self) -> None:
receipt = json.loads(self.receipt.read_text(encoding="utf-8"))
receipt["schema_version"] = 3
receipt["schema_version"] = 4
self.receipt.write_text(json.dumps(receipt), encoding="utf-8")
with self.assertRaisesRegex(AttestationError, "schema_version 4"):
with self.assertRaisesRegex(AttestationError, "schema_version 5"):
self.attest()
def test_rejects_missing_scenario(self) -> None:
receipt = json.loads(self.receipt.read_text(encoding="utf-8"))
receipt["scenarios"].pop()
self.receipt.write_text(json.dumps(receipt), encoding="utf-8")
with self.assertRaisesRegex(AttestationError, "canonical 12-scenario"):
with self.assertRaisesRegex(AttestationError, "canonical 15-scenario"):
self.attest()
def test_rejects_mismatched_release_candidate_ref(self) -> None:
@@ -395,6 +491,32 @@ class SecureRuntimeAttestationTest(unittest.TestCase):
def receipt_dict(self):
return json.loads(self.receipt.read_text(encoding="utf-8"))
def rewrite_scenario_observations(self, scenario_name, mutate) -> None:
receipt = self.receipt_dict()
scenario = next(
item for item in receipt["scenarios"] if item["name"] == scenario_name
)
observations = scenario["evidence"]["observations"]
mutate(observations)
events = [
json.loads(line)
for line in self.transcript.read_text(encoding="utf-8").splitlines()
]
event = next(
item
for item in events
if item.get("kind") == "scenario_result"
and item.get("scenario") == scenario_name
)
event["observations"] = observations
transcript_raw = b"".join(
(json.dumps(item, separators=(",", ":")) + "\n").encode()
for item in events
)
self.transcript.write_bytes(transcript_raw)
receipt["transcript"]["sha256"] = sha256_bytes(transcript_raw)
self.receipt.write_text(json.dumps(receipt), encoding="utf-8")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,49 @@
{
"schema_version": 1,
"manifest_id": "secure-runtime-linux-v5",
"target_os": "linux",
"description": "Production source boundary for the secure collector, typed helper, action runner, signed update trust and build inputs, control-plane admission, and systemd qualification harness.",
"exact_paths": [
"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/installtests/secure_runtime_systemd_lab_test.go",
"scripts/release_control/secure_runtime_attestation.py",
"scripts/release_control/secure_runtime_source_manifest_v5.json",
"scripts/release_ldflags.sh",
"scripts/release_update_key.go"
],
"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/config",
"internal/dockeragent",
"internal/hostagent",
"internal/operationreceipt",
"internal/securityutil",
"internal/updatesignature",
"pkg/auth",
"pkg/securityutil"
],
"include_suffixes": [
".go",
".tmpl"
],
"exclude_suffixes": [
"_test.go"
]
}