From 584cef81a162e7a80b818b2676605a2c3861ef2d Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:38:39 +0100 Subject: [PATCH] Add rootful runtime qualification packet --- .gitignore | 3 + .../v6/internal/subsystems/agent-lifecycle.md | 27 +- .../subsystems/deployment-installability.md | 18 +- .../v6/internal/subsystems/registry.json | 42 + ...cure_runtime_rootful_qualification_test.go | 988 ++++++++++++++++++ .../secure_runtime_rootful_attestation_v1.py | 568 ++++++++++ ...ure_runtime_rootful_attestation_v1_test.py | 475 +++++++++ ...re_runtime_rootful_source_manifest_v1.json | 51 + ...un-secure-runtime-rootful-qualification.sh | 409 ++++++++ 9 files changed, 2579 insertions(+), 2 deletions(-) create mode 100644 scripts/installtests/secure_runtime_rootful_qualification_test.go create mode 100644 scripts/release_control/secure_runtime_rootful_attestation_v1.py create mode 100644 scripts/release_control/secure_runtime_rootful_attestation_v1_test.py create mode 100644 scripts/release_control/secure_runtime_rootful_source_manifest_v1.json create mode 100755 scripts/run-secure-runtime-rootful-qualification.sh diff --git a/.gitignore b/.gitignore index 75864cd19..99d5297ef 100644 --- a/.gitignore +++ b/.gitignore @@ -271,6 +271,9 @@ scripts/release_control/* !scripts/release_control/secure_runtime_attestation_v6_test.py !scripts/release_control/secure_runtime_attestation_v7.py !scripts/release_control/secure_runtime_attestation_v7_test.py +!scripts/release_control/secure_runtime_rootful_attestation_v1.py +!scripts/release_control/secure_runtime_rootful_attestation_v1_test.py +!scripts/release_control/secure_runtime_rootful_source_manifest_v1.json !scripts/release_control/secure_runtime_rootless_attestation_v1.py !scripts/release_control/secure_runtime_rootless_attestation_v1_test.py !scripts/release_control/secure_runtime_rootless_source_manifest_v1.json diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index d9f8ad823..6cd29d707 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -1760,7 +1760,9 @@ lifecycle conditions, but compact empty-state spacing, icon treatment, and text hierarchy must compose frontend-primitives' `EmptyState` `variant="panel"` instead of lifecycle-local centered icon/text shells. -32. `scripts/run-secure-runtime-rootless-qualification.sh` shared with `deployment-installability`: the rootless runtime qualification wrapper is both an agent lifecycle provider-boundary proof entry point and a deployment installability proof harness. +32. `scripts/run-secure-runtime-rootful-qualification.sh` shared with `deployment-installability`: the rootful runtime qualification wrapper is both an agent lifecycle typed-helper provider-boundary proof entry point and a deployment installability proof harness. + +33. `scripts/run-secure-runtime-rootless-qualification.sh` shared with `deployment-installability`: the rootless runtime qualification wrapper is both an agent lifecycle provider-boundary proof entry point and a deployment installability proof harness. ## Extension Points @@ -7392,6 +7394,29 @@ published-release provenance, default-profile authorization, rootless action/update qualification, or independent security review and cannot change the product default. +### Rootful container qualification has its own typed-helper packet + +`scripts/run-secure-runtime-rootful-qualification.sh` is the destructive, +explicit entrypoint for real rootful Docker and Podman proof on two distinct +disposable Ubuntu/systemd hosts. It never mounts a host daemon socket and runs +the outer containers without a default route. The packet binds exact clean Go +artifacts and governed source hashes, then records fresh install, legacy +migration with authority reduction, collector and helper restart continuity, +helper loss without an authoritative empty replacement, exact recovery, +bounded helper-operation failure, ordinary collector-update preservation, +authority isolation, and cleanup for both runtimes. Rootful telemetry is +intentionally summary-only: the collector remains unable to open the +root-owned daemon socket and gains neither container actions nor updates. + +`scripts/release_control/secure_runtime_rootful_attestation_v1.py` accepts only +the exact Docker-then-Podman ten-scenario contract, distinct host and daemon +identities, canonical rootful socket paths, stable semantic inventory, causal +report streams, bounded failure and recovery, and immutable artifact/source +bindings. Adding this packet does not itself qualify the runtime. Only a +retained passing receipt and attestation may support a local opt-in claim, and +neither can establish published-release provenance, default-profile +authorization, appliance support, or independent security review. + ### Command and durable typed dispatch are context-honest and canceled when abandoned The agent command transport now refuses to dispatch work its caller has diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 5d23407a9..ebdb2d61b 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -322,6 +322,20 @@ Failed runtime tests must retain the disposable systemd journal and container log before nonce-bound cleanup; absence of a receipt is not a replacement for the causal failure record. +Live rootful-runtime proof is also separated from the schema-v7 systemd +packet. The explicit +`scripts/run-secure-runtime-rootful-qualification.sh` wrapper creates distinct +network-isolated Ubuntu/systemd hosts for real rootful Docker and Podman and +never mounts a host daemon socket. Its standalone schema-v1 receipt binds the +exact qualification, collector, helper, installer, source manifest, canonical +root-owned socket, summary inventory, migration, restart, loss, recovery, +bounded operation, update-preservation, authority-isolation, and cleanup +evidence before the independent validator can emit a local artifact-bound +self-attestation. All Go artifacts require exact clean VCS metadata and all +retained outputs use a private mode-`0700` host directory. Checking in the +tooling or passing its contract tests is not live qualification, published +release proof, default-profile authorization, or independent review. + Release builds and archives carry both helper and runner binaries for the five Linux targets (`amd64`, `arm64`, `armv7`, `armv6`, and `386`) with checksum, Ed25519, and SSH signature sidecars. Exact archive/container-context validation @@ -899,7 +913,9 @@ artifact-selection behaviour. `NoNewPrivileges=false` exception above: it restores only the privilege `lxc-attach` needs and leaves the rest of the sandbox intact. -9. `scripts/run-secure-runtime-rootless-qualification.sh` shared with `agent-lifecycle`: the rootless runtime qualification wrapper is both an agent lifecycle provider-boundary proof entry point and a deployment installability proof harness. +9. `scripts/run-secure-runtime-rootful-qualification.sh` shared with `agent-lifecycle`: the rootful runtime qualification wrapper is both an agent lifecycle typed-helper provider-boundary proof entry point and a deployment installability proof harness. + +10. `scripts/run-secure-runtime-rootless-qualification.sh` shared with `agent-lifecycle`: the rootless runtime qualification wrapper is both an agent lifecycle provider-boundary proof entry point and a deployment installability proof harness. ## Extension Points diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 432389866..5232b1c4f 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -1287,6 +1287,14 @@ "deployment-installability" ] }, + { + "path": "scripts/run-secure-runtime-rootful-qualification.sh", + "rationale": "the rootful runtime qualification wrapper is both an agent lifecycle typed-helper provider-boundary proof entry point and a deployment installability proof harness", + "subsystems": [ + "agent-lifecycle", + "deployment-installability" + ] + }, { "path": "scripts/run-secure-runtime-rootless-qualification.sh", "rationale": "the rootless runtime qualification wrapper is both an agent lifecycle provider-boundary proof entry point and a deployment installability proof harness", @@ -1397,6 +1405,7 @@ "scripts/installtests/windows_agent_lifecycle.ps1", "scripts/installtests/windowslifecycleserver/main.go", "scripts/run-native-pve-action-qualification.sh", + "scripts/run-secure-runtime-rootful-qualification.sh", "scripts/run-secure-runtime-rootless-qualification.sh", "tests/integration/tests/84-docker-restart-real-lab-artifact.spec.ts" ], @@ -1481,6 +1490,20 @@ "scripts/installtests/native_pve_action_qualification_test.go" ] }, + { + "id": "rootful-container-runtime-qualification", + "label": "disposable rootful Docker and Podman typed-helper lifecycle qualification proof", + "match_prefixes": [], + "match_files": [ + "scripts/run-secure-runtime-rootful-qualification.sh" + ], + "allow_same_subsystem_tests": false, + "test_prefixes": [], + "exact_files": [ + "scripts/installtests/secure_runtime_rootful_qualification_test.go", + "scripts/release_control/secure_runtime_rootful_attestation_v1_test.py" + ] + }, { "id": "rootless-container-runtime-qualification", "label": "disposable rootless Docker and Podman lifecycle qualification proof", @@ -4513,6 +4536,8 @@ "scripts/release_control/resolve_release_promotion.py", "scripts/release_control/secure_runtime_attestation_v6.py", "scripts/release_control/secure_runtime_attestation_v7.py", + "scripts/release_control/secure_runtime_rootful_attestation_v1.py", + "scripts/release_control/secure_runtime_rootful_source_manifest_v1.json", "scripts/release_control/secure_runtime_rootless_attestation_v1.py", "scripts/release_control/secure_runtime_rootless_source_manifest_v1.json", "scripts/release_control/secure_runtime_source_manifest_v6.json", @@ -4522,6 +4547,7 @@ "scripts/release_update_key.go", "scripts/run-release-backend-tests.sh", "scripts/run-release-preflight.sh", + "scripts/run-secure-runtime-rootful-qualification.sh", "scripts/run-secure-runtime-rootless-qualification.sh", "scripts/run_cloud_public_signup_smoke.sh", "scripts/run_demo_public_browser_smoke.sh", @@ -4757,6 +4783,22 @@ "scripts/release_control/secure_runtime_attestation_v7_test.py" ] }, + { + "id": "rootful-runtime-qualification-tooling", + "label": "standalone rootful Docker and Podman typed-helper qualification packet proof", + "match_prefixes": [], + "match_files": [ + "scripts/release_control/secure_runtime_rootful_attestation_v1.py", + "scripts/release_control/secure_runtime_rootful_source_manifest_v1.json", + "scripts/run-secure-runtime-rootful-qualification.sh" + ], + "allow_same_subsystem_tests": false, + "test_prefixes": [], + "exact_files": [ + "scripts/installtests/secure_runtime_rootful_qualification_test.go", + "scripts/release_control/secure_runtime_rootful_attestation_v1_test.py" + ] + }, { "id": "rootless-runtime-qualification-tooling", "label": "standalone rootless Docker and Podman qualification packet proof", diff --git a/scripts/installtests/secure_runtime_rootful_qualification_test.go b/scripts/installtests/secure_runtime_rootful_qualification_test.go new file mode 100644 index 000000000..699453d55 --- /dev/null +++ b/scripts/installtests/secure_runtime_rootful_qualification_test.go @@ -0,0 +1,988 @@ +//go:build !windows + +package installtests + +// This file is a standalone rootful-container qualification packet. The live +// test is opt-in and must run only inside the disposable Ubuntu/systemd hosts +// created by scripts/run-secure-runtime-rootful-qualification.sh. + +import ( + "context" + "debug/buildinfo" + "encoding/json" + "errors" + "fmt" + "net" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/agenthelper" + agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker" + agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host" +) + +const ( + rootfulQualOptIn = "PULSE_SECURE_RUNTIME_ROOTFUL_QUALIFICATION" + rootfulQualOptInValue = "disposable-v1" + rootfulQualMarker = "/etc/pulse-secure-runtime-rootful-qualification" + rootfulQualReceiptPath = "/opt/pulse/result/rootful-receipt.json" + rootfulQualResultDir = "/opt/pulse/result" + rootfulQualFixture = "pulse-rootful-qualification-fixture:v1" + rootfulQualRunningName = "pulse-rootful-running" + rootfulQualExitedName = "pulse-rootful-exited" + rootfulQualHelperSock = "/run/pulse-agent/helper.sock" +) + +var rootfulQualScenarioOrder = []string{ + "fresh_install", + "legacy_migration", + "collector_restart", + "helper_restart", + "helper_loss", + "helper_recovery", + "operation_bounds", + "update_preservation", + "authority_isolation", + "cleanup", +} + +type rootfulQualReceipt struct { + SchemaVersion int `json:"schema_version"` + Kind string `json:"kind"` + Result string `json:"result"` + SourceCommit string `json:"source_commit"` + StartedAt string `json:"started_at"` + CompletedAt string `json:"completed_at"` + SourceHashes map[string]string `json:"source_hashes"` + Artifacts rootlessQualArtifacts `json:"artifacts"` + Runs []rootfulQualRun `json:"runs"` +} + +type rootfulQualRun struct { + Host rootlessQualHost `json:"host"` + Runtime rootfulQualRuntime `json:"runtime"` + Scenarios []rootlessQualScenario `json:"scenarios"` +} + +type rootfulQualRuntime struct { + Runtime string `json:"runtime"` + RuntimeVersion string `json:"runtime_version"` + DaemonID string `json:"daemon_id"` + DaemonRootless bool `json:"daemon_rootless"` + SocketPath string `json:"socket_path"` + SocketUID int `json:"socket_uid"` + SocketGID int `json:"socket_gid"` + SocketMode string `json:"socket_mode"` + SocketType string `json:"socket_type"` + SocketSymlink bool `json:"socket_symlink"` +} + +type rootfulQualDaemon struct { + runtime string + unit string + socket string + dataRoot string + fixture string +} + +func TestSecureRuntimeRootfulQualification(t *testing.T) { + if os.Getenv(rootfulQualOptIn) != rootfulQualOptInValue { + t.Skip("run through scripts/run-secure-runtime-rootful-qualification.sh inside its disposable systemd container") + } + runtimeKind := strings.TrimSpace(os.Getenv("PULSE_ROOTFUL_RUNTIME")) + receiptPath := strings.TrimSpace(os.Getenv("PULSE_ROOTFUL_RECEIPT")) + rootfulQualRequireDisposableHost(t, runtimeKind, receiptPath) + + collector := secureRuntimeReadArtifact(t, "PULSE_SECURE_RUNTIME_COLLECTOR") + collectorSignature := secureRuntimeReadSignature(t, "PULSE_SECURE_RUNTIME_COLLECTOR_SIGNATURE") + helper := secureRuntimeReadArtifact(t, "PULSE_SECURE_RUNTIME_HELPER") + collectorVersion := secureRuntimeArtifactVersion(t, "PULSE_SECURE_RUNTIME_COLLECTOR") + installerPath := strings.TrimSpace(os.Getenv("PULSE_SECURE_RUNTIME_INSTALLER")) + if !filepath.IsAbs(installerPath) { + t.Fatalf("PULSE_SECURE_RUNTIME_INSTALLER must be absolute: %q", installerPath) + } + + started := time.Now().UTC() + fixture := newSecureRuntimeLabFixture(collector, collectorSignature, helper, nil, collectorVersion) + defer fixture.actionServer.Shutdown() + server := httptestNewServer(t, fixture) + defer server.Close() + collectorCredential := secureRuntimeLabToken + + daemon := rootfulQualDaemonFor(runtimeKind) + defer rootlessQualBestEffortStop(daemon.unit, rootfulQualHungUnit(runtimeKind)) + rootfulQualPrepareFixture(t, daemon) + rootfulQualStartDaemon(t, daemon) + rootfulQualCreateFixtures(t, daemon) + baseline := rootfulQualRuntimeBaseline(t, daemon) + if baseline.Count != 2 { + t.Fatalf("rootful %s baseline count = %d, want 2", runtimeKind, baseline.Count) + } + runtimeVersion := rootfulQualRuntimeVersion(t, daemon) + daemonID := rootfulQualDaemonID(t, daemon) + socketUID, socketGID, socketMode := rootfulQualSocketIdentity(t, daemon.socket) + if socketUID != 0 { + t.Fatalf("rootful runtime socket UID = %d, want 0", socketUID) + } + + var scenarios []rootlessQualScenario + appendScenario := func(name string, began time.Time, report *agentsdocker.Report, evidence map[string]any) { + scenario := rootlessQualScenario{ + Name: name, Result: "passed", StartedAt: began.Format(time.RFC3339Nano), + CompletedAt: time.Now().UTC().Format(time.RFC3339Nano), Evidence: evidence, + } + if report != nil { + stream, sequence, ok := agentshost.ParseReportSequenceID(report.SequenceID) + if !ok { + t.Fatalf("scenario %s received invalid sequence ID %q", name, report.SequenceID) + } + scenario.ReportStreamID = &stream + scenario.ReportSequence = &sequence + } + scenarios = append(scenarios, scenario) + } + + freshStarted := time.Now().UTC() + secureRuntimeRunInstaller(t, installerPath, server.URL, + "--least-privilege", "--enable-privileged-helper", "--enable-docker") + fresh := rootfulQualWaitSummary(t, fixture, freshStarted, runtimeKind, baseline.SemanticDigest, 75*time.Second) + secureRuntimeAssertSafeProfile(t) + secureRuntimeAssertHelperProtocol(t) + rootfulQualAssertCollectorSocketDenied(t, daemon.socket) + freshPID := secureRuntimeCollectorMainPID(t) + freshHelperPID, _ := rootfulQualUnitIdentity(t, "pulse-agent-helper.service") + appendScenario("fresh_install", freshStarted, &fresh.Report, + rootfulQualSummaryEvidence(freshPID, freshHelperPID, daemonID, fresh.Report)) + + rootlessQualUninstallPulse(t, installerPath, server.URL, collectorCredential) + rootlessQualAssertPulseRemoved(t) + if registered, revoked, uninstalls := fixture.collectorLifecycleSnapshot(); registered || !revoked || uninstalls != 1 { + t.Fatalf("fresh collector uninstall was not durably modeled: registered=%t revoked=%t uninstalls=%d", registered, revoked, uninstalls) + } + fixture.replaceCollectorCredential(secureRuntimeLabTokenV2, secureRuntimeCollectorBindingV2) + collectorCredential = secureRuntimeLabTokenV2 + + migrationStarted := time.Now().UTC() + secureRuntimeRunInstallerWithCollectorCredential(t, installerPath, server.URL, collectorCredential, + "--enable-commands", "--command-authority", "command-capable", "--enable-docker") + legacy := rootlessQualWaitReport(t, fixture, migrationStarted, 75*time.Second, func(report agentsdocker.Report) bool { + return rootlessQualComplete(report) && report.Host.CollectionMode == "" && report.Host.Runtime == runtimeKind && rootlessQualSemanticDigest(report) == baseline.SemanticDigest + }) + secureRuntimeAssertRootCommandProfile(t) + legacyPID := secureRuntimeCollectorMainPID(t) + applyStarted := time.Now().UTC() + secureRuntimeRunInstallerWithCollectorCredential(t, installerPath, server.URL, collectorCredential, "--safe-profile-apply") + migrated := rootfulQualWaitSummary(t, fixture, applyStarted, runtimeKind, baseline.SemanticDigest, 75*time.Second) + secureRuntimeAssertSafeProfile(t) + secureRuntimeAssertHelperProtocol(t) + rootfulQualAssertCollectorSocketDenied(t, daemon.socket) + migratedPID := secureRuntimeCollectorMainPID(t) + if legacyPID == migratedPID || fixture.authorityReductionCount() < 1 || rootlessQualSemanticDigest(legacy.Report) != rootlessQualSemanticDigest(migrated.Report) { + t.Fatalf("rootful migration did not replace/reduce the collector with summary parity") + } + migratedHelperPID, _ := rootfulQualUnitIdentity(t, "pulse-agent-helper.service") + migrationEvidence := rootfulQualSummaryEvidence(migratedPID, migratedHelperPID, daemonID, migrated.Report) + migrationEvidence["legacy_profile"] = "root-command-capable" + migrationEvidence["target_profile"] = "typed-helper-monitoring-only" + migrationEvidence["authority_reduced"] = true + migrationEvidence["legacy_collector_pid"] = legacyPID + appendScenario("legacy_migration", migrationStarted, &migrated.Report, migrationEvidence) + + collectorRestartStarted := time.Now().UTC() + previousStream, _, _ := agentshost.ParseReportSequenceID(migrated.Report.SequenceID) + secureRuntimeCommand(t, 20*time.Second, "systemctl", "restart", "pulse-agent.service") + collectorPID := secureRuntimeCollectorMainPID(t) + collectorRestart := rootfulQualWaitSummary(t, fixture, collectorRestartStarted, runtimeKind, baseline.SemanticDigest, 75*time.Second) + collectorStream, _, _ := agentshost.ParseReportSequenceID(collectorRestart.Report.SequenceID) + if collectorPID == migratedPID || collectorStream == previousStream { + t.Fatalf("collector restart did not replace PID/report stream: pid=%d/%d stream=%s/%s", migratedPID, collectorPID, previousStream, collectorStream) + } + helperPID, _ := rootfulQualUnitIdentity(t, "pulse-agent-helper.service") + collectorRestartEvidence := rootfulQualSummaryEvidence(collectorPID, helperPID, daemonID, collectorRestart.Report) + collectorRestartEvidence["previous_collector_pid"] = migratedPID + collectorRestartEvidence["previous_report_stream_id"] = previousStream + appendScenario("collector_restart", collectorRestartStarted, &collectorRestart.Report, collectorRestartEvidence) + + helperPIDBefore, helperInvocationBefore := rootfulQualUnitIdentity(t, "pulse-agent-helper.service") + secureRuntimeCommand(t, 20*time.Second, "systemctl", "restart", "pulse-agent-helper.service") + helperRestartStarted := time.Now().UTC() + helperPIDAfter, helperInvocationAfter := rootfulQualUnitIdentity(t, "pulse-agent-helper.service") + if helperPIDBefore == helperPIDAfter || helperInvocationBefore == helperInvocationAfter { + t.Fatalf("helper restart did not replace exact service identity") + } + helperRestart := rootfulQualWaitSummary(t, fixture, helperRestartStarted, runtimeKind, baseline.SemanticDigest, 75*time.Second) + helperRestartEvidence := rootfulQualSummaryEvidence(collectorPID, helperPIDAfter, daemonID, helperRestart.Report) + helperRestartEvidence["previous_helper_pid"] = helperPIDBefore + helperRestartEvidence["previous_helper_invocation_id"] = helperInvocationBefore + helperRestartEvidence["helper_invocation_id"] = helperInvocationAfter + appendScenario("helper_restart", helperRestartStarted, &helperRestart.Report, helperRestartEvidence) + + lossStarted := time.Now().UTC() + secureRuntimeCommand(t, 20*time.Second, "systemctl", "stop", "pulse-agent-helper.socket", "pulse-agent-helper.service") + loss := rootfulQualWaitStatusOnly(t, fixture, lossStarted, runtimeKind, 75*time.Second) + lossStream, lossSequence, _ := agentshost.ParseReportSequenceID(loss.Report.SequenceID) + if len(loss.Report.Containers) != 0 || secureRuntimeCollectorMainPID(t) != collectorPID { + t.Fatal("helper loss emitted an authoritative empty inventory or replaced the collector") + } + appendScenario("helper_loss", lossStarted, &loss.Report, map[string]any{ + "collector_pid": collectorPID, "previous_helper_pid": helperPIDAfter, + "collection_mode": "typed-helper-unavailable-status-only", "helper_available": false, + "status_only": true, "inventory_complete": false, "inventory_present": false, + "authoritative_inventory_replacement": false, + "previous_authoritative_inventory_count": baseline.Count, + "previous_authoritative_semantic_sha256": baseline.SemanticDigest, + "operation_status": "degraded", "operation": agenthelper.OperationContainerInventory, + "container_updates_enabled": false, "container_actions_enabled": false, "direct_socket_access": false, + }) + + recoveryStarted := time.Now().UTC() + secureRuntimeCommand(t, 20*time.Second, "systemctl", "start", "pulse-agent-helper.socket") + recovered := rootfulQualWaitSummary(t, fixture, recoveryStarted, runtimeKind, baseline.SemanticDigest, 75*time.Second) + secureRuntimeAssertHelperProtocol(t) + helperRecoveryPID, _ := rootfulQualUnitIdentity(t, "pulse-agent-helper.service") + recoveryStream, recoverySequence, _ := agentshost.ParseReportSequenceID(recovered.Report.SequenceID) + if recoveryStream != lossStream || recoverySequence <= lossSequence { + t.Fatalf("helper recovery did not advance the same report stream") + } + recoveryEvidence := rootfulQualSummaryEvidence(collectorPID, helperRecoveryPID, daemonID, recovered.Report) + recoveryEvidence["previous_helper_pid"] = helperPIDAfter + recoveryEvidence["previous_status_report_sequence"] = lossSequence + appendScenario("helper_recovery", recoveryStarted, &recovered.Report, recoveryEvidence) + + boundStarted := time.Now().UTC() + rootfulQualStopDaemon(t, daemon) + rootfulQualStartHungDaemon(t, daemon) + probeElapsed := rootfulQualRunBoundProbe(t, 2*time.Second) + probeCompletedAt := time.Now().UTC() + boundStatus := rootfulQualWaitStatusOnly(t, fixture, probeCompletedAt, runtimeKind, 45*time.Second) + boundStatusStream, boundStatusSequence, _ := agentshost.ParseReportSequenceID(boundStatus.Report.SequenceID) + rootlessQualBestEffortStop(rootfulQualHungUnit(runtimeKind)) + _ = os.Remove(daemon.socket) + rootfulQualStartDaemon(t, daemon) + boundRecoveryStarted := time.Now().UTC() + boundRecovery := rootfulQualWaitSummary(t, fixture, boundRecoveryStarted, runtimeKind, baseline.SemanticDigest, 75*time.Second) + boundRecoveryStream, boundRecoverySequence, _ := agentshost.ParseReportSequenceID(boundRecovery.Report.SequenceID) + if boundRecoveryStream != boundStatusStream || boundRecoverySequence <= boundStatusSequence { + t.Fatalf("bounded operation recovery did not advance the same report stream") + } + appendScenario("operation_bounds", boundStarted, &boundRecovery.Report, map[string]any{ + "collector_pid": collectorPID, "helper_pid": helperRecoveryPID, + "operation": agenthelper.OperationContainerInventory, "failure_class": "bounded-timeout", + "collection_mode": agentsdocker.CollectionModeTypedHelperSummary, + "inventory_complete": true, "full_fields_present": false, "stats_present": false, + "secondary_structure_sha256": "", + "deadline_ms": 2000, "elapsed_ms": probeElapsed.Milliseconds(), "bounded_failure_observed": true, + "status_only_report_sequence": boundStatusSequence, "recovery_report_sequence": boundRecoverySequence, + "previous_authoritative_inventory_count": baseline.Count, + "previous_authoritative_semantic_sha256": baseline.SemanticDigest, + "recovery_inventory_count": baseline.Count, "recovery_semantic_sha256": rootlessQualSemanticDigest(boundRecovery.Report), + "authoritative_empty_replacement": false, "collector_alive": true, "helper_alive": true, + "container_updates_enabled": false, "container_actions_enabled": false, "direct_socket_access": false, + }) + + updateStarted := time.Now().UTC() + preUpdatePID := secureRuntimeCollectorMainPID(t) + preUpdateStream, _, _ := agentshost.ParseReportSequenceID(boundRecovery.Report.SequenceID) + secureRuntimeRunInstallerWithCollectorCredential(t, installerPath, server.URL, collectorCredential, "--update") + postUpdatePID := secureRuntimeCollectorMainPID(t) + updated := rootfulQualWaitSummary(t, fixture, updateStarted, runtimeKind, baseline.SemanticDigest, 90*time.Second) + postUpdateStream, _, _ := agentshost.ParseReportSequenceID(updated.Report.SequenceID) + if postUpdatePID == preUpdatePID || postUpdateStream == preUpdateStream { + t.Fatalf("ordinary update did not restart the safe collector: pid=%d/%d stream=%s/%s", preUpdatePID, postUpdatePID, preUpdateStream, postUpdateStream) + } + postUpdateHelperPID, _ := rootfulQualUnitIdentity(t, "pulse-agent-helper.service") + if postUpdateHelperPID != helperRecoveryPID { + t.Fatalf("ordinary collector update replaced the independent helper process: pid=%d/%d", helperRecoveryPID, postUpdateHelperPID) + } + updateEvidence := rootfulQualSummaryEvidence(postUpdatePID, postUpdateHelperPID, daemonID, updated.Report) + updateEvidence["previous_collector_pid"] = preUpdatePID + updateEvidence["previous_helper_pid"] = helperRecoveryPID + updateEvidence["previous_report_stream_id"] = preUpdateStream + updateEvidence["update_applied"] = true + updateEvidence["collector_binary_sha256"] = secureRuntimeHash(secureRuntimeReadFile(t, "/usr/local/bin/pulse-agent")) + updateEvidence["helper_binary_sha256"] = secureRuntimeHash(secureRuntimeReadFile(t, "/usr/local/lib/pulse-agent/pulse-agent-helper")) + appendScenario("update_preservation", updateStarted, &updated.Report, updateEvidence) + + authorityStarted := time.Now().UTC() + collectorUID := rootlessQualUID(t, "pulse-agent") + groups := strings.Fields(rootlessQualCommand(t, 10*time.Second, "id", "-nG", "pulse-agent")) + if slices.Contains(groups, "docker") || slices.Contains(groups, "podman") { + t.Fatalf("safe collector retained a rootful daemon group: %v", groups) + } + rootfulQualAssertCollectorSocketDenied(t, daemon.socket) + helperNetworkDenied := rootlessQualAssertHelperNetworkDenied(t) + commandSessionPresent := fixture.actionServer.IsAgentConnectedForOrganization(secureRuntimeLabOrgID, secureRuntimeLabAgentID) + if !helperNetworkDenied || commandSessionPresent || secureRuntimeCollectorHasArgument("--enable-commands") || secureRuntimeCollectorProcessUID(t) != collectorUID { + t.Fatal("rootful authority isolation did not remain exact") + } + appendScenario("authority_isolation", authorityStarted, nil, map[string]any{ + "collector_pid": postUpdatePID, "collector_uid": collectorUID, "effective_uid": collectorUID, + "effective_root": false, "safe_profile_enabled": true, "commands_enabled": false, + "privileged_helper_enabled": true, "reduction_request_observed": true, + "collector_command_transport_present": false, "collector_command_session_present": false, + "container_actions_enabled": false, "container_updates_enabled": false, + "rootful_socket_access": false, "direct_socket_access": false, "helper_network_access": false, + }) + + cleanupStarted := time.Now().UTC() + rootlessQualUninstallPulse(t, installerPath, server.URL, collectorCredential) + rootfulQualRemoveFixtures(t, daemon) + rootfulQualStopDaemon(t, daemon) + rootfulQualRemoveRuntimeState(t, daemon) + rootlessQualAssertPulseRemoved(t) + if registered, revoked, uninstalls := fixture.collectorLifecycleSnapshot(); registered || !revoked || uninstalls != 2 { + t.Fatalf("final collector uninstall was not durably modeled: registered=%t revoked=%t uninstalls=%d", registered, revoked, uninstalls) + } + if _, err := os.Lstat(daemon.socket); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("cleanup left rootful runtime socket %s: %v", daemon.socket, err) + } + stateClean := rootfulQualRuntimeStateClean(daemon) + if !stateClean { + t.Fatal("cleanup left rootful runtime state") + } + appendScenario("cleanup", cleanupStarted, nil, map[string]any{ + "collector_stopped": true, "helper_stopped": true, "runtime_stopped": true, + "socket_absent": true, "fixtures_removed": true, "state_clean": true, + }) + + receipt := rootfulQualReceipt{ + SchemaVersion: 1, Kind: "pulse-secure-runtime-rootful-qualification", Result: "passed", + SourceCommit: strings.TrimSpace(os.Getenv("PULSE_ROOTFUL_SOURCE_COMMIT")), + StartedAt: started.Format(time.RFC3339Nano), CompletedAt: time.Now().UTC().Format(time.RFC3339Nano), + SourceHashes: rootfulQualSourceHashes(t), Artifacts: rootfulQualArtifactIdentities(t, installerPath), + Runs: []rootfulQualRun{{ + Host: rootlessQualHost{ + MachineID: strings.TrimSpace(string(rootlessQualReadFile(t, "/etc/machine-id"))), Architecture: runtime.GOARCH, + Kernel: rootlessQualCommand(t, 10*time.Second, "uname", "-srvmo"), + SystemdVersion: strings.SplitN(rootlessQualCommand(t, 10*time.Second, "systemctl", "--version"), "\n", 2)[0], + }, + Runtime: rootfulQualRuntime{ + Runtime: runtimeKind, RuntimeVersion: runtimeVersion, DaemonID: daemonID, DaemonRootless: false, + SocketPath: daemon.socket, SocketUID: socketUID, SocketGID: socketGID, SocketMode: socketMode, + SocketType: "unix", SocketSymlink: false, + }, + Scenarios: scenarios, + }}, + } + if err := rootfulQualValidateReceipt(receipt, 1); err != nil { + t.Fatalf("generated rootful receipt failed validation: %v", err) + } + rootlessQualWriteJSON(t, receiptPath, receipt) +} + +// TestSecureRuntimeRootfulBoundProbe is invoked as the installed collector UID +// by the live qualification test. It proves the real helper and provider honor +// a caller-supplied bounded deadline against an accepted but unresponsive +// rootful daemon connection. +func TestSecureRuntimeRootfulBoundProbe(t *testing.T) { + if os.Getenv("PULSE_ROOTFUL_BOUND_PROBE") != "1" { + t.Skip("internal rootful qualification subprocess") + } + deadlineMillis, err := strconv.Atoi(os.Getenv("PULSE_ROOTFUL_BOUND_DEADLINE_MS")) + if err != nil || deadlineMillis < 1 { + t.Fatalf("invalid bound-probe deadline: %v", err) + } + deadline := time.Duration(deadlineMillis) * time.Millisecond + client, err := agenthelper.NewClient(agenthelper.ClientConfig{SocketPath: rootfulQualHelperSock, MaxDeadline: deadline}) + if err != nil { + t.Fatal(err) + } + var response agenthelper.ContainerInventoryResult + _, err = client.Call(context.Background(), agenthelper.OperationContainerInventory, agenthelper.OperationVersion1, deadline, struct{}{}, &response) + var remote *agenthelper.RemoteError + var networkError net.Error + typedDeadline := errors.As(err, &remote) && remote.Code == agenthelper.ErrorDeadlineExceeded + localDeadline := errors.As(err, &networkError) && networkError.Timeout() + if !typedDeadline && !localDeadline { + t.Fatalf("bounded helper operation error = %T %v", err, err) + } + fmt.Println("ROOTFUL_BOUND_RESULT=deadline_exceeded") +} + +func httptestNewServer(t *testing.T, handler *secureRuntimeLabFixture) *httptest.Server { + t.Helper() + return httptest.NewServer(handler) +} + +func rootfulQualRequireDisposableHost(t *testing.T, runtimeKind, receiptPath string) { + t.Helper() + if os.Geteuid() != 0 || (runtimeKind != "docker" && runtimeKind != "podman") { + t.Fatalf("qualification requires root and PULSE_ROOTFUL_RUNTIME=docker|podman") + } + marker, err := os.ReadFile(rootfulQualMarker) + if err != nil || strings.TrimSpace(string(marker)) != rootfulQualOptInValue { + t.Fatalf("disposable marker is absent or invalid: %v", err) + } + if receiptPath != rootfulQualReceiptPath { + t.Fatalf("PULSE_ROOTFUL_RECEIPT must use %q: %q", rootfulQualReceiptPath, receiptPath) + } + info, err := os.Lstat(rootfulQualResultDir) + if err != nil { + t.Fatal(err) + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm() != 0o700 || stat.Uid != 0 { + t.Fatalf("rootful result directory must be root-owned mode 0700: mode=%s stat=%#v", info.Mode(), info.Sys()) + } + osRelease := string(rootlessQualReadFile(t, "/etc/os-release")) + if !strings.Contains(osRelease, "VERSION_ID=\"24.04\"") && !strings.Contains(osRelease, "VERSION_ID=24.04") { + t.Fatal("qualification host is not Ubuntu 24.04") + } + if _, err := os.Stat("/run/systemd/system"); err != nil { + t.Fatalf("qualification host is not booted under systemd: %v", err) + } + if rootlessQualHasDefaultRoute(string(rootlessQualReadFile(t, "/proc/net/route"))) { + t.Fatal("qualification workload must run with outer-container networking disabled") + } +} + +func rootfulQualDaemonFor(runtimeKind string) rootfulQualDaemon { + if runtimeKind == "docker" { + return rootfulQualDaemon{runtime: runtimeKind, unit: "pulse-rootful-docker", socket: "/var/run/docker.sock", dataRoot: "/var/lib/pulse-rootful-docker", fixture: "/opt/pulse/rootful-fixture"} + } + return rootfulQualDaemon{runtime: runtimeKind, unit: "pulse-rootful-podman", socket: "/run/podman/podman.sock", dataRoot: "/var/lib/containers", fixture: "/opt/pulse/rootful-fixture"} +} + +func rootfulQualPrepareFixture(t *testing.T, daemon rootfulQualDaemon) { + t.Helper() + rootlessQualCommand(t, 10*time.Second, "install", "-d", "-o", "root", "-g", "root", "-m", "0700", daemon.fixture) + containerfile := "FROM scratch\nCOPY busybox /busybox\nENTRYPOINT [\"/busybox\"]\n" + if err := os.WriteFile(filepath.Join(daemon.fixture, "Containerfile"), []byte(containerfile), 0o600); err != nil { + t.Fatal(err) + } + busybox, err := os.ReadFile("/bin/busybox") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(daemon.fixture, "busybox"), busybox, 0o700); err != nil { + t.Fatal(err) + } +} + +func rootfulQualStartDaemon(t *testing.T, daemon rootfulQualDaemon) { + t.Helper() + rootlessQualBestEffortStop(daemon.unit) + _ = os.Remove(daemon.socket) + rootlessQualCommand(t, 10*time.Second, "install", "-d", "-o", "root", "-g", "root", "-m", "0755", filepath.Dir(daemon.socket)) + if daemon.runtime == "docker" { + rootlessQualCommand(t, 20*time.Second, "systemd-run", "--quiet", "--collect", "--unit", daemon.unit, "--property=Type=exec", "--", + "/usr/bin/dockerd", "--host=unix://"+daemon.socket, "--data-root="+daemon.dataRoot, + "--exec-root=/run/pulse-rootful-docker", "--pidfile=/run/pulse-rootful-docker.pid", "--storage-driver=vfs", "--iptables=false", "--bridge=none") + } else { + rootlessQualCommand(t, 20*time.Second, "systemd-run", "--quiet", "--collect", "--unit", daemon.unit, "--property=Type=exec", "--", + "/usr/bin/podman", "system", "service", "--time=0", "unix://"+daemon.socket) + } + rootlessQualWaitSocket(t, daemon.socket) + rootlessQualCommand(t, 10*time.Second, "chmod", "0660", daemon.socket) + rootfulQualRuntimeCommand(t, daemon, 30*time.Second, "info") +} + +func rootfulQualStopDaemon(t *testing.T, daemon rootfulQualDaemon) { + t.Helper() + rootlessQualStopUnit(t, daemon.unit) + _ = os.Remove(daemon.socket) +} + +func rootfulQualRuntimeCommand(t *testing.T, daemon rootfulQualDaemon, timeout time.Duration, args ...string) string { + t.Helper() + if daemon.runtime == "docker" { + return rootlessQualCommand(t, timeout, "docker", append([]string{"--host", "unix://" + daemon.socket}, args...)...) + } + return rootlessQualCommand(t, timeout, "podman", append([]string{"--url", "unix://" + daemon.socket}, args...)...) +} + +func rootfulQualCreateFixtures(t *testing.T, daemon rootfulQualDaemon) { + t.Helper() + rootfulQualRuntimeCommand(t, daemon, 2*time.Minute, "build", "--network=none", "-t", rootfulQualFixture, "-f", filepath.Join(daemon.fixture, "Containerfile"), daemon.fixture) + rootfulQualRuntimeCommand(t, daemon, 30*time.Second, "run", "-d", "--restart=always", "--name", rootfulQualRunningName, rootfulQualFixture, "sleep", "3600") + if out, err := rootfulQualRuntimeCommandError(daemon, 30*time.Second, "run", "--name", rootfulQualExitedName, rootfulQualFixture, "true"); err != nil { + t.Fatalf("create exited fixture: %v\n%s", err, out) + } +} + +func rootfulQualRemoveFixtures(t *testing.T, daemon rootfulQualDaemon) { + t.Helper() + for _, name := range []string{rootfulQualRunningName, rootfulQualExitedName} { + _, _ = rootfulQualRuntimeCommandError(daemon, 30*time.Second, "rm", "-f", name) + } + _, _ = rootfulQualRuntimeCommandError(daemon, 30*time.Second, "rmi", "-f", rootfulQualFixture) +} + +func rootfulQualRuntimeCommandError(daemon rootfulQualDaemon, timeout time.Duration, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + name := "podman" + prefix := []string{"--url", "unix://" + daemon.socket} + if daemon.runtime == "docker" { + name = "docker" + prefix = []string{"--host", "unix://" + daemon.socket} + } + output, err := exec.CommandContext(ctx, name, append(prefix, args...)...).CombinedOutput() + return strings.TrimSpace(string(output)), err +} + +func rootfulQualRuntimeBaseline(t *testing.T, daemon rootfulQualDaemon) rootlessQualBaseline { + t.Helper() + return rootlessQualBaselineFromPSOutput(rootfulQualRuntimeCommand(t, daemon, 30*time.Second, "ps", "-a", "--format", "{{.Names}}|{{.Image}}|{{.State}}")) +} + +func rootfulQualRuntimeVersion(t *testing.T, daemon rootfulQualDaemon) string { + t.Helper() + return rootfulQualRuntimeCommand(t, daemon, 30*time.Second, "version", "--format", "{{.Server.Version}}") +} + +func rootfulQualDaemonID(t *testing.T, daemon rootfulQualDaemon) string { + t.Helper() + d := rootlessQualDaemon{runtime: daemon.runtime, rootfulSock: daemon.socket} + return rootlessQualDaemonID(t, d, false) +} + +func rootfulQualSocketIdentity(t *testing.T, path string) (int, int, string) { + t.Helper() + info, err := os.Lstat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode()&os.ModeSocket == 0 || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm() != 0o660 { + t.Fatalf("unsafe rootful runtime socket %s mode=%s", path, info.Mode()) + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + t.Fatalf("socket %s lacks Unix stat identity", path) + } + return int(stat.Uid), int(stat.Gid), fmt.Sprintf("%04o", info.Mode().Perm()) +} + +func rootfulQualWaitSummary(t *testing.T, fixture *secureRuntimeLabFixture, after time.Time, runtimeKind, digest string, timeout time.Duration) secureRuntimeDockerReport { + t.Helper() + report := rootlessQualWaitReport(t, fixture, after, timeout, func(report agentsdocker.Report) bool { + return rootlessQualComplete(report) && report.InventoryComplete != nil && *report.InventoryComplete && + report.Host.CollectionMode == agentsdocker.CollectionModeTypedHelperSummary && report.Host.Runtime == runtimeKind && + rootlessQualSemanticDigest(report) == digest && len(report.Containers) > 0 + }) + rootlessQualAssertHelperSummaryOnly(t, report.Report, runtimeKind) + return report +} + +func rootfulQualWaitStatusOnly(t *testing.T, fixture *secureRuntimeLabFixture, after time.Time, runtimeKind string, timeout time.Duration) secureRuntimeDockerReport { + t.Helper() + return rootlessQualWaitReport(t, fixture, after, timeout, func(report agentsdocker.Report) bool { + return report.InventoryComplete != nil && !*report.InventoryComplete && report.Host.Runtime == runtimeKind && + report.Host.CollectionMode == agentsdocker.CollectionModeTypedHelperSummary && len(report.Containers) == 0 && + secureRuntimeDockerHelperModuleState(report) == "degraded" + }) +} + +func rootfulQualSummaryEvidence(collectorPID, helperPID int, daemonID string, report agentsdocker.Report) map[string]any { + digest := rootlessQualDigestReport(report) + return map[string]any{ + "collector_pid": collectorPID, "helper_pid": helperPID, + "collection_mode": agentsdocker.CollectionModeTypedHelperSummary, + "inventory_complete": true, "inventory_count": digest.Count, + "semantic_sha256": digest.SemanticDigest, "full_fields_present": false, + "stats_present": false, "secondary_structure_sha256": "", + "container_updates_enabled": false, "container_actions_enabled": false, + "direct_socket_access": false, "daemon_id": daemonID, "daemon_rootless": false, + } +} + +func rootfulQualUnitIdentity(t *testing.T, unit string) (int, string) { + t.Helper() + pidText := rootlessQualCommand(t, 10*time.Second, "systemctl", "show", unit, "--property=MainPID", "--value") + pid, err := strconv.Atoi(pidText) + if err != nil || pid <= 0 { + t.Fatalf("invalid %s MainPID %q", unit, pidText) + } + invocation := rootlessQualCommand(t, 10*time.Second, "systemctl", "show", unit, "--property=InvocationID", "--value") + if len(invocation) != 32 { + t.Fatalf("invalid %s InvocationID %q", unit, invocation) + } + return pid, invocation +} + +func rootfulQualAssertCollectorSocketDenied(t *testing.T, socket string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + command := exec.CommandContext(ctx, "runuser", "-u", "pulse-agent", "--", "curl", "-fsS", "--max-time", "2", "--unix-socket", socket, "http://runtime/_ping") + if output, err := command.CombinedOutput(); err == nil { + t.Fatalf("safe collector unexpectedly reached rootful socket %s: %s", socket, strings.TrimSpace(string(output))) + } +} + +func rootfulQualHungUnit(runtimeKind string) string { return "pulse-rootful-" + runtimeKind + "-hung" } + +func rootfulQualStartHungDaemon(t *testing.T, daemon rootfulQualDaemon) { + t.Helper() + scriptPath := filepath.Join(daemon.fixture, "hung-runtime.py") + script := `import os, socket, threading, time, sys +path = sys.argv[1] +try: os.unlink(path) +except FileNotFoundError: pass +s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +s.bind(path) +os.chmod(path, 0o660) +s.listen(64) +def hold(c): + try: time.sleep(120) + finally: c.close() +while True: + c, _ = s.accept() + threading.Thread(target=hold, args=(c,), daemon=True).start() +` + if err := os.WriteFile(scriptPath, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + rootlessQualCommand(t, 20*time.Second, "systemd-run", "--quiet", "--collect", "--unit", rootfulQualHungUnit(daemon.runtime), "--property=Type=exec", "--", + "/usr/bin/python3", scriptPath, daemon.socket) + rootlessQualWaitSocket(t, daemon.socket) +} + +func rootfulQualRunBoundProbe(t *testing.T, deadline time.Duration) time.Duration { + t.Helper() + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), deadline+3*time.Second) + defer cancel() + started := time.Now() + cmd := exec.CommandContext(ctx, "runuser", "-u", "pulse-agent", "--", "env", + "PULSE_ROOTFUL_BOUND_PROBE=1", fmt.Sprintf("PULSE_ROOTFUL_BOUND_DEADLINE_MS=%d", deadline.Milliseconds()), + executable, "-test.run", "^TestSecureRuntimeRootfulBoundProbe$", "-test.count=1", "-test.v", "-test.timeout=10s") + output, err := cmd.CombinedOutput() + elapsed := time.Since(started) + if err != nil || !strings.Contains(string(output), "ROOTFUL_BOUND_RESULT=deadline_exceeded") { + t.Fatalf("bounded helper probe failed after %s: %v\n%s", elapsed, err, output) + } + if elapsed < deadline/2 || elapsed > deadline+time.Second { + t.Fatalf("bounded helper probe elapsed %s outside expected interval", elapsed) + } + return elapsed +} + +func rootfulQualRemoveRuntimeState(t *testing.T, daemon rootfulQualDaemon) { + t.Helper() + roots := []string{daemon.dataRoot, "/run/pulse-rootful-docker"} + deadline := time.Now().Add(30 * time.Second) + for { + mountInfo := string(rootlessQualReadFile(t, "/proc/self/mountinfo")) + remaining, err := rootlessQualMountPointsBelow(mountInfo, roots) + if err != nil { + t.Fatalf("inspect disposable runtime mounts: %v", err) + } + if len(remaining) == 0 { + break + } + if time.Now().After(deadline) { + t.Fatalf("runtime mounts remain after service shutdown: %q", remaining) + } + time.Sleep(100 * time.Millisecond) + } + for _, path := range []string{daemon.dataRoot, "/run/pulse-rootful-docker", daemon.fixture} { + if err := os.RemoveAll(path); err != nil { + t.Fatalf("remove disposable runtime path %s: %v", path, err) + } + } +} + +func rootfulQualRuntimeStateClean(daemon rootfulQualDaemon) bool { + for _, path := range []string{daemon.socket, daemon.dataRoot, "/run/pulse-rootful-docker", daemon.fixture} { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + return false + } + } + return true +} + +func rootfulQualArtifactIdentities(t *testing.T, installerPath string) rootlessQualArtifacts { + t.Helper() + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + return rootlessQualArtifacts{ + QualificationTest: rootfulQualGoArtifact(t, executable, "dockeragent.test"), + Collector: rootfulQualGoArtifact(t, strings.TrimSpace(os.Getenv("PULSE_SECURE_RUNTIME_COLLECTOR")), "pulse-agent"), + Helper: rootfulQualGoArtifact(t, strings.TrimSpace(os.Getenv("PULSE_SECURE_RUNTIME_HELPER")), "pulse-agent-helper"), + Installer: rootlessQualInstallerArtifact{PathBasename: filepath.Base(installerPath), SHA256: secureRuntimeHash(rootlessQualReadFile(t, installerPath))}, + } +} + +func rootfulQualGoArtifact(t *testing.T, path, basename string) rootlessQualArtifact { + t.Helper() + info, err := buildinfo.ReadFile(path) + if err != nil { + t.Fatalf("read Go build metadata for %s: %v", path, err) + } + artifact := rootlessQualArtifact{PathBasename: filepath.Base(path), SHA256: secureRuntimeHash(rootlessQualReadFile(t, path)), Package: info.Path, GoVersion: info.GoVersion} + for _, setting := range info.Settings { + switch setting.Key { + case "vcs.revision": + artifact.VCSRevision = setting.Value + case "vcs.modified": + artifact.VCSModified = setting.Value == "true" + } + } + wantCommit := strings.TrimSpace(os.Getenv("PULSE_ROOTFUL_SOURCE_COMMIT")) + if artifact.PathBasename != basename || artifact.Package == "" || artifact.VCSRevision != wantCommit || artifact.VCSModified { + t.Fatalf("rootful qualification artifact is not an exact clean source build: %+v", artifact) + } + return artifact +} + +func rootfulQualSourceHashes(t *testing.T) map[string]string { + t.Helper() + path := strings.TrimSpace(os.Getenv("PULSE_ROOTFUL_SOURCE_HASHES")) + if !filepath.IsAbs(path) { + t.Fatalf("PULSE_ROOTFUL_SOURCE_HASHES must be absolute: %q", path) + } + var hashes map[string]string + if err := json.Unmarshal(rootlessQualReadFile(t, path), &hashes); err != nil { + t.Fatal(err) + } + if len(hashes) == 0 { + t.Fatal("rootful source hash map is empty") + } + return hashes +} + +func rootfulQualValidateReceipt(receipt rootfulQualReceipt, expectedRuns int) error { + if receipt.SchemaVersion != 1 || receipt.Kind != "pulse-secure-runtime-rootful-qualification" || receipt.Result != "passed" { + return errors.New("invalid rootful qualification identity") + } + if len(receipt.SourceCommit) != 40 || receipt.StartedAt == "" || receipt.CompletedAt == "" || len(receipt.SourceHashes) == 0 || len(receipt.Runs) != expectedRuns { + return errors.New("incomplete rootful qualification envelope") + } + for _, run := range receipt.Runs { + if run.Runtime.Runtime != "docker" && run.Runtime.Runtime != "podman" { + return fmt.Errorf("unsupported runtime %q", run.Runtime.Runtime) + } + if run.Runtime.DaemonRootless || run.Runtime.DaemonID == "" || run.Runtime.SocketUID != 0 || run.Runtime.SocketPath == "" || run.Runtime.SocketMode != "0660" || run.Runtime.SocketType != "unix" || run.Runtime.SocketSymlink { + return errors.New("invalid rootful runtime identity") + } + if run.Host.MachineID == "" || len(run.Scenarios) != len(rootfulQualScenarioOrder) { + return errors.New("incomplete rootful host/scenario evidence") + } + for index, scenario := range run.Scenarios { + if scenario.Name != rootfulQualScenarioOrder[index] || scenario.Result != "passed" || scenario.StartedAt == "" || scenario.CompletedAt == "" || scenario.Evidence == nil { + return fmt.Errorf("invalid scenario %d", index) + } + isReporting := scenario.Name != "authority_isolation" && scenario.Name != "cleanup" + if isReporting != (scenario.ReportSequence != nil && scenario.ReportStreamID != nil) { + return fmt.Errorf("scenario %s report binding mismatch", scenario.Name) + } + } + } + return nil +} + +func TestRootfulQualificationReceiptContract(t *testing.T) { + stream := "stream" + sequence := uint64(1) + scenarios := make([]rootlessQualScenario, 0, len(rootfulQualScenarioOrder)) + for _, name := range rootfulQualScenarioOrder { + scenario := rootlessQualScenario{Name: name, Result: "passed", StartedAt: time.Now().UTC().Format(time.RFC3339Nano), CompletedAt: time.Now().UTC().Format(time.RFC3339Nano), Evidence: map[string]any{"observed": true}} + if name != "authority_isolation" && name != "cleanup" { + scenario.ReportStreamID = &stream + scenario.ReportSequence = &sequence + } + scenarios = append(scenarios, scenario) + } + receipt := rootfulQualReceipt{ + SchemaVersion: 1, Kind: "pulse-secure-runtime-rootful-qualification", Result: "passed", + SourceCommit: strings.Repeat("a", 40), StartedAt: time.Now().UTC().Format(time.RFC3339Nano), CompletedAt: time.Now().UTC().Format(time.RFC3339Nano), + SourceHashes: map[string]string{"go.mod": strings.Repeat("b", 64)}, + Runs: []rootfulQualRun{{Host: rootlessQualHost{MachineID: strings.Repeat("1", 32)}, Runtime: rootfulQualRuntime{Runtime: "docker", RuntimeVersion: "1", DaemonID: "daemon", SocketPath: "/var/run/docker.sock", SocketUID: 0, SocketGID: 999, SocketMode: "0660", SocketType: "unix"}, Scenarios: scenarios}}, + } + if err := rootfulQualValidateReceipt(receipt, 1); err != nil { + t.Fatal(err) + } + receipt.Runs[0].Scenarios[3], receipt.Runs[0].Scenarios[4] = receipt.Runs[0].Scenarios[4], receipt.Runs[0].Scenarios[3] + if err := rootfulQualValidateReceipt(receipt, 1); err == nil { + t.Fatal("validator accepted reordered rootful scenarios") + } +} + +func TestRootfulQualificationGoSchemaPassesPythonValidator(t *testing.T) { + commit := strings.Repeat("a", 40) + digest := strings.Repeat("b", 64) + started := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC) + receipt := rootfulQualReceipt{ + SchemaVersion: 1, Kind: "pulse-secure-runtime-rootful-qualification", Result: "passed", + SourceCommit: commit, StartedAt: started.Format(time.RFC3339Nano), + CompletedAt: started.Add(2 * time.Minute).Format(time.RFC3339Nano), + SourceHashes: map[string]string{"internal/agenthelper/container_inventory.go": digest, "scripts/install.sh": digest}, + Artifacts: rootlessQualArtifacts{ + QualificationTest: rootlessQualArtifact{PathBasename: "dockeragent.test", SHA256: digest, Package: "github.com/rcourtman/pulse-go-rewrite/scripts/installtests.test", GoVersion: "go1.25.0", VCSRevision: commit}, + Collector: rootlessQualArtifact{PathBasename: "pulse-agent", SHA256: digest, Package: "github.com/rcourtman/pulse-go-rewrite/cmd/pulse-agent", GoVersion: "go1.25.0", VCSRevision: commit}, + Helper: rootlessQualArtifact{PathBasename: "pulse-agent-helper", SHA256: digest, Package: "github.com/rcourtman/pulse-go-rewrite/cmd/pulse-agent-helper", GoVersion: "go1.25.0", VCSRevision: commit}, + Installer: rootlessQualInstallerArtifact{PathBasename: "install.sh", SHA256: digest}, + }, + } + for index, runtimeKind := range []string{"docker", "podman"} { + receipt.Runs = append(receipt.Runs, rootfulQualValidatorFixtureRun(runtimeKind, index, started.Add(time.Duration(index)*30*time.Second), digest)) + } + path := filepath.Join(t.TempDir(), "receipt.json") + rootlessQualWriteJSON(t, path, receipt) + validator := repoFile("scripts", "release_control", "secure_runtime_rootful_attestation_v1.py") + program := `import importlib.util, pathlib, sys +path=pathlib.Path(sys.argv[1]).resolve() +sys.path.insert(0, str(path.parent)) +spec=importlib.util.spec_from_file_location("validator", path) +module=importlib.util.module_from_spec(spec); spec.loader.exec_module(module) +module.parse_receipt_bytes(pathlib.Path(sys.argv[2]).read_bytes()) +` + cmd := exec.Command("python3", "-I", "-c", program, validator, path) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("Go rootful receipt schema failed the real Python validator: %v\n%s", err, output) + } +} + +func rootfulQualValidatorFixtureRun(runtimeKind string, index int, began time.Time, digest string) rootfulQualRun { + base := index * 100 + daemonID := runtimeKind + "-daemon" + socketPath := "/var/run/docker.sock" + socketGID := 999 + if runtimeKind == "podman" { + socketPath = "/run/podman/podman.sock" + socketGID = 0 + } + stream := func(value string) *string { return &value } + sequence := func(value uint64) *uint64 { return &value } + makeScenario := func(offset int, name string, streamID *string, seq *uint64, evidence map[string]any) rootlessQualScenario { + start := began.Add(time.Duration(offset) * time.Second) + return rootlessQualScenario{ + Name: name, Result: "passed", StartedAt: start.Format(time.RFC3339Nano), + CompletedAt: start.Add(time.Second).Format(time.RFC3339Nano), + ReportStreamID: streamID, ReportSequence: seq, Evidence: evidence, + } + } + summary := func(collectorPID, helperPID int) map[string]any { + return map[string]any{ + "collector_pid": collectorPID, "helper_pid": helperPID, + "collection_mode": "typed-helper-summary", "inventory_complete": true, + "inventory_count": 2, "semantic_sha256": digest, "full_fields_present": false, + "stats_present": false, "secondary_structure_sha256": "", + "container_updates_enabled": false, "container_actions_enabled": false, + "direct_socket_access": false, "daemon_id": daemonID, "daemon_rootless": false, + } + } + + fresh := summary(base+100, base+200) + migration := summary(base+110, base+210) + migration["legacy_profile"] = "root-command-capable" + migration["target_profile"] = "typed-helper-monitoring-only" + migration["authority_reduced"] = true + migration["legacy_collector_pid"] = base + 90 + collectorRestart := summary(base+120, base+210) + collectorRestart["previous_collector_pid"] = base + 110 + collectorRestart["previous_report_stream_id"] = runtimeKind + "-migration" + helperRestart := summary(base+120, base+220) + helperRestart["previous_helper_pid"] = base + 210 + helperRestart["previous_helper_invocation_id"] = runtimeKind + "-helper-old" + helperRestart["helper_invocation_id"] = runtimeKind + "-helper-new" + loss := map[string]any{ + "collector_pid": base + 120, "previous_helper_pid": base + 220, + "collection_mode": "typed-helper-unavailable-status-only", "helper_available": false, + "status_only": true, "inventory_complete": false, "inventory_present": false, + "authoritative_inventory_replacement": false, + "previous_authoritative_inventory_count": 2, "previous_authoritative_semantic_sha256": digest, + "operation_status": "degraded", "operation": "container.inventory", + "container_updates_enabled": false, "container_actions_enabled": false, "direct_socket_access": false, + } + recovery := summary(base+120, base+230) + recovery["previous_helper_pid"] = base + 220 + recovery["previous_status_report_sequence"] = uint64(3) + bounds := map[string]any{ + "collector_pid": base + 120, "helper_pid": base + 230, + "operation": "container.inventory", "failure_class": "bounded-timeout", + "deadline_ms": 2000, "elapsed_ms": 2000, "bounded_failure_observed": true, + "status_only_report_sequence": uint64(5), "recovery_report_sequence": uint64(6), + "collection_mode": "typed-helper-summary", "inventory_complete": true, + "previous_authoritative_inventory_count": 2, "previous_authoritative_semantic_sha256": digest, + "recovery_inventory_count": 2, "recovery_semantic_sha256": digest, + "full_fields_present": false, "stats_present": false, "secondary_structure_sha256": "", + "authoritative_empty_replacement": false, "collector_alive": true, "helper_alive": true, + "container_updates_enabled": false, "container_actions_enabled": false, "direct_socket_access": false, + } + update := summary(base+130, base+230) + update["previous_collector_pid"] = base + 120 + update["previous_helper_pid"] = base + 230 + update["previous_report_stream_id"] = runtimeKind + "-steady" + update["update_applied"] = true + update["collector_binary_sha256"] = digest + update["helper_binary_sha256"] = digest + authority := map[string]any{ + "collector_pid": base + 130, "collector_uid": 1000 + index, "effective_uid": 1000 + index, + "effective_root": false, "safe_profile_enabled": true, "commands_enabled": false, + "privileged_helper_enabled": true, "reduction_request_observed": true, + "collector_command_transport_present": false, "collector_command_session_present": false, + "container_actions_enabled": false, "container_updates_enabled": false, + "rootful_socket_access": false, "direct_socket_access": false, "helper_network_access": false, + } + + return rootfulQualRun{ + Host: rootlessQualHost{ + MachineID: "machine-" + runtimeKind, Architecture: "amd64", + Kernel: "Linux fixture", SystemdVersion: "systemd 255", + }, + Runtime: rootfulQualRuntime{ + Runtime: runtimeKind, RuntimeVersion: "1.0.0", DaemonID: daemonID, + DaemonRootless: false, SocketPath: socketPath, SocketUID: 0, SocketGID: socketGID, + SocketMode: "0660", SocketType: "unix", SocketSymlink: false, + }, + Scenarios: []rootlessQualScenario{ + makeScenario(0, "fresh_install", stream(runtimeKind+"-fresh"), sequence(1), fresh), + makeScenario(2, "legacy_migration", stream(runtimeKind+"-migration"), sequence(1), migration), + makeScenario(4, "collector_restart", stream(runtimeKind+"-steady"), sequence(1), collectorRestart), + makeScenario(6, "helper_restart", stream(runtimeKind+"-steady"), sequence(2), helperRestart), + makeScenario(8, "helper_loss", stream(runtimeKind+"-steady"), sequence(3), loss), + makeScenario(10, "helper_recovery", stream(runtimeKind+"-steady"), sequence(4), recovery), + makeScenario(12, "operation_bounds", stream(runtimeKind+"-steady"), sequence(6), bounds), + makeScenario(14, "update_preservation", stream(runtimeKind+"-update"), sequence(1), update), + makeScenario(16, "authority_isolation", nil, nil, authority), + makeScenario(18, "cleanup", nil, nil, map[string]any{ + "collector_stopped": true, "helper_stopped": true, "runtime_stopped": true, + "socket_absent": true, "fixtures_removed": true, "state_clean": true, + }), + }, + } +} + +func TestRootfulQualificationWrapperInvariants(t *testing.T) { + raw, err := os.ReadFile(repoFile("scripts", "run-secure-runtime-rootful-qualification.sh")) + if err != nil { + t.Fatal(err) + } + script := string(raw) + for _, required := range []string{ + "pulse-secure-runtime-rootful-qualification", "PULSE_ROOTFUL_QUALIFICATION_CONFIRM", + "--network none", "--cgroupns=private", "docker-receipt.json", "podman-receipt.json", + "secure_runtime_rootful_attestation_v1.py", "qualification output directory must have exact mode 0700", + "capture_qualification_container_diagnostics", "journalctl --no-pager -n 2000", + "org.pulse.rootful-qualification.run", "-buildvcs=true", + "github.com/rcourtman/pulse-go-rewrite/scripts/installtests.test", + } { + if !strings.Contains(script, required) { + t.Fatalf("rootful qualification wrapper missing %q", required) + } + } + if count := strings.Count(script, "-buildvcs=true"); count != 3 { + t.Fatalf("rootful wrapper must require VCS metadata for exactly three Go artifacts: got %d", count) + } + for _, forbidden := range []string{"/var/run/docker.sock:/", "/run/docker.sock:/", "/run/podman/podman.sock:/", "--pid=host", "--cgroupns=host"} { + if strings.Contains(script, forbidden) { + t.Fatalf("rootful qualification wrapper contains forbidden host boundary %q", forbidden) + } + } +} diff --git a/scripts/release_control/secure_runtime_rootful_attestation_v1.py b/scripts/release_control/secure_runtime_rootful_attestation_v1.py new file mode 100644 index 000000000..e5f977eaa --- /dev/null +++ b/scripts/release_control/secure_runtime_rootful_attestation_v1.py @@ -0,0 +1,568 @@ +#!/usr/bin/env python3 +"""Validate the secret-free opt-in rootful runtime qualification receipt. + +This validator qualifies only local, artifact-bound Docker and Podman +typed-helper summary collection. It does not qualify a published release, +authorize the safe profile as the default, or substitute for external review. +""" + +from __future__ import annotations + +import argparse +import contextlib +import hashlib +import json +import re +import stat +import sys +from datetime import datetime +from pathlib import Path +from typing import Any + +import secure_runtime_rootless_attestation_v1 as hardened + + +RECEIPT_SCHEMA_VERSION = 1 +ATTESTATION_SCHEMA_VERSION = 1 +RECEIPT_KIND = "pulse-secure-runtime-rootful-qualification" +ATTESTATION_KIND = "pulse-secure-runtime-rootful-receipt-attestation" +CLASSIFICATION = "local-opt-in-rootful-runtime-artifact-bound-self-attestation" +SOURCE_MANIFEST_SCHEMA_VERSION = 1 +SOURCE_MANIFEST_ID = "secure-runtime-rootful-v1" +SOURCE_MANIFEST_PATH = "scripts/release_control/secure_runtime_rootful_source_manifest_v1.json" +MAX_RECEIPT_BYTES = hardened.MAX_RECEIPT_BYTES + +REQUIRED_RUNTIMES = ("docker", "podman") +REQUIRED_SCENARIOS = ( + "fresh_install", + "legacy_migration", + "collector_restart", + "helper_restart", + "helper_loss", + "helper_recovery", + "operation_bounds", + "update_preservation", + "authority_isolation", + "cleanup", +) + +ValidationError = hardened.ValidationError +fail = hardened.fail +require = hardened.require +require_object = hardened.require_object +require_bool = hardened.require_bool +require_int = hardened.require_int +require_text = hardened.require_text +require_digest = hardened.require_digest +parse_timestamp = hardened.parse_timestamp +reject_sensitive_evidence = hardened.reject_sensitive_evidence +read_immutable_receipt = hardened.read_immutable_receipt +immutable_artifact_snapshot = hardened.immutable_artifact_snapshot +inspect_go_build_identity = hardened.inspect_go_build_identity +verify_source_commit = hardened.verify_source_commit +IDENTITY_RE = hardened.IDENTITY_RE +VERSION_RE = hardened.VERSION_RE +GO_VERSION_RE = hardened.GO_VERSION_RE +COMMIT_RE = hardened.COMMIT_RE + + +SUMMARY_KEYS = { + "collector_pid", + "helper_pid", + "collection_mode", + "inventory_complete", + "inventory_count", + "semantic_sha256", + "full_fields_present", + "stats_present", + "secondary_structure_sha256", + "container_updates_enabled", + "container_actions_enabled", + "direct_socket_access", + "daemon_id", + "daemon_rootless", +} + + +def expected_socket_path(runtime: str) -> str: + return "/var/run/docker.sock" if runtime == "docker" else "/run/podman/podman.sock" + + +def validate_socket_mode(value: Any, label: str) -> str: + mode = require_text(value, label, pattern=re.compile(r"^0[0-7]{3}$"), maximum=4) + numeric = int(mode, 8) + require(numeric & 0o600 == 0o600, f"{label} must grant owner read and write") + require(numeric & 0o006 == 0, f"{label} must not grant world read or write") + return mode + + +def validate_summary( + value: Any, + label: str, + runtime: dict[str, Any], + *, + extra_keys: set[str] | None = None, + expected_collector_pid: int | None = None, + expected_helper_pid: int | None = None, +) -> dict[str, Any]: + item = require_object(value, label, SUMMARY_KEYS | (extra_keys or set())) + collector_pid = require_int(item["collector_pid"], f"{label}.collector_pid", minimum=2) + helper_pid = require_int(item["helper_pid"], f"{label}.helper_pid", minimum=2) + if expected_collector_pid is not None: + require(collector_pid == expected_collector_pid, f"{label} unexpectedly changed collector PID") + if expected_helper_pid is not None: + require(helper_pid == expected_helper_pid, f"{label} unexpectedly changed helper PID") + require(item["collection_mode"] == "typed-helper-summary", f"{label}.collection_mode differs") + require_bool(item["inventory_complete"], True, f"{label}.inventory_complete") + require_int(item["inventory_count"], f"{label}.inventory_count", minimum=1) + require_digest(item["semantic_sha256"], f"{label}.semantic_sha256") + require_bool(item["full_fields_present"], False, f"{label}.full_fields_present") + require_bool(item["stats_present"], False, f"{label}.stats_present") + require(item["secondary_structure_sha256"] == "", f"{label}.secondary_structure_sha256 must be empty") + for name in ("container_updates_enabled", "container_actions_enabled", "direct_socket_access"): + require_bool(item[name], False, f"{label}.{name}") + require(item["daemon_id"] == runtime["daemon_id"], f"{label}.daemon_id differs") + require_bool(item["daemon_rootless"], False, f"{label}.daemon_rootless") + return item + + +def require_summary_continuity(baseline: dict[str, Any], current: dict[str, Any], label: str) -> None: + require(current["inventory_count"] == baseline["inventory_count"], f"{label} inventory count differs") + require(current["semantic_sha256"] == baseline["semantic_sha256"], f"{label} semantic digest differs") + + +def validate_scenario( + value: Any, + expected_name: str, + label: str, + receipt_start: datetime, + receipt_end: datetime, +) -> tuple[dict[str, Any], datetime, datetime]: + item = require_object( + value, + label, + {"name", "result", "started_at", "completed_at", "report_stream_id", "report_sequence", "evidence"}, + ) + require(item["name"] == expected_name, f"{label}.name must be {expected_name}") + require(item["result"] == "passed", f"{label}.result must be passed") + started = parse_timestamp(item["started_at"], f"{label}.started_at") + completed = parse_timestamp(item["completed_at"], f"{label}.completed_at") + require(receipt_start <= started < completed <= receipt_end, f"{label} chronology is outside the receipt window") + if expected_name in {"authority_isolation", "cleanup"}: + require(item["report_stream_id"] is None, f"{label}.report_stream_id must be null") + require(item["report_sequence"] is None, f"{label}.report_sequence must be null") + else: + require_text(item["report_stream_id"], f"{label}.report_stream_id", pattern=IDENTITY_RE) + require_int(item["report_sequence"], f"{label}.report_sequence", minimum=1) + return item, started, completed + + +def validate_run(run_value: Any, expected_runtime: str, index: int, receipt_start: datetime, receipt_end: datetime) -> dict[str, Any]: + label = f"runs[{index}]" + run = require_object(run_value, label, {"host", "runtime", "scenarios"}) + host = require_object(run["host"], f"{label}.host", {"machine_id", "architecture", "kernel", "systemd_version"}) + require_text(host["machine_id"], f"{label}.host.machine_id", pattern=IDENTITY_RE) + require(host["architecture"] in {"amd64", "arm64"}, f"{label}.host.architecture is unsupported") + require_text(host["kernel"], f"{label}.host.kernel", maximum=128) + require_text(host["systemd_version"], f"{label}.host.systemd_version", maximum=128) + + runtime = require_object( + run["runtime"], + f"{label}.runtime", + {"runtime", "runtime_version", "daemon_id", "daemon_rootless", "socket_path", "socket_uid", "socket_gid", "socket_mode", "socket_type", "socket_symlink"}, + ) + require(runtime["runtime"] == expected_runtime, f"{label}.runtime.runtime must be {expected_runtime}") + require_text(runtime["runtime_version"], f"{label}.runtime.runtime_version", pattern=VERSION_RE, maximum=128) + require_text(runtime["daemon_id"], f"{label}.runtime.daemon_id", pattern=IDENTITY_RE) + require_bool(runtime["daemon_rootless"], False, f"{label}.runtime.daemon_rootless") + require(runtime["socket_path"] == expected_socket_path(expected_runtime), f"{label}.runtime.socket_path differs") + require_int(runtime["socket_uid"], f"{label}.runtime.socket_uid") + require(runtime["socket_uid"] == 0, f"{label}.runtime.socket_uid must be root") + require_int(runtime["socket_gid"], f"{label}.runtime.socket_gid") + validate_socket_mode(runtime["socket_mode"], f"{label}.runtime.socket_mode") + require(runtime["socket_type"] == "unix", f"{label}.runtime.socket_type must be unix") + require_bool(runtime["socket_symlink"], False, f"{label}.runtime.socket_symlink") + + scenarios = run["scenarios"] + require(isinstance(scenarios, list) and len(scenarios) == len(REQUIRED_SCENARIOS), f"{label}.scenarios must contain exactly ten scenarios") + parsed: dict[str, dict[str, Any]] = {} + previous_completed: datetime | None = None + for scenario_index, name in enumerate(REQUIRED_SCENARIOS): + scenario, started, completed = validate_scenario( + scenarios[scenario_index], name, f"{label}.scenarios[{scenario_index}]", receipt_start, receipt_end + ) + if previous_completed is not None: + require(previous_completed <= started, f"{label}.scenarios are not chronological") + previous_completed = completed + parsed[name] = scenario + + fresh = validate_summary(parsed["fresh_install"]["evidence"], f"{label}.fresh_install", runtime) + + migration = validate_summary( + parsed["legacy_migration"]["evidence"], + f"{label}.legacy_migration", + runtime, + extra_keys={"legacy_profile", "target_profile", "authority_reduced", "legacy_collector_pid"}, + ) + require(migration["legacy_profile"] == "root-command-capable", f"{label}.legacy_migration legacy profile differs") + require(migration["target_profile"] == "typed-helper-monitoring-only", f"{label}.legacy_migration target profile differs") + require_bool(migration["authority_reduced"], True, f"{label}.legacy_migration.authority_reduced") + legacy_pid = require_int(migration["legacy_collector_pid"], f"{label}.legacy_migration.legacy_collector_pid", minimum=2) + require(migration["collector_pid"] != legacy_pid, f"{label}.legacy_migration retained legacy collector PID") + require_summary_continuity(fresh, migration, f"{label}.legacy_migration") + + collector_restart = validate_summary( + parsed["collector_restart"]["evidence"], + f"{label}.collector_restart", + runtime, + extra_keys={"previous_collector_pid", "previous_report_stream_id"}, + ) + require(collector_restart["previous_collector_pid"] == migration["collector_pid"], f"{label}.collector_restart predecessor PID differs") + require(collector_restart["collector_pid"] != migration["collector_pid"], f"{label}.collector_restart did not change PID") + require(collector_restart["previous_report_stream_id"] == parsed["legacy_migration"]["report_stream_id"], f"{label}.collector_restart predecessor stream differs") + require_summary_continuity(fresh, collector_restart, f"{label}.collector_restart") + + install_streams = [parsed[name]["report_stream_id"] for name in ("fresh_install", "legacy_migration", "collector_restart")] + require(len(set(install_streams)) == 3, f"{label} install and collector restart report streams must be distinct") + + helper_restart = validate_summary( + parsed["helper_restart"]["evidence"], + f"{label}.helper_restart", + runtime, + extra_keys={"previous_helper_pid", "previous_helper_invocation_id", "helper_invocation_id"}, + expected_collector_pid=collector_restart["collector_pid"], + ) + require(helper_restart["previous_helper_pid"] == collector_restart["helper_pid"], f"{label}.helper_restart predecessor PID differs") + require(helper_restart["helper_pid"] != collector_restart["helper_pid"], f"{label}.helper_restart did not change helper PID") + previous_invocation = require_text(helper_restart["previous_helper_invocation_id"], f"{label}.helper_restart.previous_helper_invocation_id", pattern=IDENTITY_RE) + invocation = require_text(helper_restart["helper_invocation_id"], f"{label}.helper_restart.helper_invocation_id", pattern=IDENTITY_RE) + require(previous_invocation != invocation, f"{label}.helper_restart did not change InvocationID") + require_summary_continuity(fresh, helper_restart, f"{label}.helper_restart") + + steady_stream = parsed["collector_restart"]["report_stream_id"] + require(parsed["helper_restart"]["report_stream_id"] == steady_stream, f"{label}.helper_restart changed report stream") + require(parsed["helper_restart"]["report_sequence"] > parsed["collector_restart"]["report_sequence"], f"{label}.helper_restart report did not advance") + + loss_label = f"{label}.helper_loss" + loss = require_object( + parsed["helper_loss"]["evidence"], + loss_label, + {"collector_pid", "previous_helper_pid", "collection_mode", "helper_available", "status_only", "inventory_complete", "inventory_present", "authoritative_inventory_replacement", "previous_authoritative_inventory_count", "previous_authoritative_semantic_sha256", "operation_status", "operation", "container_updates_enabled", "container_actions_enabled", "direct_socket_access"}, + ) + require(loss["collector_pid"] == helper_restart["collector_pid"], f"{loss_label} changed collector PID") + require(loss["previous_helper_pid"] == helper_restart["helper_pid"], f"{loss_label} previous helper PID differs") + require(loss["collection_mode"] == "typed-helper-unavailable-status-only", f"{loss_label}.collection_mode differs") + for name, expected in (("helper_available", False), ("status_only", True), ("inventory_complete", False), ("inventory_present", False), ("authoritative_inventory_replacement", False)): + require_bool(loss[name], expected, f"{loss_label}.{name}") + require(loss["previous_authoritative_inventory_count"] == fresh["inventory_count"], f"{loss_label} prior count differs") + require(loss["previous_authoritative_semantic_sha256"] == fresh["semantic_sha256"], f"{loss_label} prior digest differs") + require(loss["operation_status"] == "degraded", f"{loss_label}.operation_status differs") + require(loss["operation"] == "container.inventory", f"{loss_label}.operation differs") + for name in ("container_updates_enabled", "container_actions_enabled", "direct_socket_access"): + require_bool(loss[name], False, f"{loss_label}.{name}") + require(parsed["helper_loss"]["report_stream_id"] == steady_stream, f"{loss_label} changed report stream") + require(parsed["helper_loss"]["report_sequence"] > parsed["helper_restart"]["report_sequence"], f"{loss_label} report did not advance") + + recovery = validate_summary( + parsed["helper_recovery"]["evidence"], + f"{label}.helper_recovery", + runtime, + extra_keys={"previous_helper_pid", "previous_status_report_sequence"}, + expected_collector_pid=helper_restart["collector_pid"], + ) + require(recovery["previous_helper_pid"] == helper_restart["helper_pid"], f"{label}.helper_recovery previous helper PID differs") + require(recovery["helper_pid"] != helper_restart["helper_pid"], f"{label}.helper_recovery did not replace helper PID") + require(recovery["previous_status_report_sequence"] == parsed["helper_loss"]["report_sequence"], f"{label}.helper_recovery status sequence differs") + require(parsed["helper_recovery"]["report_stream_id"] == steady_stream, f"{label}.helper_recovery changed report stream") + require(parsed["helper_recovery"]["report_sequence"] > parsed["helper_loss"]["report_sequence"], f"{label}.helper_recovery report did not advance") + require_summary_continuity(fresh, recovery, f"{label}.helper_recovery") + + bounds_label = f"{label}.operation_bounds" + bounds = require_object( + parsed["operation_bounds"]["evidence"], + bounds_label, + {"collector_pid", "helper_pid", "operation", "failure_class", "deadline_ms", "elapsed_ms", "bounded_failure_observed", "status_only_report_sequence", "recovery_report_sequence", "collection_mode", "inventory_complete", "previous_authoritative_inventory_count", "previous_authoritative_semantic_sha256", "recovery_inventory_count", "recovery_semantic_sha256", "full_fields_present", "stats_present", "secondary_structure_sha256", "authoritative_empty_replacement", "collector_alive", "helper_alive", "container_updates_enabled", "container_actions_enabled", "direct_socket_access"}, + ) + require(bounds["collector_pid"] == recovery["collector_pid"], f"{bounds_label} changed collector PID") + require(bounds["helper_pid"] == recovery["helper_pid"], f"{bounds_label} changed helper PID") + require(bounds["operation"] == "container.inventory", f"{bounds_label}.operation differs") + require(bounds["failure_class"] == "bounded-timeout", f"{bounds_label}.failure_class differs") + deadline = require_int(bounds["deadline_ms"], f"{bounds_label}.deadline_ms", minimum=1) + elapsed = require_int(bounds["elapsed_ms"], f"{bounds_label}.elapsed_ms", minimum=1) + require(deadline <= 30_000, f"{bounds_label}.deadline_ms exceeds the helper operation ceiling") + require(elapsed <= deadline + 1000, f"{bounds_label} exceeded bounded deadline allowance") + require_bool(bounds["bounded_failure_observed"], True, f"{bounds_label}.bounded_failure_observed") + status_sequence = require_int(bounds["status_only_report_sequence"], f"{bounds_label}.status_only_report_sequence", minimum=1) + recovery_sequence = require_int(bounds["recovery_report_sequence"], f"{bounds_label}.recovery_report_sequence", minimum=1) + require(parsed["operation_bounds"]["report_sequence"] == recovery_sequence, f"{bounds_label} wrapper sequence differs") + require(parsed["operation_bounds"]["report_stream_id"] == steady_stream, f"{bounds_label} changed report stream") + require(parsed["helper_recovery"]["report_sequence"] < status_sequence < recovery_sequence, f"{bounds_label} report sequence is not causal") + require(bounds["collection_mode"] == "typed-helper-summary", f"{bounds_label}.collection_mode differs") + require_bool(bounds["inventory_complete"], True, f"{bounds_label}.inventory_complete") + require(bounds["previous_authoritative_inventory_count"] == fresh["inventory_count"], f"{bounds_label} prior count differs") + require(bounds["previous_authoritative_semantic_sha256"] == fresh["semantic_sha256"], f"{bounds_label} prior digest differs") + require(bounds["recovery_inventory_count"] == fresh["inventory_count"], f"{bounds_label} recovery count differs") + require(bounds["recovery_semantic_sha256"] == fresh["semantic_sha256"], f"{bounds_label} recovery digest differs") + require_bool(bounds["full_fields_present"], False, f"{bounds_label}.full_fields_present") + require_bool(bounds["stats_present"], False, f"{bounds_label}.stats_present") + require(bounds["secondary_structure_sha256"] == "", f"{bounds_label}.secondary_structure_sha256 must be empty") + require_bool(bounds["authoritative_empty_replacement"], False, f"{bounds_label}.authoritative_empty_replacement") + for name in ("collector_alive", "helper_alive"): + require_bool(bounds[name], True, f"{bounds_label}.{name}") + for name in ("container_updates_enabled", "container_actions_enabled", "direct_socket_access"): + require_bool(bounds[name], False, f"{bounds_label}.{name}") + + update = validate_summary( + parsed["update_preservation"]["evidence"], + f"{label}.update_preservation", + runtime, + extra_keys={"previous_collector_pid", "previous_helper_pid", "previous_report_stream_id", "update_applied", "collector_binary_sha256", "helper_binary_sha256"}, + ) + require(update["previous_collector_pid"] == recovery["collector_pid"], f"{label}.update_preservation predecessor PID differs") + require(update["collector_pid"] != recovery["collector_pid"], f"{label}.update_preservation did not replace collector PID") + require(update["previous_helper_pid"] == recovery["helper_pid"], f"{label}.update_preservation predecessor helper PID differs") + require(update["helper_pid"] == recovery["helper_pid"], f"{label}.update_preservation unexpectedly replaced helper PID") + require(update["previous_report_stream_id"] == steady_stream, f"{label}.update_preservation predecessor stream differs") + require(parsed["update_preservation"]["report_stream_id"] != steady_stream, f"{label}.update_preservation did not create a new stream") + require_bool(update["update_applied"], True, f"{label}.update_preservation.update_applied") + require_digest(update["collector_binary_sha256"], f"{label}.update_preservation.collector_binary_sha256") + require_digest(update["helper_binary_sha256"], f"{label}.update_preservation.helper_binary_sha256") + require_summary_continuity(fresh, update, f"{label}.update_preservation") + + authority_label = f"{label}.authority_isolation" + authority = require_object( + parsed["authority_isolation"]["evidence"], + authority_label, + {"collector_pid", "collector_uid", "effective_uid", "effective_root", "safe_profile_enabled", "commands_enabled", "privileged_helper_enabled", "reduction_request_observed", "collector_command_transport_present", "collector_command_session_present", "container_actions_enabled", "container_updates_enabled", "rootful_socket_access", "direct_socket_access", "helper_network_access"}, + ) + require(authority["collector_pid"] == update["collector_pid"], f"{authority_label} collector PID differs") + uid = require_int(authority["collector_uid"], f"{authority_label}.collector_uid", minimum=1) + require(authority["effective_uid"] == uid, f"{authority_label}.effective_uid differs") + require_bool(authority["effective_root"], False, f"{authority_label}.effective_root") + require_bool(authority["safe_profile_enabled"], True, f"{authority_label}.safe_profile_enabled") + require_bool(authority["privileged_helper_enabled"], True, f"{authority_label}.privileged_helper_enabled") + require_bool(authority["reduction_request_observed"], True, f"{authority_label}.reduction_request_observed") + for name in ("commands_enabled", "collector_command_transport_present", "collector_command_session_present", "container_actions_enabled", "container_updates_enabled", "rootful_socket_access", "direct_socket_access", "helper_network_access"): + require_bool(authority[name], False, f"{authority_label}.{name}") + + cleanup = require_object( + parsed["cleanup"]["evidence"], + f"{label}.cleanup", + {"collector_stopped", "helper_stopped", "runtime_stopped", "socket_absent", "fixtures_removed", "state_clean"}, + ) + for name in cleanup: + require_bool(cleanup[name], True, f"{label}.cleanup.{name}") + return run + + +def validate_receipt(receipt: Any) -> dict[str, Any]: + reject_sensitive_evidence(receipt) + root = require_object(receipt, "receipt", {"schema_version", "kind", "result", "source_commit", "started_at", "completed_at", "artifacts", "source_hashes", "runs"}) + require(type(root["schema_version"]) is int and root["schema_version"] == RECEIPT_SCHEMA_VERSION, "receipt.schema_version must be 1") + require(root["kind"] == RECEIPT_KIND, f"receipt.kind must be {RECEIPT_KIND}") + require(root["result"] == "passed", "receipt.result must be passed") + source_commit = require_text(root["source_commit"], "receipt.source_commit", pattern=COMMIT_RE, maximum=40) + started = parse_timestamp(root["started_at"], "receipt.started_at") + completed = parse_timestamp(root["completed_at"], "receipt.completed_at") + require(started < completed, "receipt chronology is invalid") + + artifacts = require_object(root["artifacts"], "receipt.artifacts", {"qualification_test", "collector", "helper", "installer"}) + expected = { + "qualification_test": ("dockeragent.test", "github.com/rcourtman/pulse-go-rewrite/scripts/installtests.test"), + "collector": ("pulse-agent", "github.com/rcourtman/pulse-go-rewrite/cmd/pulse-agent"), + "helper": ("pulse-agent-helper", "github.com/rcourtman/pulse-go-rewrite/cmd/pulse-agent-helper"), + } + for name, (basename, package) in expected.items(): + artifact = require_object(artifacts[name], f"receipt.artifacts.{name}", {"path_basename", "sha256", "package", "go_version", "vcs_revision", "vcs_modified"}) + require(artifact["path_basename"] == basename, f"receipt.artifacts.{name}.path_basename differs") + require_digest(artifact["sha256"], f"receipt.artifacts.{name}.sha256") + require(artifact["package"] == package, f"receipt.artifacts.{name}.package differs") + require_text(artifact["go_version"], f"receipt.artifacts.{name}.go_version", pattern=GO_VERSION_RE, maximum=32) + require(artifact["vcs_revision"] == source_commit, f"receipt.artifacts.{name}.vcs_revision differs") + require_bool(artifact["vcs_modified"], False, f"receipt.artifacts.{name}.vcs_modified") + installer = require_object(artifacts["installer"], "receipt.artifacts.installer", {"path_basename", "sha256"}) + require(installer["path_basename"] == "install.sh", "receipt.artifacts.installer.path_basename differs") + require_digest(installer["sha256"], "receipt.artifacts.installer.sha256") + + source_hashes = root["source_hashes"] + require(isinstance(source_hashes, dict) and source_hashes, "receipt.source_hashes must be a non-empty object") + for source_path, digest in source_hashes.items(): + require_text(source_path, "receipt.source_hashes path", maximum=512) + candidate = Path(source_path) + require(source_path == candidate.as_posix() and not candidate.is_absolute() and ".." not in candidate.parts, "receipt.source_hashes contains a non-canonical path") + require_digest(digest, f"receipt.source_hashes[{source_path}]") + require(source_hashes.get("scripts/install.sh") == installer["sha256"], "installer digest must equal governed scripts/install.sh") + + runs = root["runs"] + require(isinstance(runs, list) and len(runs) == 2, "receipt.runs must contain exactly two runtime runs") + validated = [validate_run(runs[i], runtime, i, started, completed) for i, runtime in enumerate(REQUIRED_RUNTIMES)] + require(validated[0]["host"]["machine_id"] != validated[1]["host"]["machine_id"], "Docker and Podman runs must use distinct hosts") + require(validated[0]["runtime"]["daemon_id"] != validated[1]["runtime"]["daemon_id"], "Docker and Podman daemon identities must differ") + docker_stream = validated[0]["scenarios"][2]["report_stream_id"] + podman_stream = validated[1]["scenarios"][2]["report_stream_id"] + require(docker_stream != podman_stream, "Docker and Podman report streams must differ") + for index, run in enumerate(validated): + update = run["scenarios"][REQUIRED_SCENARIOS.index("update_preservation")]["evidence"] + require(update["collector_binary_sha256"] == artifacts["collector"]["sha256"], f"runs[{index}].update_preservation collector artifact digest differs") + require(update["helper_binary_sha256"] == artifacts["helper"]["sha256"], f"runs[{index}].update_preservation helper artifact digest differs") + return root + + +def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + fail(f"receipt contains duplicate JSON key {key!r}") + result[key] = value + return result + + +def parse_receipt_bytes(data: bytes) -> dict[str, Any]: + require(len(data) <= MAX_RECEIPT_BYTES, "receipt exceeds the maximum size") + try: + parsed = json.loads(data.decode("utf-8"), object_pairs_hook=_reject_duplicate_keys, parse_constant=lambda value: fail(f"receipt contains invalid number {value}")) + except UnicodeDecodeError as exc: + raise ValidationError("receipt is not UTF-8") from exc + except json.JSONDecodeError as exc: + raise ValidationError(f"receipt is not valid JSON: {exc}") from exc + return validate_receipt(parsed) + + +def _canonical_relative_path(value: Any, label: str) -> str: + text = require_text(value, label, maximum=512) + candidate = Path(text) + require(not candidate.is_absolute() and candidate.as_posix() == text and text not in {"", "."} and ".." not in candidate.parts, f"{label} is not a canonical repository-relative path") + return text + + +def load_source_manifest(checkout: Path, manifest_path: Path) -> tuple[bytes, dict[str, str]]: + manifest_bytes = read_immutable_receipt(manifest_path) + try: + manifest = json.loads(manifest_bytes.decode("utf-8"), object_pairs_hook=_reject_duplicate_keys) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValidationError(f"source manifest is invalid JSON: {exc}") from exc + manifest = require_object(manifest, "source manifest", {"schema_version", "manifest_id", "target_os", "description", "exact_paths", "recursive_roots", "include_suffixes", "exclude_suffixes"}) + require(type(manifest["schema_version"]) is int and manifest["schema_version"] == SOURCE_MANIFEST_SCHEMA_VERSION, "source manifest schema differs") + require(manifest["manifest_id"] == SOURCE_MANIFEST_ID, "source manifest ID differs") + require(manifest["target_os"] == "linux", "source manifest target_os must be linux") + require_text(manifest["description"], "source manifest description", maximum=1024) + for field in ("exact_paths", "recursive_roots", "include_suffixes", "exclude_suffixes"): + require(isinstance(manifest[field], list) and all(isinstance(item, str) for item in manifest[field]), f"source manifest {field} must be a text array") + require(len(manifest[field]) == len(set(manifest[field])), f"source manifest {field} contains duplicates") + require(manifest["exact_paths"], "source manifest exact_paths must not be empty") + include_suffixes = tuple(manifest["include_suffixes"]) + exclude_suffixes = tuple(manifest["exclude_suffixes"]) + require(include_suffixes and all(item.startswith(".") for item in include_suffixes), "source manifest include_suffixes are invalid") + require(all(item.startswith("_") or item.startswith(".") for item in exclude_suffixes), "source manifest exclude_suffixes are invalid") + paths = {_canonical_relative_path(item, "source manifest exact path") for item in manifest["exact_paths"]} + for raw_root in manifest["recursive_roots"]: + root_text = _canonical_relative_path(raw_root, "source manifest recursive root") + root = checkout / root_text + require(root.is_dir() and not root.is_symlink(), f"source manifest recursive root is not a regular directory: {root_text}") + for candidate in root.rglob("*"): + if candidate.is_dir() or candidate.name.endswith(exclude_suffixes) or not candidate.name.endswith(include_suffixes): + continue + paths.add(candidate.relative_to(checkout).as_posix()) + hashes: dict[str, str] = {} + checkout_resolved = checkout.resolve(strict=True) + for relative in sorted(paths): + candidate = checkout / relative + try: + metadata = candidate.lstat() + except OSError as exc: + raise ValidationError(f"unable to inspect governed source {relative}: {exc}") from exc + require(stat.S_ISREG(metadata.st_mode) and not stat.S_ISLNK(metadata.st_mode), f"governed source is not a regular file: {relative}") + require(candidate.resolve(strict=True).is_relative_to(checkout_resolved), f"governed source escapes checkout: {relative}") + hashes[relative] = hashlib.sha256(read_immutable_receipt(candidate)).hexdigest() + require(hashes, "source manifest expands to no source files") + return manifest_bytes, hashes + + +def create_attestation( + receipt_path: Path, + qualification_test_path: Path, + collector_path: Path, + helper_path: Path, + installer_path: Path, + *, + checkout: Path | None = None, + manifest_path: Path | None = None, + verify_git_commit: bool = True, +) -> dict[str, Any]: + data = read_immutable_receipt(receipt_path) + receipt = parse_receipt_bytes(data) + checkout = (checkout or Path(__file__).resolve().parents[2]).resolve(strict=True) + manifest_path = manifest_path or checkout / SOURCE_MANIFEST_PATH + manifest_bytes, source_hashes = load_source_manifest(checkout, manifest_path) + require(receipt["source_hashes"] == source_hashes, "receipt source_hashes do not match the canonical source manifest") + if verify_git_commit: + verify_source_commit(checkout, receipt["source_commit"], source_hashes) + supplied = { + "qualification_test": (qualification_test_path, "dockeragent.test", True), + "collector": (collector_path, "pulse-agent", True), + "helper": (helper_path, "pulse-agent-helper", True), + "installer": (installer_path, "install.sh", False), + } + bindings: dict[str, dict[str, Any]] = {} + with contextlib.ExitStack() as stack: + snapshots: dict[str, Path] = {} + for name, (path, basename, executable) in supplied.items(): + snapshot, digest = stack.enter_context(immutable_artifact_snapshot(path, basename, executable=executable)) + require(digest == receipt["artifacts"][name]["sha256"], f"{name} bytes do not match receipt SHA-256") + snapshots[name] = snapshot + bindings[name] = {"path_basename": basename, "sha256": digest} + for name in ("qualification_test", "collector", "helper"): + build = inspect_go_build_identity(snapshots[name]) + claimed = receipt["artifacts"][name] + require(build["package"] == claimed["package"], f"{name} Go package differs from receipt") + require(build["go_version"] == claimed["go_version"], f"{name} Go version differs from receipt") + require(build["vcs_revision"] == claimed["vcs_revision"] == receipt["source_commit"], f"{name} VCS revision differs") + require_bool(build["vcs_modified"], False, f"{name} vcs.modified") + bindings[name].update(build) + validator_bytes = read_immutable_receipt(Path(__file__).absolute()) + return { + "schema_version": ATTESTATION_SCHEMA_VERSION, + "kind": ATTESTATION_KIND, + "classification": CLASSIFICATION, + "receipt_sha256": hashlib.sha256(data).hexdigest(), + "validator_source_sha256": hashlib.sha256(validator_bytes).hexdigest(), + "source_manifest_schema_version": SOURCE_MANIFEST_SCHEMA_VERSION, + "source_manifest_id": SOURCE_MANIFEST_ID, + "source_manifest_sha256": hashlib.sha256(manifest_bytes).hexdigest(), + "source_hash_count": len(source_hashes), + "source_commit": receipt["source_commit"], + "source_commit_verified": verify_git_commit, + "artifact_bindings": bindings, + "validated_runtimes": list(REQUIRED_RUNTIMES), + "limitations": [ + "not-published-release-provenance", + "not-default-profile-authorization", + "not-independent-security-review", + "production-exact-scope-proof-is-external-prior", + ], + } + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("receipt", type=Path, help="schema-v1 rootful qualification receipt") + parser.add_argument("--qualification-test", required=True, type=Path, help="exact dockeragent.test qualification binary") + parser.add_argument("--collector", required=True, type=Path, help="exact pulse-agent binary") + parser.add_argument("--helper", required=True, type=Path, help="exact pulse-agent-helper binary") + parser.add_argument("--installer", required=True, type=Path, help="exact install.sh blob") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + arguments = parse_args(argv) + try: + attestation = create_attestation(arguments.receipt, arguments.qualification_test, arguments.collector, arguments.helper, arguments.installer) + except ValidationError as exc: + print(f"rootful receipt validation failed: {exc}", file=sys.stderr) + return 1 + print(json.dumps(attestation, sort_keys=True, separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_control/secure_runtime_rootful_attestation_v1_test.py b/scripts/release_control/secure_runtime_rootful_attestation_v1_test.py new file mode 100644 index 000000000..99580a5b9 --- /dev/null +++ b/scripts/release_control/secure_runtime_rootful_attestation_v1_test.py @@ -0,0 +1,475 @@ +#!/usr/bin/env python3 +"""Adversarial tests for the schema-v1 rootful qualification validator.""" + +from __future__ import annotations + +import copy +import hashlib +import json +import os +import subprocess +import sys +import tempfile +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import secure_runtime_rootful_attestation_v1 as attester + + +def sha(value: str | bytes) -> str: + return hashlib.sha256(value if isinstance(value, bytes) else value.encode()).hexdigest() + + +class RootfulAttestationV1Test(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + self.commit = "a" * 40 + self.artifacts: dict[str, Path] = {} + for key, basename in { + "qualification_test": "dockeragent.test", + "collector": "pulse-agent", + "helper": "pulse-agent-helper", + "installer": "install.sh", + }.items(): + path = self.root / basename + path.write_bytes(f"{key}-bytes".encode()) + path.chmod(0o700 if key != "installer" else 0o600) + self.artifacts[key] = path + self.sources = { + "runtime.go": b"package runtime\n", + "internal/api/api_tokens.go": b"package api\n", + "scripts/install.sh": self.artifacts["installer"].read_bytes(), + } + for relative, data in self.sources.items(): + target = self.root / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + self.manifest = self.root / "manifest.json" + self.manifest.write_text( + json.dumps( + { + "schema_version": 1, + "manifest_id": attester.SOURCE_MANIFEST_ID, + "target_os": "linux", + "description": "test boundary", + "exact_paths": sorted(self.sources), + "recursive_roots": [], + "include_suffixes": [".go"], + "exclude_suffixes": ["_test.go"], + } + ), + encoding="utf-8", + ) + self.receipt = self.make_receipt() + + def tearDown(self) -> None: + self.temp.cleanup() + + @staticmethod + def ts(index: int) -> str: + value = datetime(2026, 9, 1, 10, tzinfo=timezone.utc) + timedelta(minutes=index) + return value.isoformat().replace("+00:00", "Z") + + def summary(self, runtime: str, collector_pid: int, helper_pid: int, daemon_id: str) -> dict: + return { + "collector_pid": collector_pid, + "helper_pid": helper_pid, + "collection_mode": "typed-helper-summary", + "inventory_complete": True, + "inventory_count": 2, + "semantic_sha256": sha(runtime + "-summary"), + "full_fields_present": False, + "stats_present": False, + "secondary_structure_sha256": "", + "container_updates_enabled": False, + "container_actions_enabled": False, + "direct_socket_access": False, + "daemon_id": daemon_id, + "daemon_rootless": False, + } + + def make_run(self, runtime: str, index: int) -> dict: + daemon = runtime + "-rootful-daemon" + base_collector = 4100 + index * 100 + base_helper = 5100 + index * 100 + fresh = self.summary(runtime, base_collector, base_helper, daemon) + migration = { + **self.summary(runtime, base_collector + 1, base_helper + 1, daemon), + "legacy_profile": "root-command-capable", + "target_profile": "typed-helper-monitoring-only", + "authority_reduced": True, + "legacy_collector_pid": base_collector + 50, + } + collector_restart = { + **self.summary(runtime, base_collector + 2, base_helper + 1, daemon), + "previous_collector_pid": migration["collector_pid"], + "previous_report_stream_id": runtime + "-migration-stream", + } + helper_restart = { + **self.summary(runtime, collector_restart["collector_pid"], base_helper + 2, daemon), + "previous_helper_pid": collector_restart["helper_pid"], + "previous_helper_invocation_id": runtime + "-helper-before", + "helper_invocation_id": runtime + "-helper-after", + } + loss = { + "collector_pid": helper_restart["collector_pid"], + "previous_helper_pid": helper_restart["helper_pid"], + "collection_mode": "typed-helper-unavailable-status-only", + "helper_available": False, + "status_only": True, + "inventory_complete": False, + "inventory_present": False, + "authoritative_inventory_replacement": False, + "previous_authoritative_inventory_count": fresh["inventory_count"], + "previous_authoritative_semantic_sha256": fresh["semantic_sha256"], + "operation_status": "degraded", + "operation": "container.inventory", + "container_updates_enabled": False, + "container_actions_enabled": False, + "direct_socket_access": False, + } + recovery = { + **self.summary(runtime, helper_restart["collector_pid"], base_helper + 3, daemon), + "previous_helper_pid": helper_restart["helper_pid"], + "previous_status_report_sequence": 3, + } + bounds = { + "collector_pid": recovery["collector_pid"], + "helper_pid": recovery["helper_pid"], + "operation": "container.inventory", + "failure_class": "bounded-timeout", + "deadline_ms": 2000, + "elapsed_ms": 2050, + "bounded_failure_observed": True, + "status_only_report_sequence": 5, + "recovery_report_sequence": 6, + "collection_mode": "typed-helper-summary", + "inventory_complete": True, + "previous_authoritative_inventory_count": fresh["inventory_count"], + "previous_authoritative_semantic_sha256": fresh["semantic_sha256"], + "recovery_inventory_count": fresh["inventory_count"], + "recovery_semantic_sha256": fresh["semantic_sha256"], + "full_fields_present": False, + "stats_present": False, + "secondary_structure_sha256": "", + "authoritative_empty_replacement": False, + "collector_alive": True, + "helper_alive": True, + "container_updates_enabled": False, + "container_actions_enabled": False, + "direct_socket_access": False, + } + update = { + **self.summary(runtime, base_collector + 3, recovery["helper_pid"], daemon), + "previous_collector_pid": recovery["collector_pid"], + "previous_helper_pid": recovery["helper_pid"], + "previous_report_stream_id": runtime + "-restart-stream", + "update_applied": True, + "collector_binary_sha256": sha(self.artifacts["collector"].read_bytes()), + "helper_binary_sha256": sha(self.artifacts["helper"].read_bytes()), + } + authority = { + "collector_pid": update["collector_pid"], + "collector_uid": 1200 + index, + "effective_uid": 1200 + index, + "effective_root": False, + "safe_profile_enabled": True, + "commands_enabled": False, + "privileged_helper_enabled": True, + "reduction_request_observed": True, + "collector_command_transport_present": False, + "collector_command_session_present": False, + "container_actions_enabled": False, + "container_updates_enabled": False, + "rootful_socket_access": False, + "direct_socket_access": False, + "helper_network_access": False, + } + cleanup = { + "collector_stopped": True, + "helper_stopped": True, + "runtime_stopped": True, + "socket_absent": True, + "fixtures_removed": True, + "state_clean": True, + } + evidences = [fresh, migration, collector_restart, helper_restart, loss, recovery, bounds, update, authority, cleanup] + reports = { + "fresh_install": (runtime + "-fresh-stream", 1), + "legacy_migration": (runtime + "-migration-stream", 1), + "collector_restart": (runtime + "-restart-stream", 1), + "helper_restart": (runtime + "-restart-stream", 2), + "helper_loss": (runtime + "-restart-stream", 3), + "helper_recovery": (runtime + "-restart-stream", 4), + "operation_bounds": (runtime + "-restart-stream", 6), + "update_preservation": (runtime + "-update-stream", 1), + } + scenarios = [] + for scenario_index, name in enumerate(attester.REQUIRED_SCENARIOS): + stream, sequence = reports.get(name, (None, None)) + scenarios.append( + { + "name": name, + "result": "passed", + "started_at": self.ts(index * 25 + scenario_index * 2 + 1), + "completed_at": self.ts(index * 25 + scenario_index * 2 + 2), + "report_stream_id": stream, + "report_sequence": sequence, + "evidence": evidences[scenario_index], + } + ) + return { + "host": { + "machine_id": f"{index + 1:032x}", + "architecture": "amd64", + "kernel": "6.12.0-test", + "systemd_version": "systemd-257", + }, + "runtime": { + "runtime": runtime, + "runtime_version": "27.3.1" if runtime == "docker" else "5.2.2", + "daemon_id": daemon, + "daemon_rootless": False, + "socket_path": attester.expected_socket_path(runtime), + "socket_uid": 0, + "socket_gid": 0, + "socket_mode": "0660", + "socket_type": "unix", + "socket_symlink": False, + }, + "scenarios": scenarios, + } + + def make_receipt(self) -> dict: + packages = { + "qualification_test": "github.com/rcourtman/pulse-go-rewrite/scripts/installtests.test", + "collector": "github.com/rcourtman/pulse-go-rewrite/cmd/pulse-agent", + "helper": "github.com/rcourtman/pulse-go-rewrite/cmd/pulse-agent-helper", + } + artifacts = { + "installer": { + "path_basename": "install.sh", + "sha256": sha(self.artifacts["installer"].read_bytes()), + } + } + for name, package in packages.items(): + artifacts[name] = { + "path_basename": self.artifacts[name].name, + "sha256": sha(self.artifacts[name].read_bytes()), + "package": package, + "go_version": "go1.25.1", + "vcs_revision": self.commit, + "vcs_modified": False, + } + return { + "schema_version": 1, + "kind": attester.RECEIPT_KIND, + "result": "passed", + "source_commit": self.commit, + "started_at": self.ts(0), + "completed_at": self.ts(50), + "source_hashes": {name: sha(data) for name, data in self.sources.items()}, + "artifacts": artifacts, + "runs": [self.make_run("docker", 0), self.make_run("podman", 1)], + } + + def scenario(self, receipt: dict, run: int, name: str) -> dict: + return receipt["runs"][run]["scenarios"][attester.REQUIRED_SCENARIOS.index(name)] + + def invalid(self, mutation) -> None: + receipt = copy.deepcopy(self.receipt) + mutation(receipt) + with self.assertRaises(attester.ValidationError): + attester.validate_receipt(receipt) + + def write_receipt(self, receipt: dict | None = None) -> Path: + path = self.root / "receipt.json" + path.write_text(json.dumps(receipt or self.receipt, sort_keys=True), encoding="utf-8") + return path + + def valid_build(self, package: str) -> dict: + return {"package": package, "go_version": "go1.25.1", "vcs_revision": self.commit, "vcs_modified": False} + + def attest(self, receipt: dict | None = None) -> dict: + packages = {name: self.receipt["artifacts"][name]["package"] for name in ("qualification_test", "collector", "helper")} + by_basename = {path.name: key for key, path in self.artifacts.items()} + with mock.patch.object(attester, "inspect_go_build_identity", side_effect=lambda path: self.valid_build(packages[by_basename[path.name]])): + return attester.create_attestation( + self.write_receipt(receipt), + self.artifacts["qualification_test"], + self.artifacts["collector"], + self.artifacts["helper"], + self.artifacts["installer"], + checkout=self.root, + manifest_path=self.manifest, + verify_git_commit=False, + ) + + def test_valid_receipt_and_local_attestation(self) -> None: + attester.validate_receipt(self.receipt) + result = self.attest() + self.assertEqual(result["validated_runtimes"], ["docker", "podman"]) + self.assertEqual(result["classification"], attester.CLASSIFICATION) + self.assertEqual(set(result["artifact_bindings"]), set(self.artifacts)) + self.assertEqual( + result["limitations"], + [ + "not-published-release-provenance", + "not-default-profile-authorization", + "not-independent-security-review", + "production-exact-scope-proof-is-external-prior", + ], + ) + + def test_exact_runtime_topology_and_order_fail_closed(self) -> None: + mutations = [ + lambda r: r.update(schema_version=True), + lambda r: r["runs"].pop(), + lambda r: r["runs"].reverse(), + lambda r: r["runs"][1]["host"].update(machine_id=r["runs"][0]["host"]["machine_id"]), + lambda r: r["runs"][1]["runtime"].update(daemon_id=r["runs"][0]["runtime"]["daemon_id"]), + lambda r: r["runs"][0]["runtime"].update(daemon_rootless=True), + lambda r: r["runs"][0]["runtime"].update(socket_path="/run/docker.sock"), + lambda r: r["runs"][0]["runtime"].update(socket_uid=1000), + lambda r: r["runs"][0]["runtime"].update(socket_mode="0666"), + lambda r: r["runs"][0]["runtime"].update(socket_symlink=True), + lambda r: r["runs"][0]["scenarios"].reverse(), + ] + for mutation in mutations: + with self.subTest(mutation=mutation): + self.invalid(mutation) + + def test_summary_is_exactly_helper_only_and_continuous(self) -> None: + mutations = [ + lambda r: self.scenario(r, 0, "fresh_install")["evidence"].update(collection_mode="direct-rootful-socket"), + lambda r: self.scenario(r, 0, "fresh_install")["evidence"].update(full_fields_present=True), + lambda r: self.scenario(r, 0, "fresh_install")["evidence"].update(stats_present=True), + lambda r: self.scenario(r, 0, "fresh_install")["evidence"].update(secondary_structure_sha256=sha("secondary")), + lambda r: self.scenario(r, 0, "fresh_install")["evidence"].update(container_updates_enabled=True), + lambda r: self.scenario(r, 0, "fresh_install")["evidence"].update(container_actions_enabled=True), + lambda r: self.scenario(r, 0, "fresh_install")["evidence"].update(direct_socket_access=True), + lambda r: self.scenario(r, 0, "helper_recovery")["evidence"].update(inventory_count=3), + lambda r: self.scenario(r, 0, "update_preservation")["evidence"].update(semantic_sha256=sha("wrong")), + ] + for mutation in mutations: + with self.subTest(mutation=mutation): + self.invalid(mutation) + + def test_pid_stream_loss_recovery_and_bounds_are_causal(self) -> None: + mutations = [ + lambda r: self.scenario(r, 0, "legacy_migration")["evidence"].update(authority_reduced=False), + lambda r: self.scenario(r, 0, "collector_restart")["evidence"].update(previous_collector_pid=9999), + lambda r: self.scenario(r, 0, "helper_restart")["evidence"].update(previous_helper_pid=9999), + lambda r: self.scenario(r, 0, "helper_restart")["evidence"].update(helper_invocation_id="docker-helper-before"), + lambda r: self.scenario(r, 0, "helper_loss")["evidence"].update(status_only=False), + lambda r: self.scenario(r, 0, "helper_loss")["evidence"].update(inventory_present=True), + lambda r: self.scenario(r, 0, "helper_loss")["evidence"].update(authoritative_inventory_replacement=True), + lambda r: self.scenario(r, 0, "helper_recovery")["evidence"].update(previous_status_report_sequence=2), + lambda r: self.scenario(r, 0, "operation_bounds")["evidence"].update(elapsed_ms=5000), + lambda r: self.scenario(r, 0, "operation_bounds")["evidence"].update(deadline_ms=60000, elapsed_ms=100), + lambda r: self.scenario(r, 0, "operation_bounds")["evidence"].update(authoritative_empty_replacement=True), + lambda r: self.scenario(r, 0, "operation_bounds")["evidence"].update(stats_present=True), + lambda r: self.scenario(r, 0, "operation_bounds")["evidence"].update(recovery_report_sequence=5), + lambda r: self.scenario(r, 0, "update_preservation")["evidence"].update(previous_collector_pid=9999), + lambda r: self.scenario(r, 0, "update_preservation")["evidence"].update(previous_helper_pid=9999), + lambda r: self.scenario(r, 0, "update_preservation")["evidence"].update(helper_pid=9999), + lambda r: self.scenario(r, 0, "update_preservation")["evidence"].update(collector_binary_sha256=sha("wrong")), + ] + for mutation in mutations: + with self.subTest(mutation=mutation): + self.invalid(mutation) + + def test_authority_cleanup_secret_and_extra_fields_fail_closed(self) -> None: + mutations = [ + lambda r: self.scenario(r, 0, "authority_isolation")["evidence"].update(commands_enabled=True), + lambda r: self.scenario(r, 0, "authority_isolation")["evidence"].update(rootful_socket_access=True), + lambda r: self.scenario(r, 0, "authority_isolation")["evidence"].update(helper_network_access=True), + lambda r: self.scenario(r, 0, "cleanup")["evidence"].update(socket_absent=False), + lambda r: self.scenario(r, 0, "cleanup")["evidence"].update(user_state_clean=True), + lambda r: r["runs"][0]["host"].update(kernel="Bearer abcdefghijklmnop"), + ] + for mutation in mutations: + with self.subTest(mutation=mutation): + self.invalid(mutation) + + def test_artifact_source_and_duplicate_json_binding(self) -> None: + self.artifacts["collector"].write_bytes(b"different") + self.artifacts["collector"].chmod(0o700) + with self.assertRaises(attester.ValidationError): + self.attest() + self.artifacts["collector"].write_bytes(b"collector-bytes") + self.artifacts["collector"].chmod(0o700) + helper = self.artifacts["helper"] + real_helper = self.root / "helper-real" + helper.rename(real_helper) + helper.symlink_to(real_helper) + with self.assertRaises(attester.ValidationError): + self.attest() + helper.unlink() + real_helper.rename(helper) + bad = copy.deepcopy(self.receipt) + bad["source_hashes"]["runtime.go"] = sha("wrong") + with self.assertRaises(attester.ValidationError): + self.attest(bad) + with self.assertRaises(attester.ValidationError): + attester.parse_receipt_bytes(b'{"schema_version":1,"schema_version":1}') + with mock.patch.object(attester, "inspect_go_build_identity", return_value=self.valid_build("github.com/rcourtman/pulse-go-rewrite/cmd/pulse-agent")): + with self.assertRaises(attester.ValidationError): + attester.create_attestation( + self.write_receipt(), self.artifacts["qualification_test"], self.artifacts["collector"], + self.artifacts["helper"], self.artifacts["installer"], checkout=self.root, + manifest_path=self.manifest, verify_git_commit=False, + ) + args = attester.parse_args([ + "receipt.json", "--qualification-test", "dockeragent.test", "--collector", "pulse-agent", + "--helper", "pulse-agent-helper", "--installer", "install.sh", + ]) + self.assertEqual(args.qualification_test.name, "dockeragent.test") + + @unittest.skipUnless(hasattr(os, "mkfifo"), "FIFO validation requires Unix mkfifo") + def test_fifo_receipt_and_artifact_paths_fail_without_blocking(self) -> None: + module_dir = Path(attester.__file__).resolve().parent + receipt_fifo = self.root / "receipt.fifo" + os.mkfifo(receipt_fifo, 0o600) + receipt_probe = ( + "import sys; from pathlib import Path; sys.path.insert(0, sys.argv[1]); " + "import secure_runtime_rootful_attestation_v1 as a; a.read_immutable_receipt(Path(sys.argv[2]))" + ) + result = subprocess.run([sys.executable, "-I", "-c", receipt_probe, str(module_dir), str(receipt_fifo)], capture_output=True, timeout=2) + self.assertNotEqual(result.returncode, 0) + self.assertIn(b"receipt must be a regular file", result.stderr) + + artifact_fifo = self.root / "pulse-agent" + artifact_fifo.unlink() + os.mkfifo(artifact_fifo, 0o700) + artifact_probe = ( + "import sys; from pathlib import Path; sys.path.insert(0, sys.argv[1]); " + "import secure_runtime_rootful_attestation_v1 as a; " + "a.immutable_artifact_snapshot(Path(sys.argv[2]), 'pulse-agent', executable=True).__enter__()" + ) + result = subprocess.run([sys.executable, "-I", "-c", artifact_probe, str(module_dir), str(artifact_fifo)], capture_output=True, timeout=2) + self.assertNotEqual(result.returncode, 0) + self.assertIn(b"artifact pulse-agent must be a regular file", result.stderr) + + def test_manifest_contract_binds_transitive_harness_and_production_boundary(self) -> None: + manifest = json.loads((Path(__file__).with_name("secure_runtime_rootful_source_manifest_v1.json")).read_text(encoding="utf-8")) + required = { + "scripts/installtests/secure_runtime_rootful_qualification_test.go", + "scripts/installtests/secure_runtime_rootless_qualification_test.go", + "scripts/installtests/secure_runtime_systemd_lab_test.go", + "scripts/release_control/secure_runtime_rootful_attestation_v1.py", + "scripts/release_control/secure_runtime_rootful_source_manifest_v1.json", + "scripts/release_control/secure_runtime_rootless_attestation_v1.py", + "scripts/run-secure-runtime-rootful-qualification.sh", + } + self.assertTrue(required.issubset(manifest["exact_paths"])) + self.assertIn("internal/agenthelper", manifest["recursive_roots"]) + self.assertIn("pkg/auth", manifest["recursive_roots"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release_control/secure_runtime_rootful_source_manifest_v1.json b/scripts/release_control/secure_runtime_rootful_source_manifest_v1.json new file mode 100644 index 000000000..7c55a5d43 --- /dev/null +++ b/scripts/release_control/secure_runtime_rootful_source_manifest_v1.json @@ -0,0 +1,51 @@ +{ + "schema_version": 1, + "manifest_id": "secure-runtime-rootful-v1", + "target_os": "linux", + "description": "Production source boundary for rootful Docker and Podman typed-helper summary inventory, loss and recovery, operation bounds, update preservation, installer migration, and the standalone rootful qualification packet.", + "exact_paths": [ + "VERSION", + "go.mod", + "go.sum", + "pkg/agents/docker/report.go", + "pkg/agents/docker/report_limits.go", + "scripts/install.sh", + "scripts/release_ldflags.sh", + "scripts/installtests/secure_runtime_rootful_qualification_test.go", + "scripts/installtests/secure_runtime_rootless_qualification_test.go", + "scripts/installtests/secure_runtime_systemd_lab_test.go", + "scripts/installtests/testdata/secure_runtime_docker_fixture.go", + "scripts/release_control/secure_runtime_rootful_attestation_v1.py", + "scripts/release_control/secure_runtime_rootful_source_manifest_v1.json", + "scripts/release_control/secure_runtime_rootless_attestation_v1.py", + "scripts/run-secure-runtime-rootful-qualification.sh" + ], + "recursive_roots": [ + "cmd/pulse-agent", + "cmd/pulse-agent-helper", + "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/agents/host", + "pkg/securityutil", + "pkg/tlsutil" + ], + "include_suffixes": [ + ".go", + ".tmpl" + ], + "exclude_suffixes": [ + "_test.go" + ] +} diff --git a/scripts/run-secure-runtime-rootful-qualification.sh b/scripts/run-secure-runtime-rootful-qualification.sh new file mode 100755 index 000000000..79d6fa83e --- /dev/null +++ b/scripts/run-secure-runtime-rootful-qualification.sh @@ -0,0 +1,409 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readonly UBUNTU_IMAGE="${PULSE_ROOTFUL_UBUNTU_IMAGE:?set PULSE_ROOTFUL_UBUNTU_IMAGE to an immutable ubuntu@sha256:... Ubuntu 24.04 image}" +readonly OUTPUT_PARENT="${PULSE_ROOTFUL_QUALIFICATION_OUTPUT_DIR:?set PULSE_ROOTFUL_QUALIFICATION_OUTPUT_DIR to an existing absolute private directory}" +readonly CONFIRM="${PULSE_ROOTFUL_QUALIFICATION_CONFIRM:-}" + +portable_mode() { + stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" +} + +portable_uid() { + stat -c '%u' "$1" 2>/dev/null || stat -f '%u' "$1" +} + +if [[ ! "${UBUNTU_IMAGE}" =~ ^ubuntu@sha256:[0-9a-f]{64}$ ]]; then + echo "ERROR: PULSE_ROOTFUL_UBUNTU_IMAGE must be an exact ubuntu@sha256 digest" >&2 + exit 2 +fi +if [[ "${OUTPUT_PARENT}" != /* || ! -d "${OUTPUT_PARENT}" || -L "${OUTPUT_PARENT}" ]]; then + echo "ERROR: PULSE_ROOTFUL_QUALIFICATION_OUTPUT_DIR must be an existing absolute non-symlink directory" >&2 + exit 2 +fi +if [[ "$(portable_mode "${OUTPUT_PARENT}")" != "700" ]]; then + echo "ERROR: qualification output directory must have exact mode 0700" >&2 + exit 2 +fi +if [[ "$(portable_uid "${OUTPUT_PARENT}")" != "$(id -u)" ]]; then + echo "ERROR: qualification output directory must be owned by the invoking user" >&2 + exit 2 +fi +if ! command -v docker >/dev/null 2>&1 || ! docker info >/dev/null 2>&1; then + echo "ERROR: a working Docker CLI/daemon is required to create disposable qualification containers" >&2 + exit 2 +fi +if [[ "$(git -C "${REPO_ROOT}" branch --show-current)" != "main" ]]; then + echo "ERROR: qualification builds are allowed only from main" >&2 + exit 2 +fi +if [[ -n "$(git -C "${REPO_ROOT}" status --porcelain)" ]]; then + echo "ERROR: qualification requires a clean exact source checkout" >&2 + exit 2 +fi + +readonly SOURCE_COMMIT="$(git -C "${REPO_ROOT}" rev-parse HEAD)" +readonly EXPECTED_CONFIRM="I_HAVE_VERIFIED_THESE_ARE_DISPOSABLE_ROOTFUL_SYSTEMD_CONTAINERS_COMMIT_${SOURCE_COMMIT}" +if [[ "${CONFIRM}" != "${EXPECTED_CONFIRM}" ]]; then + echo "ERROR: exact destructive opt-in required:" >&2 + echo "export PULSE_ROOTFUL_QUALIFICATION_CONFIRM=${EXPECTED_CONFIRM}" >&2 + exit 2 +fi + +readonly RUN_STAMP="$(date -u +%Y%m%dT%H%M%SZ)" +readonly OUTPUT_DIR="${OUTPUT_PARENT}/${RUN_STAMP}-${SOURCE_COMMIT:0:12}" +readonly IMAGE_TAG="pulse-rootful-qualification:${SOURCE_COMMIT:0:12}" +readonly CONTAINER_RUN_LABEL="org.pulse.rootful-qualification.run" +readonly CONTAINER_RUN_NONCE="$(openssl rand -hex 16)" +if docker image inspect "${IMAGE_TAG}" >/dev/null 2>&1; then + echo "ERROR: qualification image tag already exists; refusing to overwrite ${IMAGE_TAG}" >&2 + exit 2 +fi +PACKET_DIR="$(mktemp -d /tmp/pulse-rootful-packet.XXXXXX)" +CONTAINER_IDS=() +IMAGE_CREATED=false + +inspect_container_nonce() { + docker inspect --format '{{ index .Config.Labels "org.pulse.rootful-qualification.run" }}' "$1" +} + +remove_container_strict() { + local container_id="$1" + local observed_nonce remaining + observed_nonce="$(inspect_container_nonce "${container_id}")" || { + echo "ERROR: unable to verify ownership label for qualification container ${container_id}" >&2 + return 1 + } + if [[ "${observed_nonce}" != "${CONTAINER_RUN_NONCE}" ]]; then + echo "ERROR: qualification container ${container_id} ownership label changed" >&2 + return 1 + fi + docker rm -f "${container_id}" >/dev/null || return 1 + remaining="$(docker ps -aq --no-trunc --filter "id=${container_id}")" || return 1 + if printf '%s\n' "${remaining}" | grep -Fxq "${container_id}"; then + echo "ERROR: qualification container ${container_id} remains after removal" >&2 + return 1 + fi +} + +forget_container() { + local removed_id="$1" candidate + local retained=() + for candidate in "${CONTAINER_IDS[@]}"; do + [[ "${candidate}" == "${removed_id}" ]] || retained+=("${candidate}") + done + CONTAINER_IDS=("${retained[@]}") +} + +remove_container_best_effort() { + local container_id="$1" observed_nonce + observed_nonce="$(inspect_container_nonce "${container_id}" 2>/dev/null)" || { + echo "WARNING: unable to inspect qualification container ${container_id}; manual cleanup may be required" >&2 + return + } + if [[ "${observed_nonce}" != "${CONTAINER_RUN_NONCE}" ]]; then + echo "WARNING: refusing to remove qualification container after ownership-label mismatch: ${container_id}" >&2 + return + fi + docker rm -f "${container_id}" >/dev/null 2>&1 || \ + echo "WARNING: failed to remove qualification container ${container_id}" >&2 +} + +remove_image_strict() { + local observed_nonce + [[ "${IMAGE_CREATED}" == true ]] || return 0 + observed_nonce="$(docker image inspect --format '{{ index .Config.Labels "org.pulse.rootful-qualification.run" }}' "${IMAGE_TAG}")" || return 1 + if [[ "${observed_nonce}" != "${CONTAINER_RUN_NONCE}" ]]; then + echo "ERROR: qualification image ownership label changed" >&2 + return 1 + fi + docker image rm "${IMAGE_TAG}" >/dev/null || return 1 + IMAGE_CREATED=false +} + +cleanup() { + local container_id discovered discovered_ids="" + discovered_ids="$(docker ps -aq --no-trunc --filter "label=${CONTAINER_RUN_LABEL}=${CONTAINER_RUN_NONCE}" 2>/dev/null)" || true + for container_id in "${CONTAINER_IDS[@]}"; do + remove_container_best_effort "${container_id}" + done + while IFS= read -r discovered; do + [[ -n "${discovered}" ]] || continue + if [[ " ${CONTAINER_IDS[*]} " != *" ${discovered} "* ]]; then + remove_container_best_effort "${discovered}" + fi + done <<<"${discovered_ids}" + if [[ "${IMAGE_CREATED}" == true ]]; then + remove_image_strict >/dev/null 2>&1 || echo "WARNING: failed to remove qualification image ${IMAGE_TAG}" >&2 + fi + if [[ "${PACKET_DIR}" == /tmp/pulse-rootful-packet.* && -d "${PACKET_DIR}" ]]; then + find "${PACKET_DIR}" -type f -exec chmod u+w {} + 2>/dev/null || true + rm -rf -- "${PACKET_DIR}" + fi +} +trap cleanup EXIT INT TERM + +sha256_files() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$@" + else + shasum -a 256 "$@" + fi +} + +mkdir "${OUTPUT_DIR}" +chmod 0700 "${OUTPUT_DIR}" + +openssl genpkey -algorithm ED25519 -out "${PACKET_DIR}/update-private.pem" +chmod 0600 "${PACKET_DIR}/update-private.pem" +openssl pkey -in "${PACKET_DIR}/update-private.pem" -pubout -outform DER -out "${PACKET_DIR}/update-public.der" +chmod 0600 "${PACKET_DIR}/update-public.der" +update_public_key="$(python3 -I - "${PACKET_DIR}/update-public.der" <<'PY' +import base64 +import pathlib +import sys + +spki = pathlib.Path(sys.argv[1]).read_bytes() +prefix = bytes.fromhex("302a300506032b6570032100") +if len(spki) != len(prefix) + 32 or not spki.startswith(prefix): + raise SystemExit("unexpected Ed25519 SubjectPublicKeyInfo encoding") +print(base64.b64encode(spki[len(prefix):]).decode("ascii"), end="") +PY +)" +qualification_version="rootful-v1.${SOURCE_COMMIT:0:12}" +agent_ldflags="$(cd "${REPO_ROOT}" && ./scripts/release_ldflags.sh agent --version "${qualification_version}" --update-public-keys "${update_public_key}")" +helper_ldflags="$(cd "${REPO_ROOT}" && ./scripts/release_ldflags.sh agent --version "${qualification_version}" --update-public-keys "${update_public_key}")" + +( + cd "${REPO_ROOT}" + CGO_ENABLED=0 GOOS=linux GOARCH="$(go env GOARCH)" GOFLAGS= GOWORK=off go build -trimpath -buildvcs=true -ldflags "${agent_ldflags}" -o "${PACKET_DIR}/pulse-agent" ./cmd/pulse-agent + CGO_ENABLED=0 GOOS=linux GOARCH="$(go env GOARCH)" GOFLAGS= GOWORK=off go build -trimpath -buildvcs=true -ldflags "${helper_ldflags}" -o "${PACKET_DIR}/pulse-agent-helper" ./cmd/pulse-agent-helper + CGO_ENABLED=0 GOOS=linux GOARCH="$(go env GOARCH)" GOFLAGS= GOWORK=off go test -c -trimpath -buildvcs=true -o "${PACKET_DIR}/dockeragent.test" ./scripts/installtests +) +openssl pkeyutl -sign -rawin -inkey "${PACKET_DIR}/update-private.pem" -in "${PACKET_DIR}/pulse-agent" | openssl base64 -A >"${PACKET_DIR}/pulse-agent.sig" +printf '\n' >>"${PACKET_DIR}/pulse-agent.sig" +rm -f "${PACKET_DIR}/update-private.pem" "${PACKET_DIR}/update-public.der" +unset update_public_key agent_ldflags helper_ldflags +install -m 0700 "${REPO_ROOT}/scripts/install.sh" "${PACKET_DIR}/install.sh" +chmod 0700 "${PACKET_DIR}/pulse-agent" "${PACKET_DIR}/pulse-agent-helper" "${PACKET_DIR}/dockeragent.test" + +verify_vcs_artifact() { + local artifact="$1" expected_package="$2" metadata + metadata="$(go version -m "${artifact}")" || { + echo "ERROR: unable to inspect Go build metadata for ${artifact}" >&2 + return 1 + } + grep -Fq $'path\t'"${expected_package}" <<<"${metadata}" || { + echo "ERROR: artifact package identity mismatch for ${artifact}" >&2 + return 1 + } + grep -Fq "vcs.revision=${SOURCE_COMMIT}" <<<"${metadata}" || { + echo "ERROR: artifact vcs.revision does not match source commit: ${artifact}" >&2 + return 1 + } + grep -Fq 'vcs.modified=false' <<<"${metadata}" || { + echo "ERROR: artifact vcs.modified identity is not clean: ${artifact}" >&2 + return 1 + } +} + +verify_vcs_artifact "${PACKET_DIR}/pulse-agent" "github.com/rcourtman/pulse-go-rewrite/cmd/pulse-agent" +verify_vcs_artifact "${PACKET_DIR}/pulse-agent-helper" "github.com/rcourtman/pulse-go-rewrite/cmd/pulse-agent-helper" +verify_vcs_artifact "${PACKET_DIR}/dockeragent.test" "github.com/rcourtman/pulse-go-rewrite/scripts/installtests.test" + +python3 -I - "${REPO_ROOT}" "${PACKET_DIR}/source-hashes.json" <<'PY' +import importlib.util +import json +import pathlib +import sys + +checkout = pathlib.Path(sys.argv[1]).resolve(strict=True) +destination = pathlib.Path(sys.argv[2]) +validator_path = checkout / "scripts/release_control/secure_runtime_rootful_attestation_v1.py" +spec = importlib.util.spec_from_file_location("rootful_validator", validator_path) +if spec is None or spec.loader is None: + raise SystemExit("unable to load rootful receipt validator") +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +_, hashes = module.load_source_manifest(checkout, checkout / module.SOURCE_MANIFEST_PATH) +destination.write_text(json.dumps(hashes, sort_keys=True, separators=(",", ":")) + "\n") +PY +chmod 0600 "${PACKET_DIR}/source-hashes.json" "${PACKET_DIR}/pulse-agent.sig" + +cat >"${PACKET_DIR}/Dockerfile" </etc/apt/sources.list.d/docker.list && \ + apt-get update && apt-get install -y --no-install-recommends docker-ce docker-ce-cli containerd.io && \ + apt-get clean && rm -rf /var/lib/apt/lists/* && \ + ln -sf /dev/null /etc/systemd/system/docker.service && \ + ln -sf /dev/null /etc/systemd/system/docker.socket && \ + ln -sf /dev/null /etc/systemd/system/podman.service && \ + ln -sf /dev/null /etc/systemd/system/podman.socket && \ + install -d -m 0700 /opt/pulse/packet /opt/pulse/result && \ + printf '%s\n' disposable-v1 >/etc/pulse-secure-runtime-rootful-qualification && \ + rm -f /etc/machine-id && touch /etc/machine-id && \ + systemctl set-default multi-user.target +STOPSIGNAL SIGRTMIN+3 +CMD ["/sbin/init"] +EOF + +docker build --pull --no-cache --network default \ + --label "${CONTAINER_RUN_LABEL}=${CONTAINER_RUN_NONCE}" \ + -t "${IMAGE_TAG}" -f "${PACKET_DIR}/Dockerfile" "${PACKET_DIR}" | tee "${OUTPUT_DIR}/image-build.log" +IMAGE_CREATED=true +docker image inspect "${IMAGE_TAG}" >"${OUTPUT_DIR}/qualification-image-inspect.json" +chmod 0600 "${OUTPUT_DIR}/image-build.log" "${OUTPUT_DIR}/qualification-image-inspect.json" + +capture_qualification_container_diagnostics() { + local runtime_name="$1" container_id="$2" + docker logs "${container_id}" >"${OUTPUT_DIR}/${runtime_name}-container.log" 2>&1 || true + docker exec "${container_id}" journalctl --no-pager -n 2000 >"${OUTPUT_DIR}/${runtime_name}-journal.log" 2>&1 || true + chmod 0600 "${OUTPUT_DIR}/${runtime_name}-container.log" "${OUTPUT_DIR}/${runtime_name}-journal.log" +} + +run_runtime() { + local runtime_name="$1" + local container_name="pulse-rootful-qual-${runtime_name}-${SOURCE_COMMIT:0:8}-$$" + local container_id local_receipt machine_id_file machine_id deadline mounts + local_receipt="${OUTPUT_DIR}/${runtime_name}-receipt.json" + machine_id_file="${PACKET_DIR}/.machine-id-${runtime_name}" + machine_id="$(openssl rand -hex 16)" + if [[ ! "${machine_id}" =~ ^[0-9a-f]{32}$ || "${machine_id}" == "00000000000000000000000000000000" ]]; then + echo "ERROR: unable to generate a valid machine ID for ${runtime_name}" >&2 + return 1 + fi + printf '%s\n' "${machine_id}" >"${machine_id_file}" + chmod 0444 "${machine_id_file}" + + container_id="$(docker create --name "${container_name}" --hostname "pulse-rootful-${runtime_name}" \ + --label "${CONTAINER_RUN_LABEL}=${CONTAINER_RUN_NONCE}" \ + --privileged --network none --cgroupns=private \ + --tmpfs /run:rw,nosuid,nodev,mode=755 --tmpfs /run/lock:rw,nosuid,nodev,mode=755 \ + "${IMAGE_TAG}")" + CONTAINER_IDS+=("${container_id}") + docker cp "${machine_id_file}" "${container_id}:/etc/machine-id" + rm -f -- "${machine_id_file}" + docker cp "${PACKET_DIR}/." "${container_id}:/opt/pulse/packet" + docker start "${container_id}" >/dev/null + + deadline=$((SECONDS + 60)) + until docker exec "${container_id}" systemctl is-system-running --wait >/dev/null 2>&1; do + if (( SECONDS >= deadline )); then + capture_qualification_container_diagnostics "${runtime_name}" "${container_id}" + echo "ERROR: ${runtime_name} disposable systemd container did not become ready" >&2 + return 1 + fi + sleep 1 + done + if docker exec "${container_id}" sh -c 'ip route | grep -q "^default "'; then + echo "ERROR: ${runtime_name} qualification container unexpectedly has a default route" >&2 + return 1 + fi + mounts="$(docker inspect "${container_id}" --format '{{range .Mounts}}{{println .Source "->" .Destination}}{{end}}')" + if grep -E '/(var/)?run/(docker|podman)(\.sock)?' <<<"${mounts}"; then + echo "ERROR: host runtime socket was mounted into ${runtime_name} qualification container" >&2 + return 1 + fi + + if ! docker exec \ + -e PULSE_SECURE_RUNTIME_ROOTFUL_QUALIFICATION=disposable-v1 \ + -e "PULSE_ROOTFUL_RUNTIME=${runtime_name}" \ + -e PULSE_ROOTFUL_RECEIPT=/opt/pulse/result/rootful-receipt.json \ + -e PULSE_ROOTFUL_SOURCE_HASHES=/opt/pulse/packet/source-hashes.json \ + -e "PULSE_ROOTFUL_SOURCE_COMMIT=${SOURCE_COMMIT}" \ + -e PULSE_SECURE_RUNTIME_COLLECTOR=/opt/pulse/packet/pulse-agent \ + -e PULSE_SECURE_RUNTIME_COLLECTOR_SIGNATURE=/opt/pulse/packet/pulse-agent.sig \ + -e PULSE_SECURE_RUNTIME_HELPER=/opt/pulse/packet/pulse-agent-helper \ + -e PULSE_SECURE_RUNTIME_INSTALLER=/opt/pulse/packet/install.sh \ + "${container_id}" /opt/pulse/packet/dockeragent.test \ + -test.run '^TestSecureRuntimeRootfulQualification$' -test.count=1 -test.v -test.timeout=45m \ + | tee "${OUTPUT_DIR}/${runtime_name}-test.log"; then + capture_qualification_container_diagnostics "${runtime_name}" "${container_id}" + chmod 0600 "${OUTPUT_DIR}/${runtime_name}-test.log" + return 1 + fi + docker exec "${container_id}" test -f /opt/pulse/result/rootful-receipt.json || { + capture_qualification_container_diagnostics "${runtime_name}" "${container_id}" + echo "ERROR: ${runtime_name} qualification did not retain its receipt" >&2 + return 1 + } + docker cp "${container_id}:/opt/pulse/result/rootful-receipt.json" "${local_receipt}" + capture_qualification_container_diagnostics "${runtime_name}" "${container_id}" + chmod 0600 "${local_receipt}" "${OUTPUT_DIR}/${runtime_name}-test.log" + remove_container_strict "${container_id}" + forget_container "${container_id}" +} + +run_runtime docker +run_runtime podman + +python3 -I - "${OUTPUT_DIR}/docker-receipt.json" "${OUTPUT_DIR}/podman-receipt.json" "${OUTPUT_DIR}/receipt.json" <<'PY' +import json +import pathlib +import sys + +docker_path, podman_path, output_path = map(pathlib.Path, sys.argv[1:]) +docker = json.loads(docker_path.read_text()) +podman = json.loads(podman_path.read_text()) +if docker.get("result") != "passed" or podman.get("result") != "passed": + raise SystemExit('per-runtime qualification result != "passed"') +for field in ("schema_version", "kind", "source_commit", "source_hashes", "artifacts"): + if docker.get(field) != podman.get(field): + raise SystemExit(f"per-runtime qualification field differs: {field}") +runs = docker.get("runs", []) + podman.get("runs", []) +if [run.get("runtime", {}).get("runtime") for run in runs] != ["docker", "podman"]: + raise SystemExit("per-runtime receipts are not exact Docker then Podman runs") +machine_ids = [run.get("host", {}).get("machine_id") for run in runs] +daemon_ids = [run.get("runtime", {}).get("daemon_id") for run in runs] +if len(set(machine_ids)) != 2 or len(set(daemon_ids)) != 2: + raise SystemExit("Docker and Podman qualification hosts/daemons must have distinct identities") +combined = { + "schema_version": docker["schema_version"], "kind": docker["kind"], "result": "passed", + "source_commit": docker["source_commit"], + "started_at": min(docker["started_at"], podman["started_at"]), + "completed_at": max(docker["completed_at"], podman["completed_at"]), + "source_hashes": docker["source_hashes"], "artifacts": docker["artifacts"], "runs": runs, +} +output_path.write_text(json.dumps(combined, indent=2, sort_keys=True) + "\n") +PY +chmod 0600 "${OUTPUT_DIR}/receipt.json" + +python3 -I "${REPO_ROOT}/scripts/release_control/secure_runtime_rootful_attestation_v1.py" \ + "${OUTPUT_DIR}/receipt.json" \ + --qualification-test "${PACKET_DIR}/dockeragent.test" \ + --collector "${PACKET_DIR}/pulse-agent" \ + --helper "${PACKET_DIR}/pulse-agent-helper" \ + --installer "${PACKET_DIR}/install.sh" \ + >"${OUTPUT_DIR}/attestation.json" +chmod 0600 "${OUTPUT_DIR}/attestation.json" + +sha256_files "${OUTPUT_DIR}/receipt.json" "${OUTPUT_DIR}/attestation.json" >"${OUTPUT_DIR}/sha256.txt" +chmod 0600 "${OUTPUT_DIR}/sha256.txt" + +if [[ -n "$(docker ps -aq --no-trunc --filter "label=${CONTAINER_RUN_LABEL}=${CONTAINER_RUN_NONCE}")" ]]; then + echo "ERROR: labeled qualification containers remain after strict cleanup" >&2 + exit 1 +fi +remove_image_strict +if [[ -n "$(docker images -q --filter "label=${CONTAINER_RUN_LABEL}=${CONTAINER_RUN_NONCE}")" ]]; then + echo "ERROR: labeled qualification images remain after strict cleanup" >&2 + exit 1 +fi +while IFS= read -r retained; do + [[ -z "${retained}" ]] && continue + if [[ "$(portable_uid "${retained}")" != "$(id -u)" ]]; then + echo "ERROR: retained output is not owned by the invoking user: ${retained}" >&2 + exit 1 + fi + chmod go-rwx "${retained}" +done < <(find "${OUTPUT_DIR}" -mindepth 1 -maxdepth 1 -type f -print) + +echo "Rootful qualification passed: ${OUTPUT_DIR}"