Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changes/unreleased/+application-kernel-sandbox.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
kind: Security
body: Run application containers with canonical supplementary groups, no Linux capabilities, no-new-privileges, and Docker's built-in seccomp profile.
15 changes: 7 additions & 8 deletions docs/APT_PROVIDER_DETAIL_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2166,14 +2166,13 @@ an adjacent hidden staging directory and publishes its fixed regular-file
result atomically after success. The chosen host path is operation state, not a
build identity input.

Each one-shot command and `reploy shell` mounts a fresh anonymous Docker volume
at `/mnt/reploy-home` for `HOME` and `TMPDIR`. It is disk-backed, unnamed, and
removed with the transient container. The embedded platform helper assigns the
empty volume to the selected runtime UID/GID, restricts it to that identity,
drops root privileges, and directly executes the requested command. Explicit
interruption cleanup uses forced container removal with anonymous-volume
removal. Workload containers retain a separate tmpfs home at
`/mnt/reploy-home`.
Each one-shot command and `reploy shell` mounts a fresh 64 MiB tmpfs at
`/mnt/reploy-home` for `HOME` and `TMPDIR`. The mount is mode `0700`, owned by
the selected runtime UID/GID, and removed with the transient container. Docker
starts the resolved executable directly under that final numeric identity; no
root bootstrap helper is involved. Explicit interruption cleanup force-removes
the transient container. Workload containers use the same bounded tmpfs-home
policy at `/mnt/reploy-home`.

The policy digest is
`canonical.Sum("runtime-policy", "runtime-policy-v1", policy)`. It is recorded
Expand Down
10 changes: 10 additions & 0 deletions docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ This file is the day-to-day queue for design and implementation gaps.
protection, build locks, cache identity, and actionable missing-export
diagnostics. Do not add a parallel direct-command form such as `base.rm`.

- [ ] `P1` Implement the promised Docker Desktop runtime identity.
Current macOS and Windows user-scope planning does not provide the stable
Reploy-managed non-root Linux identity described by the environment
model; native Windows runtime discovery can currently collapse to
container identity `0:0`. Define and persist a non-root identity for
Docker Desktop, propagate its canonical supplementary groups, reject
accidental root, and add native macOS/Windows plus Docker integration
evidence across staged and installed current-user workloads and
transient commands.

- [ ] `P2` Define cancellation at the admission boundary.
Specify the authoritative outcome when a waiting caller is promoted at
the same instant its context is cancelled. Preserve the invariant that
Expand Down
18 changes: 8 additions & 10 deletions docs/BLUEPRINT_ENVIRONMENT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,16 +277,14 @@ recovery. The two output options are mutually exclusive. Reploy never
discovers or copies arbitrary files from the transient container.

The transient `$HOME` is operation-local workspace, not an output channel.
Docker backs `/mnt/reploy-home` with a fresh anonymous volume for each one-shot
container. Normal `--rm` completion removes the volume, and Reploy's
interruption cleanup force-removes the container with its anonymous volumes.
It is disk-backed rather than a size-limited tmpfs, but it is never named or
reused. Before launching the requested command, Reploy's embedded Linux helper
assigns the empty volume to the selected runtime UID/GID, restricts it to that
identity, drops root privileges, and directly executes the command. Files
survive a one-shot invocation only through its selected output contract or
another writable mount declared by the blueprint. The persistent workload
container keeps its separate tmpfs home at the same container path.
Docker backs `/mnt/reploy-home` with a fresh 64 MiB tmpfs for each one-shot
container. The mount is restricted to mode `0700`, owned by the selected
runtime UID/GID, and removed with the container. Docker starts the resolved
executable directly as that final numeric identity; no root bootstrap helper
is involved. Files survive a one-shot invocation only through its selected
output contract or another writable mount declared by the blueprint. The
persistent workload container uses the same bounded tmpfs-home policy at the
same container path.

Lifecycle actions invoke commands through the same one-shot mechanism. An
`after_start` command may communicate with the running workload through its
Expand Down
11 changes: 11 additions & 0 deletions docs/CONTROLLED_SESSION_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1038,6 +1038,17 @@ ordinary Reploy application containers. Prove staged workloads, installed
workloads, transient commands, shells, and later controlled sessions consume
the same baseline. This is global runtime work, not controlled-session code.

Implementation status: the canonical application sandbox plan and its identity
and kernel baseline are implemented for persistent Compose workloads and
transient application commands. Reploy now imports canonical supplementary
groups, rejects root-group membership for non-root identities, starts transient
commands directly as the final identity, drops all capabilities, enables
`no-new-privileges`, explicitly selects Docker's built-in seccomp profile, and
prohibits privileged mode, host namespaces, and host devices in the common
plan. Live Docker tests inspect both runtime paths. Trusted production startup
verification, mount/root authority, network denial, and resource limits remain
separate prerequisite slices.

### Slice 2: Controlled-Session Lifecycle Core

Using synthetic controller and workload images with networking disabled:
Expand Down
8 changes: 5 additions & 3 deletions docs/REDESIGN_EVALUATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ integration testing against a real Docker daemon.

Fix operation-local home initialization for non-root transient containers.
The solution must apply consistently to shells, app commands, and lifecycle
actions; preserve the read-only image filesystem; retain anonymous-volume
cleanup; and avoid host-owned persistent state.
actions; preserve the read-only image filesystem; retain operation-local home
isolation; and avoid host-owned persistent state. The original anonymous-volume
implementation was subsequently simplified to a bounded UID/GID-owned tmpfs,
which also removed the trusted root bootstrap helper.

**Primary finding:** `Transient shell home is not writable by the runtime user`.

Expand All @@ -42,7 +44,7 @@ cleanup; and avoid host-owned persistent state.
- a transient container running as a numeric non-root UID/GID can create,
modify, and remove files under `$HOME` and `$TMPDIR`;
- its image filesystem remains read-only;
- its anonymous home volume is removed after normal exit and forced stop; and
- its bounded tmpfs home is removed after normal exit and forced stop; and
- shell, app-command, and lifecycle integration tests exercise the shared path.

### Slice 2: Stop app commands from refreshing the environment
Expand Down
66 changes: 66 additions & 0 deletions internal/dockerdeploy/application_sandbox_plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,21 @@ package dockerdeploy
import (
"fmt"
"path"
"slices"
"strconv"
)

const applicationSeccompProfileBuiltinV1 = "builtin"

type ApplicationKernelPolicyV1 struct {
DropAllCapabilities bool
NoNewPrivileges bool
SeccompProfile string
Privileged bool
HostNamespaces []string
HostDevices []string
}

// ApplicationSandboxPlanV1 is the common security boundary consumed by every
// application-container renderer. It contains only policies that Reploy
// currently enforces; later sandbox slices extend this plan rather than adding
Expand All @@ -14,13 +26,21 @@ type ApplicationSandboxPlanV1 struct {
RuntimeUser RuntimeUserPlan
ReadOnlyRoot bool
TemporaryHome string
Kernel ApplicationKernelPolicyV1
}

func newApplicationSandboxPlanV1(runtimeUser RuntimeUserPlan) ApplicationSandboxPlanV1 {
return ApplicationSandboxPlanV1{
RuntimeUser: runtimeUser,
ReadOnlyRoot: true,
TemporaryHome: environmentTemporaryHome,
Kernel: ApplicationKernelPolicyV1{
DropAllCapabilities: true,
NoNewPrivileges: true,
SeccompProfile: applicationSeccompProfileBuiltinV1,
HostNamespaces: []string{},
HostDevices: []string{},
},
}
}

Expand All @@ -32,11 +52,57 @@ func ValidateApplicationSandboxPlanV1(plan ApplicationSandboxPlanV1) error {
if plan.RuntimeUser.DockerUser != wantUser {
return fmt.Errorf("application sandbox Docker user must match its numeric UID and GID")
}
wantGroups, err := normalizeSupplementaryGIDsV1(plan.RuntimeUser.GID, plan.RuntimeUser.SupplementaryGIDs)
if err != nil {
return fmt.Errorf("application sandbox supplementary groups: %w", err)
}
if !slices.Equal(plan.RuntimeUser.SupplementaryGIDs, wantGroups) {
return fmt.Errorf("application sandbox supplementary groups must be unique, sorted, and exclude the primary GID")
}
if plan.RuntimeUser.UID != 0 {
if plan.RuntimeUser.GID == 0 || slices.Contains(plan.RuntimeUser.SupplementaryGIDs, 0) {
return fmt.Errorf("non-root application sandbox identity must not include the root group")
}
}
if !plan.ReadOnlyRoot {
return fmt.Errorf("application sandbox requires a read-only container root")
}
if plan.TemporaryHome != environmentTemporaryHome || !path.IsAbs(plan.TemporaryHome) || path.Clean(plan.TemporaryHome) != plan.TemporaryHome {
return fmt.Errorf("application sandbox temporary home must be %s", environmentTemporaryHome)
}
if !plan.Kernel.DropAllCapabilities {
return fmt.Errorf("application sandbox must drop all Linux capabilities")
}
if !plan.Kernel.NoNewPrivileges {
return fmt.Errorf("application sandbox must enable no-new-privileges")
}
if plan.Kernel.SeccompProfile != applicationSeccompProfileBuiltinV1 {
return fmt.Errorf("application sandbox seccomp profile must be %q", applicationSeccompProfileBuiltinV1)
}
if plan.Kernel.Privileged {
return fmt.Errorf("application sandbox must not use privileged mode")
}
if plan.Kernel.HostNamespaces == nil || len(plan.Kernel.HostNamespaces) != 0 {
return fmt.Errorf("application sandbox must prohibit host namespaces")
}
if plan.Kernel.HostDevices == nil || len(plan.Kernel.HostDevices) != 0 {
return fmt.Errorf("application sandbox must prohibit host devices")
}
return nil
}

func normalizeSupplementaryGIDsV1(primary int, values []int) ([]int, error) {
result := append([]int(nil), values...)
for _, gid := range result {
if gid < 0 {
return nil, fmt.Errorf("GID must be non-negative")
}
}
slices.Sort(result)
result = slices.Compact(result)
result = slices.DeleteFunc(result, func(gid int) bool { return gid == primary })
if result == nil {
result = []int{}
}
return result, nil
}
57 changes: 44 additions & 13 deletions internal/dockerdeploy/application_sandbox_plan_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package dockerdeploy

import (
"slices"
"strings"
"testing"

Expand All @@ -9,11 +10,6 @@ import (
)

func TestApplicationRenderersConsumeCanonicalSandboxPlan(t *testing.T) {
platform, err := blueprint.ParsePlatform("linux/amd64")
if err != nil {
t.Fatal(err)
}
workspace := testPreparedProbeWorkspace(t, platform, t.TempDir())
plan := DockerExecutionPlan{
EnvironmentID: "demo",
DeploymentDir: t.TempDir(),
Expand All @@ -22,7 +18,7 @@ func TestApplicationRenderersConsumeCanonicalSandboxPlan(t *testing.T) {
ContainerName: "demo-staging-abcd",
NetworkName: "demo-staging-abcd",
Sandbox: newApplicationSandboxPlanV1(RuntimeUserPlan{
UID: 501, GID: 20, DockerUser: "501:20",
UID: 501, GID: 20, SupplementaryGIDs: []int{33, 44}, DockerUser: "501:20",
}),
}

Expand All @@ -38,6 +34,9 @@ func TestApplicationRenderersConsumeCanonicalSandboxPlan(t *testing.T) {
if service.User != plan.Sandbox.RuntimeUser.DockerUser || !service.ReadOnly {
t.Fatalf("persistent sandbox identity/read-only = user %q, read-only %t", service.User, service.ReadOnly)
}
if !slices.Equal(service.GroupAdd, []string{"33", "44"}) || !slices.Equal(service.CapDrop, []string{"ALL"}) || !slices.Equal(service.SecurityOpt, []string{"no-new-privileges:true", "seccomp=builtin"}) {
t.Fatalf("persistent kernel sandbox = groups %#v, caps %#v, security %#v", service.GroupAdd, service.CapDrop, service.SecurityOpt)
}
if service.Environment["HOME"] != plan.Sandbox.TemporaryHome || service.Environment["TMPDIR"] != plan.Sandbox.TemporaryHome {
t.Fatalf("persistent sandbox environment = %#v", service.Environment)
}
Expand All @@ -48,15 +47,14 @@ func TestApplicationRenderersConsumeCanonicalSandboxPlan(t *testing.T) {
transient, err := TransientCommandSpec(
plan,
ResolvedEnvironmentCommand{Argv: []string{"/bin/true"}},
workspace,
nil,
false,
false,
)
if err != nil {
t.Fatal(err)
}
if !containsInOrder(transient.Args, []string{"--read-only", "--mount", transientHomeMountForPlan(plan)}) {
if !containsInOrder(transient.Args, []string{"--read-only", "--tmpfs", transientHomeMountForPlan(plan)}) {
t.Fatalf("transient sandbox read-only root/home = %#v", transient.Args)
}
if !containsInOrder(transient.Args, []string{
Expand All @@ -65,10 +63,10 @@ func TestApplicationRenderersConsumeCanonicalSandboxPlan(t *testing.T) {
}) {
t.Fatalf("transient sandbox environment = %#v", transient.Args)
}
if !containsInOrder(transient.Args, []string{
"--entrypoint", ProbeContainerExecutable,
plan.Image, "run-transient", "501", "20", "/bin/true",
}) {
if !containsInOrder(transient.Args, []string{"--user", "501:20", "--cap-drop", "ALL"}) ||
!containsInOrder(transient.Args, []string{"--group-add", "33", "--group-add", "44"}) ||
!containsInOrder(transient.Args, []string{"--security-opt", "no-new-privileges=true", "--security-opt", "seccomp=builtin"}) ||
!containsInOrder(transient.Args, []string{"--entrypoint", "/bin/true", plan.Image}) {
t.Fatalf("transient sandbox runtime identity = %#v", transient.Args)
}

Expand All @@ -77,11 +75,44 @@ func TestApplicationRenderersConsumeCanonicalSandboxPlan(t *testing.T) {
if _, err := RenderDockerInputs(invalid, "demo"); err == nil || !strings.Contains(err.Error(), "read-only container root") {
t.Fatalf("persistent invalid sandbox error = %v", err)
}
if _, err := TransientCommandSpec(invalid, ResolvedEnvironmentCommand{Argv: []string{"/bin/true"}}, workspace, nil, false, false); err == nil || !strings.Contains(err.Error(), "read-only container root") {
if _, err := TransientCommandSpec(invalid, ResolvedEnvironmentCommand{Argv: []string{"/bin/true"}}, nil, false, false); err == nil || !strings.Contains(err.Error(), "read-only container root") {
t.Fatalf("transient invalid sandbox error = %v", err)
}
}

func TestApplicationSandboxPlanRejectsIdentityAndKernelEscapes(t *testing.T) {
base := newApplicationSandboxPlanV1(RuntimeUserPlan{
UID: 501, GID: 20, SupplementaryGIDs: []int{33, 44}, DockerUser: "501:20",
})
tests := []struct {
name string
mutate func(*ApplicationSandboxPlanV1)
want string
}{
{name: "root primary group", mutate: func(plan *ApplicationSandboxPlanV1) { plan.RuntimeUser.GID, plan.RuntimeUser.DockerUser = 0, "501:0" }, want: "root group"},
{name: "root supplementary group", mutate: func(plan *ApplicationSandboxPlanV1) { plan.RuntimeUser.SupplementaryGIDs = []int{0, 33} }, want: "root group"},
{name: "noncanonical groups", mutate: func(plan *ApplicationSandboxPlanV1) { plan.RuntimeUser.SupplementaryGIDs = []int{44, 33} }, want: "unique, sorted"},
{name: "capabilities", mutate: func(plan *ApplicationSandboxPlanV1) { plan.Kernel.DropAllCapabilities = false }, want: "drop all"},
{name: "privilege escalation", mutate: func(plan *ApplicationSandboxPlanV1) { plan.Kernel.NoNewPrivileges = false }, want: "no-new-privileges"},
{name: "seccomp", mutate: func(plan *ApplicationSandboxPlanV1) { plan.Kernel.SeccompProfile = "" }, want: "seccomp"},
{name: "privileged", mutate: func(plan *ApplicationSandboxPlanV1) { plan.Kernel.Privileged = true }, want: "privileged"},
{name: "host namespace", mutate: func(plan *ApplicationSandboxPlanV1) { plan.Kernel.HostNamespaces = []string{"pid"} }, want: "host namespaces"},
{name: "host device", mutate: func(plan *ApplicationSandboxPlanV1) { plan.Kernel.HostDevices = []string{"/dev/kvm"} }, want: "host devices"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
plan := base
plan.RuntimeUser.SupplementaryGIDs = append([]int(nil), base.RuntimeUser.SupplementaryGIDs...)
plan.Kernel.HostNamespaces = []string{}
plan.Kernel.HostDevices = []string{}
test.mutate(&plan)
if err := ValidateApplicationSandboxPlanV1(plan); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("error = %v, want %q", err, test.want)
}
})
}
}

func containsString(values []string, want string) bool {
for _, value := range values {
if value == want {
Expand Down
Loading
Loading