From 861f0f07bc0067c84cc035935c9b3d00e855baf8 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:47:15 +0100 Subject: [PATCH] Persist rootless qualification receipts --- .../v6/internal/subsystems/agent-lifecycle.md | 3 +++ .../subsystems/deployment-installability.md | 5 ++++- ...ure_runtime_rootless_qualification_test.go | 22 ++++++++++++++----- ...n-secure-runtime-rootless-qualification.sh | 11 +++++++--- 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index bb4938e60..259bb5860 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -7338,6 +7338,9 @@ standalone `secure-runtime-rootless-v1` receipt only after exact socket ownership, daemon rootless attestation, installer pinning, direct telemetry, same-family typed-helper fallback, recovery without collector restart, ambiguity refusal, authority isolation, and cleanup are recorded. +Receipt output uses a dedicated root-owned, mode-`0700` `/opt/pulse/result` +boundary instead of volatile `/run` state, so systemd user-manager teardown +cannot erase a passing result before the wrapper retains it. The wrapper must compile every Go artifact with mandatory VCS stamping so a missing revision or unavailable clean-worktree proof fails before live evidence can be emitted; the receipt and validator retain the exact artifact hashes and diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index f7c02701f..c2643425b 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -261,7 +261,10 @@ network-isolated Ubuntu/systemd hosts for real rootless Docker and Podman, never mounts a host daemon socket, and records each host identity separately. The exact qualification packet remains outside the image layers; the image must pre-create its root-owned, mode-`0700` packet destination before the -wrapper injects artifacts into each stopped disposable container. +wrapper injects artifacts into each stopped disposable container. The image +must also pre-create the separate root-owned, mode-`0700` `/opt/pulse/result` +output boundary; receipts are written there instead of volatile `/run` state so +systemd user-manager cleanup cannot erase completed qualification evidence. Rootless Docker uses its supported `slirp4netns` driver inside the outer `--network none` container only; an unsupported `host` RootlessKit driver or an outer default route invalidates the proof. The disposable host uses a diff --git a/scripts/installtests/secure_runtime_rootless_qualification_test.go b/scripts/installtests/secure_runtime_rootless_qualification_test.go index 03b5af7ad..157684de7 100644 --- a/scripts/installtests/secure_runtime_rootless_qualification_test.go +++ b/scripts/installtests/secure_runtime_rootless_qualification_test.go @@ -38,6 +38,8 @@ const ( rootlessQualOptIn = "PULSE_SECURE_RUNTIME_ROOTLESS_QUALIFICATION" rootlessQualOptInValue = "disposable-v1" rootlessQualMarker = "/etc/pulse-secure-runtime-rootless-qualification" + rootlessQualReceiptPath = "/opt/pulse/result/rootless-receipt.json" + rootlessQualResultDir = "/opt/pulse/result" rootlessQualFixture = "pulse-rootless-qualification-fixture:v1" rootlessQualRunningName = "pulse-rootless-running" rootlessQualExitedName = "pulse-rootless-exited" @@ -520,8 +522,16 @@ func rootlessQualRequireDisposableHost(t *testing.T, runtimeKind, receiptPath st if err != nil || strings.TrimSpace(string(marker)) != rootlessQualOptInValue { t.Fatalf("disposable marker is absent or invalid: %v", err) } - if !filepath.IsAbs(receiptPath) || filepath.Clean(receiptPath) != receiptPath { - t.Fatalf("PULSE_ROOTLESS_RECEIPT must be an exact absolute path: %q", receiptPath) + if receiptPath != rootlessQualReceiptPath { + t.Fatalf("PULSE_ROOTLESS_RECEIPT must use the dedicated durable output path %q: %q", rootlessQualReceiptPath, receiptPath) + } + resultInfo, err := os.Lstat(rootlessQualResultDir) + if err != nil { + t.Fatalf("inspect rootless qualification result directory: %v", err) + } + resultStat, ok := resultInfo.Sys().(*syscall.Stat_t) + if !ok || !resultInfo.IsDir() || resultInfo.Mode()&os.ModeSymlink != 0 || resultInfo.Mode().Perm() != 0o700 || resultStat.Uid != 0 { + t.Fatalf("rootless qualification result directory must be a root-owned, non-symlink directory with mode 0700: mode=%s stat=%#v", resultInfo.Mode(), resultInfo.Sys()) } osRelease := string(rootlessQualReadFile(t, "/etc/os-release")) if !strings.Contains(osRelease, "VERSION_ID=\"24.04\"") && !strings.Contains(osRelease, "VERSION_ID=24.04") { @@ -1597,7 +1607,9 @@ func TestRootlessQualificationGuardAndWrapperInvariants(t *testing.T) { `run_runtime docker`, `run_runtime podman`, `PULSE_ROOTLESS_RUNTIME=${runtime_name}`, `--privileged`, `pulse-secure-runtime-rootless-qualification`, `qualification result != \"passed\"`, `openssl pkeyutl -sign -rawin -inkey`, `qualification output directory must have exact mode 0700`, - `install -d -m 0700 /opt/pulse/packet`, + `install -d -m 0700 /opt/pulse/packet /opt/pulse/result`, + `PULSE_ROOTLESS_RECEIPT=/opt/pulse/result/rootless-receipt.json`, + `docker cp "${container_id}:/opt/pulse/result/rootless-receipt.json"`, `capture_qualification_container_diagnostics`, `journalctl --no-pager -n 2000`, `302a300506032b6570032100`, `len(spki) != len(prefix) + 32`, } { @@ -1608,7 +1620,7 @@ func TestRootlessQualificationGuardAndWrapperInvariants(t *testing.T) { if count := strings.Count(script, `-buildvcs=true`); count != 3 { t.Fatalf("rootless qualification wrapper must require VCS metadata for all three Go artifacts: got %d", count) } - for _, forbidden := range []string{"/var/run/docker.sock:", "/run/podman/podman.sock:", "/sys/fs/cgroup:/sys/fs/cgroup", "--cgroupns=host", "-v $", "--volume"} { + for _, forbidden := range []string{"/var/run/docker.sock:", "/run/podman/podman.sock:", "/sys/fs/cgroup:/sys/fs/cgroup", "/run/rootless-receipt.json", "--cgroupns=host", "-v $", "--volume"} { if strings.Contains(script, forbidden) { t.Fatalf("rootless qualification wrapper contains forbidden host-runtime mount marker %q", forbidden) } @@ -1621,7 +1633,7 @@ func TestRootlessQualificationGuardAndWrapperInvariants(t *testing.T) { if createIndex < 0 || trackIndex < 0 || trackIndex < createIndex { t.Fatal("rootless wrapper must track the exact container ID only after docker create succeeds") } - packetDirectoryIndex := strings.Index(script, `install -d -m 0700 /opt/pulse/packet`) + packetDirectoryIndex := strings.Index(script, `install -d -m 0700 /opt/pulse/packet /opt/pulse/result`) packetCopyIndex := strings.Index(script, `docker cp "${PACKET_DIR}/." "${container_id}:/opt/pulse/packet"`) if packetDirectoryIndex < 0 || packetCopyIndex < 0 || packetCopyIndex < packetDirectoryIndex { t.Fatal("rootless wrapper must create the private packet destination in the image before artifact injection") diff --git a/scripts/run-secure-runtime-rootless-qualification.sh b/scripts/run-secure-runtime-rootless-qualification.sh index 907936b1f..d51f555b3 100755 --- a/scripts/run-secure-runtime-rootless-qualification.sh +++ b/scripts/run-secure-runtime-rootless-qualification.sh @@ -198,7 +198,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ 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 && \ + install -d -m 0700 /opt/pulse/packet /opt/pulse/result && \ printf '%s\n' disposable-v1 >/etc/pulse-secure-runtime-rootless-qualification && \ rm -f /etc/machine-id && touch /etc/machine-id && \ systemctl set-default multi-user.target @@ -253,7 +253,7 @@ run_runtime() { if ! docker exec \ -e PULSE_SECURE_RUNTIME_ROOTLESS_QUALIFICATION=disposable-v1 \ -e "PULSE_ROOTLESS_RUNTIME=${runtime_name}" \ - -e PULSE_ROOTLESS_RECEIPT=/run/rootless-receipt.json \ + -e PULSE_ROOTLESS_RECEIPT=/opt/pulse/result/rootless-receipt.json \ -e PULSE_ROOTLESS_SOURCE_HASHES=/opt/pulse/packet/source-hashes.json \ -e "PULSE_ROOTLESS_SOURCE_COMMIT=${SOURCE_COMMIT}" \ -e PULSE_SECURE_RUNTIME_COLLECTOR=/opt/pulse/packet/pulse-agent \ @@ -268,7 +268,12 @@ run_runtime() { return 1 fi - docker cp "${container_id}:/run/rootless-receipt.json" "${local_receipt}" + if ! docker exec "${container_id}" test -f /opt/pulse/result/rootless-receipt.json; then + capture_qualification_container_diagnostics "${runtime_name}" "${container_id}" + echo "ERROR: ${runtime_name} qualification did not retain its durable receipt" >&2 + return 1 + fi + docker cp "${container_id}:/opt/pulse/result/rootless-receipt.json" "${local_receipt}" capture_qualification_container_diagnostics "${runtime_name}" "${container_id}" chmod 0600 "${local_receipt}" "${OUTPUT_DIR}/${runtime_name}-test.log" remove_qualification_container_strict "${container_id}"