diff --git a/.changes/unreleased/+thread-local-sandbox-verification.yaml b/.changes/unreleased/+thread-local-sandbox-verification.yaml new file mode 100644 index 0000000..fedd4c6 --- /dev/null +++ b/.changes/unreleased/+thread-local-sandbox-verification.yaml @@ -0,0 +1,2 @@ +kind: Fixed +body: Verify application sandbox capabilities on the exact Linux thread that will launch the workload. diff --git a/docs/CONTROLLED_SESSION_DESIGN.md b/docs/CONTROLLED_SESSION_DESIGN.md index b4df344..ec971ba 100644 --- a/docs/CONTROLLED_SESSION_DESIGN.md +++ b/docs/CONTROLLED_SESSION_DESIGN.md @@ -1197,7 +1197,7 @@ in a final runtime layer, creates the locked container-local account there, records that layer outside the provider graph, and uses its fixed sandbox-and-exec contract as the outermost process for persistent workloads, transient commands, shells, and lifecycle commands. The verifier -fails closed unless `/proc/self/status` reports seccomp filtering, +fails closed unless `/proc/thread-self/status` reports seccomp filtering, `no-new-privileges`, and empty inheritable, effective, permitted, bounding, and ambient capability sets, then directly executes the exact application argv. Private-environment diff --git a/internal/dockerdeploy/private_workload_environment_integration_test.go b/internal/dockerdeploy/private_workload_environment_integration_test.go index 76ea473..1454574 100644 --- a/internal/dockerdeploy/private_workload_environment_integration_test.go +++ b/internal/dockerdeploy/private_workload_environment_integration_test.go @@ -21,7 +21,7 @@ func TestPrivateWorkloadEnvironmentDockerIntegrationMasksFilesAndInjectsValues(t if os.Getenv("REPLOY_DOCKER_INTEGRATION") != "1" { t.Skip("set REPLOY_DOCKER_INTEGRATION=1 to run Docker integration evidence") } - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) defer cancel() image, _ := buildApplicationStartupVerifierIntegrationImage(t, ctx) @@ -152,7 +152,7 @@ func TestPrivateRuntimeMasksDockerIntegrationProtectTransientContainer(t *testin if os.Getenv("REPLOY_DOCKER_INTEGRATION") != "1" { t.Skip("set REPLOY_DOCKER_INTEGRATION=1 to run Docker integration evidence") } - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) defer cancel() image, _ := buildApplicationStartupVerifierIntegrationImage(t, ctx) diff --git a/internal/probe/startup_verifier.go b/internal/probe/startup_verifier.go index a3651a9..3fbd646 100644 --- a/internal/probe/startup_verifier.go +++ b/internal/probe/startup_verifier.go @@ -9,7 +9,11 @@ import ( "strings" ) -const applicationKernelStatusPath = "/proc/self/status" +// Sandbox setup is deliberately pinned to one OS thread because Linux +// credentials and capability sets are thread-scoped. /proc/self/status +// describes the thread-group leader, which may be a different Go runtime +// thread; verify the exact thread that will exec the application instead. +const applicationKernelStatusPath = "/proc/thread-self/status" var requiredApplicationKernelStatusV1 = []struct { name string diff --git a/internal/probe/startup_verifier_linux_test.go b/internal/probe/startup_verifier_linux_test.go new file mode 100644 index 0000000..1a6f667 --- /dev/null +++ b/internal/probe/startup_verifier_linux_test.go @@ -0,0 +1,68 @@ +//go:build linux + +package probe + +import ( + "os" + "runtime" + "strconv" + "strings" + "testing" + + "golang.org/x/sys/unix" +) + +func TestReadApplicationKernelStatusUsesCallingThread(t *testing.T) { + type result struct { + tid int + content []byte + err error + } + results := make(chan result, 2) + release := make(chan struct{}) + done := make(chan struct{}, 2) + defer func() { + close(release) + for range 2 { + <-done + } + }() + for range 2 { + go func() { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + defer func() { done <- struct{}{} }() + content, err := readApplicationKernelStatus() + results <- result{tid: unix.Gettid(), content: content, err: err} + <-release + }() + } + + observedNonLeader := false + for range 2 { + result := <-results + if result.err != nil { + t.Fatal(result.err) + } + if result.tid != os.Getpid() { + observedNonLeader = true + } + var statusPID string + for _, line := range strings.Split(string(result.content), "\n") { + name, raw, found := strings.Cut(line, ":") + if found && name == "Pid" { + fields := strings.Fields(raw) + if len(fields) == 1 { + statusPID = fields[0] + } + break + } + } + if statusPID != strconv.Itoa(result.tid) { + t.Fatalf("status Pid = %q, want calling thread %d", statusPID, result.tid) + } + } + if !observedNonLeader { + t.Fatal("test did not observe a non-leader OS thread") + } +}