From c1226568e193d59669d51ceb5c9d78001bd82d17 Mon Sep 17 00:00:00 2001 From: Omry Yadan Date: Thu, 6 Aug 2026 02:17:42 +0800 Subject: [PATCH] Enforce root host filesystem boundary --- .changes/unreleased/+root-host-authority.yaml | 2 + docs/BACKLOG.md | 13 ++- docs/BLUEPRINT_ENVIRONMENT_MODEL.md | 7 ++ docs/CONTROLLED_SESSION_DESIGN.md | 24 +++-- .../environment_lifecycle_executor.go | 16 +++- .../environment_lifecycle_executor_test.go | 56 ++++++++++++ internal/dockerdeploy/one_shot_output.go | 3 + internal/dockerdeploy/one_shot_output_test.go | 17 +++- .../provider_install_lifecycle_execute.go | 3 +- internal/dockerdeploy/provider_install_run.go | 3 + .../dockerdeploy/provider_install_run_test.go | 84 ++++++++++++++++++ .../dockerdeploy/runtime_host_preflight.go | 50 ++++++++++- .../runtime_host_preflight_test.go | 88 +++++++++++++++++-- internal/dockerdeploy/runtime_readiness.go | 7 +- .../dockerdeploy/runtime_readiness_test.go | 56 +++++++++++- internal/probe/local_account_linux_test.go | 22 +++++ 16 files changed, 415 insertions(+), 36 deletions(-) create mode 100644 .changes/unreleased/+root-host-authority.yaml create mode 100644 internal/dockerdeploy/environment_lifecycle_executor_test.go diff --git a/.changes/unreleased/+root-host-authority.yaml b/.changes/unreleased/+root-host-authority.yaml new file mode 100644 index 00000000..dc777155 --- /dev/null +++ b/.changes/unreleased/+root-host-authority.yaml @@ -0,0 +1,2 @@ +kind: Security +body: Reject host input, shared-state, and explicit output mounts for root application runtimes while continuing to allow Docker-managed volumes and tmpfs. diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 1439f289..61c5cbf5 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -31,13 +31,12 @@ This file is the day-to-day queue for design and implementation gaps. ## Now -- [ ] `P1` Inventory the global sandbox prerequisites for controlled sessions. - Use `CONTROLLED_SESSION_DESIGN.md` as the policy source. Map every staged, - installed, command, and shell container-launch path against the approved - identity, seccomp, `no-new-privileges`, capability, namespace, device, - mount, mask, secret, network, and root invariants. Record verified current - behavior and turn each missing invariant into a focused implementation - slice before starting the controlled-session lifecycle core. +- [ ] `P1` Define confinement for special files nested inside host directory + binds. A launch-time recursive scan for sockets and device nodes adds + unbounded source-tree latency and provides only a point-in-time result. + Choose a durable mechanism or explicitly narrow the security contract, + then add focused cross-platform tests without weakening ordinary + read-only project mounts. ## Pre-release diff --git a/docs/BLUEPRINT_ENVIRONMENT_MODEL.md b/docs/BLUEPRINT_ENVIRONMENT_MODEL.md index 26e40507..9c9b5bfd 100644 --- a/docs/BLUEPRINT_ENVIRONMENT_MODEL.md +++ b/docs/BLUEPRINT_ENVIRONMENT_MODEL.md @@ -1375,6 +1375,13 @@ is deliberately independent of a Windows domain account or Unix host account. An effective UID of zero uses the existing local name `root`; a blueprint cannot request root merely by naming it. +An application runtime with effective UID zero cannot receive a host bind, +whether read-only input or writable shared state. It also cannot use +`--output-file` or `--output-dir` until the separately reviewed root-safe output +contract is implemented. Reploy rejects these combinations before container +creation or output-path preparation. Docker-managed volumes and tmpfs remain +available because they do not expose a host filesystem path directly. + This is a portable blueprint contract with target-specific realization. The current backend writes Linux account databases. A future native-Windows or other target backend may realize the same local identity through different OS diff --git a/docs/CONTROLLED_SESSION_DESIGN.md b/docs/CONTROLLED_SESSION_DESIGN.md index e093bac3..0bc3c555 100644 --- a/docs/CONTROLLED_SESSION_DESIGN.md +++ b/docs/CONTROLLED_SESSION_DESIGN.md @@ -635,7 +635,7 @@ user-scope Reploy was invoked as root, or because a system-scope installation explicitly selected root. It is never inherited merely from the base image's configured `USER`. -A root runtime identity emits a warning equivalent to: +A root runtime identity must emit a warning equivalent to: > The application will run as root inside its container. Root can bypass > application-level file permissions. Host input and shared-state mounts are @@ -673,10 +673,10 @@ through a writable bind. Root inside any Reploy application container may not receive host input or shared-state binds, including read-only binds. Read-only prevents modification but does not make exposed content confidential from container root. Reploy -validates the complete effective mount plan and rejects the operation before -contacting Docker if a prohibited bind source would be visible. A separately -validated output-only bind is a narrow explicit result grant, not general host -filesystem authority. +validates the complete effective mount plan and rejects the application-runtime +launch before Docker can create a container with a prohibited bind source. A +separately validated output-only bind is a narrow explicit result grant, not +general host filesystem authority. Root application containers may use image content, Docker-managed volumes, tmpfs, or a disposable copied workspace because those do not expose the @@ -1064,16 +1064,22 @@ prohibits privileged mode, host namespaces, and host devices in the common plan. Live Docker tests inspect both runtime paths. Trusted production startup verification is also implemented: Reploy packages the platform-specific probe in a final runtime layer, creates the locked container-local account there, -records that layer outside the provider graph, and -uses its fixed verify-and-exec contract as the outermost process for persistent +records that layer outside the provider graph, and uses its fixed +verify-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, `no-new-privileges`, and empty effective, permitted, and bounding capability sets, then directly executes the exact application argv. Private-environment workloads use one additional fixed Reploy step: after verification, the probe executes the environment injector, which imports the private variables and then -executes the unchanged application argv. Mount/root authority, network denial, -and resource limits remain separate prerequisite slices. +executes the unchanged application argv. Network denial and resource limits +remain separate prerequisite slices. Root host authority is now enforced at +runtime: host sources are classified as input, shared state, or explicit +output; UID 0 is rejected for all three before container creation; and root +output options are rejected before host-path preparation. Docker-managed +volumes and tmpfs remain available to root. Durable confinement of special +files nested inside a non-root directory bind remains unresolved and must not +be represented as solved by an expensive launch-time snapshot alone. ### Slice 2: Controlled-Session Lifecycle Core diff --git a/internal/dockerdeploy/environment_lifecycle_executor.go b/internal/dockerdeploy/environment_lifecycle_executor.go index 4c504bcf..c561e88c 100644 --- a/internal/dockerdeploy/environment_lifecycle_executor.go +++ b/internal/dockerdeploy/environment_lifecycle_executor.go @@ -4,13 +4,15 @@ import ( "context" "io" - "github.com/omry/reploy/internal/blueprint" - "github.com/omry/reploy/internal/providerstore" + "github.com/omry/reploy/internal/deploy" ) -func environmentLifecycleExecutor(options RuntimeOptions, plan DockerExecutionPlan, _ providerstore.Store, _ blueprint.Platform, stdout io.Writer, stderr io.Writer) LifecycleExecutor { +func environmentLifecycleExecutor(options RuntimeOptions, plan DockerExecutionPlan, policy deploy.RuntimePolicyV1, stdout io.Writer, stderr io.Writer) LifecycleExecutor { return LifecycleExecutor{ RunCommand: func(ctx context.Context, command ResolvedEnvironmentCommand) error { + if err := validateLifecycleRuntimeHostSourcesV1(policy, plan, command.Name); err != nil { + return err + } if _, err := preparePrivateWorkloadEnvironmentV1(options.Dir); err != nil { return err } @@ -32,3 +34,11 @@ func environmentLifecycleExecutor(options RuntimeOptions, plan DockerExecutionPl }, } } + +func validateLifecycleRuntimeHostSourcesV1(policy deploy.RuntimePolicyV1, plan DockerExecutionPlan, commandName string) error { + invocation, err := CommandRuntimeInvocationV1(plan, commandName, nil) + if err != nil { + return err + } + return ValidateRuntimeHostSourcesV1(policy, invocation.PlanID, plan.Sandbox.RuntimeUser.UID, invocation.Sources) +} diff --git a/internal/dockerdeploy/environment_lifecycle_executor_test.go b/internal/dockerdeploy/environment_lifecycle_executor_test.go new file mode 100644 index 00000000..1986e06d --- /dev/null +++ b/internal/dockerdeploy/environment_lifecycle_executor_test.go @@ -0,0 +1,56 @@ +package dockerdeploy + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/omry/reploy/internal/blueprint" + "github.com/omry/reploy/internal/deploy" +) + +func TestValidateLifecycleRuntimeHostSourcesV1EnforcesRootBindPolicy(t *testing.T) { + root := t.TempDir() + plan := DockerExecutionPlan{ + Sandbox: newApplicationSandboxPlanV1(RuntimeUserPlan{UID: 0, GID: 0, DockerUser: "0:0"}), + Mounts: []MountExecutionPlan{{ + Name: "config", Mode: blueprint.MountBind, Source: root, + SourceKind: deploy.RuntimeMountSourceDirectory, Target: "/mnt/config", ReadOnly: true, + }}, + } + policy := runtimeHostPolicy([]deploy.RuntimeMountV1{{ + Destination: "/mnt/config", SourceKind: deploy.RuntimeMountSourceDirectory, ReadOnly: true, + }}) + policy.Plans[0].ID = "command/check" + if err := validateLifecycleRuntimeHostSourcesV1(policy, plan, "check"); err == nil || !strings.Contains(err.Error(), "root application runtime") { + t.Fatalf("root lifecycle bind error = %v", err) + } + + plan.Sandbox = newApplicationSandboxPlanV1(RuntimeUserPlan{UID: 1000, GID: 1000, DockerUser: "1000:1000"}) + if err := validateLifecycleRuntimeHostSourcesV1(policy, plan, "check"); err != nil { + t.Fatal(err) + } +} + +func TestValidateLifecycleRuntimeHostSourcesV1ChecksHostKind(t *testing.T) { + root := t.TempDir() + file := filepath.Join(root, "config") + if err := os.WriteFile(file, []byte("value=true\n"), 0o600); err != nil { + t.Fatal(err) + } + plan := DockerExecutionPlan{ + Sandbox: newApplicationSandboxPlanV1(RuntimeUserPlan{UID: 1000, GID: 1000, DockerUser: "1000:1000"}), + Mounts: []MountExecutionPlan{{ + Name: "config", Mode: blueprint.MountBind, Source: file, + SourceKind: deploy.RuntimeMountSourceDirectory, Target: "/mnt/config", ReadOnly: true, + }}, + } + policy := runtimeHostPolicy([]deploy.RuntimeMountV1{{ + Destination: "/mnt/config", SourceKind: deploy.RuntimeMountSourceDirectory, ReadOnly: true, + }}) + policy.Plans[0].ID = "command/check" + if err := validateLifecycleRuntimeHostSourcesV1(policy, plan, "check"); err == nil || !strings.Contains(err.Error(), "not a directory") { + t.Fatalf("lifecycle host kind error = %v", err) + } +} diff --git a/internal/dockerdeploy/one_shot_output.go b/internal/dockerdeploy/one_shot_output.go index 86172169..bd01e93b 100644 --- a/internal/dockerdeploy/one_shot_output.go +++ b/internal/dockerdeploy/one_shot_output.go @@ -47,6 +47,9 @@ func prepareOneShotOutputWithBackend( if outputDir == "" && outputFile == "" { return &oneShotOutputSession{}, nil } + if runtimeUser.UID == 0 { + return nil, fmt.Errorf("root application runtime cannot use --output-dir or --output-file until the root-safe output contract is implemented") + } if backend.currentUID == nil || backend.currentGID == nil || backend.chown == nil { return nil, fmt.Errorf("prepare one-shot output requires a complete ownership backend") } diff --git a/internal/dockerdeploy/one_shot_output_test.go b/internal/dockerdeploy/one_shot_output_test.go index ece5731c..10610e8f 100644 --- a/internal/dockerdeploy/one_shot_output_test.go +++ b/internal/dockerdeploy/one_shot_output_test.go @@ -9,7 +9,11 @@ import ( func currentOutputRuntimeUser() RuntimeUserPlan { backend := oneShotOutputOwnershipBackend() - return RuntimeUserPlan{UID: backend.currentUID(), GID: backend.currentGID()} + uid, gid := backend.currentUID(), backend.currentGID() + if uid == 0 { + uid, gid = 1, 1 + } + return RuntimeUserPlan{UID: uid, GID: gid} } func TestOneShotOutputDirectoryIsDirectAndPersistent(t *testing.T) { @@ -32,6 +36,17 @@ func TestOneShotOutputDirectoryIsDirectAndPersistent(t *testing.T) { } } +func TestOneShotOutputRejectsRootBeforePreparingHostPaths(t *testing.T) { + root := t.TempDir() + destination := filepath.Join(root, "not-created") + if _, err := prepareOneShotOutput(destination, "", RuntimeUserPlan{UID: 0, GID: 0}); err == nil || !strings.Contains(err.Error(), "root-safe output contract") { + t.Fatalf("root output error = %v", err) + } + if _, err := os.Stat(destination); !os.IsNotExist(err) { + t.Fatalf("root output destination was mutated: %v", err) + } +} + func TestOneShotOutputFilePublishesCompleteFile(t *testing.T) { final := filepath.Join(t.TempDir(), "report.json") session, err := prepareOneShotOutput("", final, currentOutputRuntimeUser()) diff --git a/internal/dockerdeploy/provider_install_lifecycle_execute.go b/internal/dockerdeploy/provider_install_lifecycle_execute.go index f6332ea9..8e42aa43 100644 --- a/internal/dockerdeploy/provider_install_lifecycle_execute.go +++ b/internal/dockerdeploy/provider_install_lifecycle_execute.go @@ -57,8 +57,7 @@ func providerInstallLifecycleExecutorV1(locked lockedProviderInstallV1) Lifecycl DockerPreflightTimeout: locked.Input.RunOptions.DockerPreflightTimeout, }, locked.Plan.Docker, - locked.DestinationStore, - locked.InstallBuild.Platform, + locked.InstallBuild.RuntimePolicy, locked.Input.RunOptions.Stdout, locked.Input.RunOptions.Stderr, ) diff --git a/internal/dockerdeploy/provider_install_run.go b/internal/dockerdeploy/provider_install_run.go index bfe94520..da2b93d8 100644 --- a/internal/dockerdeploy/provider_install_run.go +++ b/internal/dockerdeploy/provider_install_run.go @@ -268,6 +268,9 @@ func runProviderInstallV1( if plan.Installation.Status != deploy.InstallationStatusReady { return deploy.StateV1{}, fmt.Errorf("provider installation plan must describe a ready installation") } + if err := ValidateRootRuntimeHostAuthorityV1(sourceBuild.Lock.RuntimePolicy, plan.Docker); err != nil { + return deploy.StateV1{}, fmt.Errorf("validate installed root host authority: %w", err) + } installBuild, err := backend.buildInstallRuntime(ctx, sourceStore, sourceBuild, plan.Docker, input.RunOptions) if err != nil { return deploy.StateV1{}, err diff --git a/internal/dockerdeploy/provider_install_run_test.go b/internal/dockerdeploy/provider_install_run_test.go index 9aaa5dfb..20cf3b13 100644 --- a/internal/dockerdeploy/provider_install_run_test.go +++ b/internal/dockerdeploy/provider_install_run_test.go @@ -965,6 +965,90 @@ func TestRunProviderInstallV1RejectsServiceRenameBeforeDestinationPreparation(t } } +func TestRunProviderInstallV1RejectsRootHostBindBeforeRuntimeBuildOrDestinationPreparation(t *testing.T) { + sourceDir := t.TempDir() + destinationDir := filepath.Join(t.TempDir(), "installed") + hostSource := t.TempDir() + _, build := providerInstallRunBuildFixture(t, sourceDir) + build.Lock.RuntimePolicy = runtimeHostPolicy([]deploy.RuntimeMountV1{{ + Destination: "/mnt/config", SourceKind: deploy.RuntimeMountSourceDirectory, ReadOnly: true, + }}) + build.Lock.RuntimePolicy.Plans[0].ID = runtimeShellPlanID + + backend := providerInstallRunBackend{ + acquire: deploy.AcquireOperationLock, + release: func(lock *deploy.OperationLock) error { return lock.Unlock() }, + newStore: providerstore.NewStore, + buildSource: func(context.Context, LockedProviderBuildRunInputV1) (LockedProviderBuildExecutionResultV1, error) { + return build, nil + }, + prepareAccount: providerInstallRunPrepareAccountFixture, + newReferences: func(string, string) (EnvironmentImageReferences, error) { + return fixedPublicationReferences(t, destinationDir, 0x84), nil + }, + planInstallation: func(_ context.Context, input providerInstallPlanningV1) (providerInstallationPlanV1, error) { + plan := providerInstallRunPlanFixture(destinationDir, input.References) + plan.Docker.Sandbox = testApplicationSandboxPlanV1(0, 0) + plan.Docker.Mounts = []MountExecutionPlan{{ + Name: "config", Mode: blueprint.MountBind, Source: hostSource, + SourceKind: deploy.RuntimeMountSourceDirectory, Target: "/mnt/config", ReadOnly: true, + }} + return plan, nil + }, + buildInstallRuntime: func(context.Context, providerstore.Store, CurrentBuild, DockerExecutionPlan, RunOptions) (installedRuntimeIdentityBuildV1, error) { + t.Fatal("built an installed runtime after rejecting root host authority") + return installedRuntimeIdentityBuildV1{}, nil + }, + inspectHostTools: func(context.Context, installBackend) (providerInstallHostToolsV1, error) { + t.Fatal("inspected host tools after rejecting root host authority") + return providerInstallHostToolsV1{}, nil + }, + preflightDestination: func(providerstore.Store, CurrentBuild, string) error { + t.Fatal("preflighted destination after rejecting root host authority") + return nil + }, + ensureDestination: func(string) (bool, error) { + t.Fatal("created destination after rejecting root host authority") + return false, nil + }, + cleanupDestination: func(string) error { return nil }, + prepareDestination: func(context.Context, lockedProviderInstallV1) (preparedProviderInstallFilesV1, error) { + t.Fatal("prepared destination after rejecting root host authority") + return preparedProviderInstallFilesV1{}, nil + }, + publish: func(context.Context, *deploy.OperationLock, *deploy.OperationLock, providerstore.Store, providerstore.Store, InstalledBuildPublicationInputV1) (deploy.StateV1, error) { + t.Fatal("published destination after rejecting root host authority") + return deploy.StateV1{}, nil + }, + publishFiles: func(preparedProviderInstallFilesV1) error { + t.Fatal("published files after rejecting root host authority") + return nil + }, + activateDestination: func(context.Context, lockedProviderInstallV1, deploy.StateV1) error { + t.Fatal("activated destination after rejecting root host authority") + return nil + }, + markReady: func(*deploy.OperationLock, deploy.InstallationStateV1) (deploy.StateV1, bool, error) { + t.Fatal("marked destination ready after rejecting root host authority") + return deploy.StateV1{}, false, nil + }, + startDestination: func(context.Context, lockedProviderInstallV1, deploy.StateV1) error { + t.Fatal("started destination after rejecting root host authority") + return nil + }, + } + + _, err := runProviderInstallV1(t.Context(), providerInstallRunInputV1{ + SourceDeploymentDir: sourceDir, DestinationDeploymentDir: destinationDir, + }, providerInstallAdmissionTestBackend(backend, nil)) + if err == nil || !strings.Contains(err.Error(), "root application runtime") { + t.Fatalf("root host-bind install error = %v", err) + } + if _, err := os.Lstat(destinationDir); !os.IsNotExist(err) { + t.Fatalf("root host-bind rejection mutated destination: %v", err) + } +} + func providerInstallRunBuildFixture(t *testing.T, sourceDir string) (deploy.StateV1, LockedProviderBuildExecutionResultV1) { t.Helper() operation, _, current := installedBuildPublicationSourceFixtureAtDir(t, sourceDir) diff --git a/internal/dockerdeploy/runtime_host_preflight.go b/internal/dockerdeploy/runtime_host_preflight.go index 94ec6945..3ad5ad46 100644 --- a/internal/dockerdeploy/runtime_host_preflight.go +++ b/internal/dockerdeploy/runtime_host_preflight.go @@ -14,9 +14,16 @@ type RuntimeHostSourceV1 struct { Destination string HostPath string SourceKind string + Authority string ReadOnly bool } +const ( + runtimeHostAuthorityInputV1 = "host-input" + runtimeHostAuthoritySharedStateV1 = "shared-state" + runtimeHostAuthorityOutputV1 = "explicit-output" +) + func RuntimeHostSourcesV1(plan DockerExecutionPlan, output *transientOutputMount) ([]RuntimeHostSourceV1, error) { sources := []RuntimeHostSourceV1{} for _, mount := range plan.Mounts { @@ -26,8 +33,13 @@ func RuntimeHostSourcesV1(plan DockerExecutionPlan, output *transientOutputMount if err != nil { return nil, fmt.Errorf("runtime mount %q: %w", mount.Name, err) } + authority := runtimeHostAuthoritySharedStateV1 + if mount.ReadOnly { + authority = runtimeHostAuthorityInputV1 + } sources = append(sources, RuntimeHostSourceV1{ - Destination: mount.Target, HostPath: mount.Source, SourceKind: sourceKind, ReadOnly: mount.ReadOnly, + Destination: mount.Target, HostPath: mount.Source, SourceKind: sourceKind, + Authority: authority, ReadOnly: mount.ReadOnly, }) case blueprint.MountVolume, blueprint.MountTmpfs: default: @@ -37,7 +49,7 @@ func RuntimeHostSourcesV1(plan DockerExecutionPlan, output *transientOutputMount if output != nil { sources = append(sources, RuntimeHostSourceV1{ Destination: runtimeOutputRoot, HostPath: output.HostDirectory, - SourceKind: deploy.RuntimeMountSourceDirectory, ReadOnly: false, + SourceKind: deploy.RuntimeMountSourceDirectory, Authority: runtimeHostAuthorityOutputV1, ReadOnly: false, }) } sort.Slice(sources, func(left int, right int) bool { return sources[left].Destination < sources[right].Destination }) @@ -49,10 +61,13 @@ func RuntimeHostSourcesV1(plan DockerExecutionPlan, output *transientOutputMount return sources, nil } -func ValidateRuntimeHostSourcesV1(policy deploy.RuntimePolicyV1, planID string, sources []RuntimeHostSourceV1) error { +func ValidateRuntimeHostSourcesV1(policy deploy.RuntimePolicyV1, planID string, runtimeUID int, sources []RuntimeHostSourceV1) error { if err := deploy.ValidateRuntimePolicyV1(policy); err != nil { return err } + if runtimeUID < 0 { + return fmt.Errorf("runtime UID must be non-negative") + } var selected *deploy.RuntimePlanV1 for index := range policy.Plans { if policy.Plans[index].ID == planID { @@ -85,9 +100,25 @@ func ValidateRuntimeHostSourcesV1(policy deploy.RuntimePolicyV1, planID string, return fmt.Errorf("runtime plan %q mount %q is missing its host source", planID, mount.Destination) } delete(byDestination, mount.Destination) - if source.SourceKind != mount.SourceKind || source.ReadOnly != mount.ReadOnly { + wantAuthority := runtimeHostAuthoritySharedStateV1 + if mount.ReadOnly { + wantAuthority = runtimeHostAuthorityInputV1 + } else if mount.Destination == runtimeOutputRoot { + wantAuthority = runtimeHostAuthorityOutputV1 + } + if source.SourceKind != mount.SourceKind || source.ReadOnly != mount.ReadOnly || source.Authority != wantAuthority { return fmt.Errorf("runtime plan %q mount %q host kind or access policy changed", planID, mount.Destination) } + if runtimeUID == 0 { + if source.Authority == runtimeHostAuthorityOutputV1 { + return fmt.Errorf("root application runtime cannot use explicit output mounts until the root-safe output contract is implemented") + } + kind := "host shared-state" + if source.Authority == runtimeHostAuthorityInputV1 { + kind = "host input" + } + return fmt.Errorf("root application runtime cannot use %s mount %q; use image content, a Docker-managed volume, or tmpfs instead", kind, mount.Destination) + } info, err := os.Stat(source.HostPath) if err != nil { return fmt.Errorf("runtime plan %q mount %q host source: %w", planID, mount.Destination, err) @@ -115,3 +146,14 @@ func ValidateRuntimeHostSourcesV1(policy deploy.RuntimePolicyV1, planID string, } return nil } + +func ValidateRootRuntimeHostAuthorityV1(policy deploy.RuntimePolicyV1, plan DockerExecutionPlan) error { + if plan.Sandbox.RuntimeUser.UID != 0 { + return nil + } + invocation, err := ShellRuntimeInvocationV1(plan) + if err != nil { + return err + } + return ValidateRuntimeHostSourcesV1(policy, invocation.PlanID, 0, invocation.Sources) +} diff --git a/internal/dockerdeploy/runtime_host_preflight_test.go b/internal/dockerdeploy/runtime_host_preflight_test.go index 2bfe67ca..af9ab4c5 100644 --- a/internal/dockerdeploy/runtime_host_preflight_test.go +++ b/internal/dockerdeploy/runtime_host_preflight_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/omry/reploy/internal/blueprint" "github.com/omry/reploy/internal/deploy" "github.com/omry/reploy/internal/providers" ) @@ -21,9 +22,10 @@ func TestValidateRuntimeHostSourcesV1AcceptsExactPlanWithoutGeneratedSources(t * {Destination: environmentTemporaryHome, SourceKind: deploy.RuntimeMountSourceGenerated}, }) sources := []RuntimeHostSourceV1{{ - Destination: "/mnt/config", HostPath: config, SourceKind: deploy.RuntimeMountSourceDirectory, ReadOnly: true, + Destination: "/mnt/config", HostPath: config, SourceKind: deploy.RuntimeMountSourceDirectory, + Authority: runtimeHostAuthorityInputV1, ReadOnly: true, }} - if err := ValidateRuntimeHostSourcesV1(policy, "command/check", sources); err != nil { + if err := ValidateRuntimeHostSourcesV1(policy, "command/check", 1000, sources); err != nil { t.Fatal(err) } } @@ -38,7 +40,8 @@ func TestValidateRuntimeHostSourcesV1RejectsPlanAndSourceDrift(t *testing.T) { Destination: "/mnt/config", SourceKind: deploy.RuntimeMountSourceFile, ReadOnly: true, }}) valid := RuntimeHostSourceV1{ - Destination: "/mnt/config", HostPath: file, SourceKind: deploy.RuntimeMountSourceFile, ReadOnly: true, + Destination: "/mnt/config", HostPath: file, SourceKind: deploy.RuntimeMountSourceFile, + Authority: runtimeHostAuthorityInputV1, ReadOnly: true, } for _, test := range []struct { name string @@ -49,11 +52,12 @@ func TestValidateRuntimeHostSourcesV1RejectsPlanAndSourceDrift(t *testing.T) { {name: "unknown plan", planID: "command/other", sources: []RuntimeHostSourceV1{valid}, want: "absent"}, {name: "missing", planID: "command/check", sources: []RuntimeHostSourceV1{}, want: "missing"}, {name: "access", planID: "command/check", sources: []RuntimeHostSourceV1{{Destination: valid.Destination, HostPath: file, SourceKind: valid.SourceKind}}, want: "access policy changed"}, - {name: "kind", planID: "command/check", sources: []RuntimeHostSourceV1{{Destination: valid.Destination, HostPath: file, SourceKind: deploy.RuntimeMountSourceDirectory, ReadOnly: true}}, want: "kind or access"}, + {name: "kind", planID: "command/check", sources: []RuntimeHostSourceV1{{Destination: valid.Destination, HostPath: file, SourceKind: deploy.RuntimeMountSourceDirectory, Authority: valid.Authority, ReadOnly: true}}, want: "kind or access"}, + {name: "authority", planID: "command/check", sources: []RuntimeHostSourceV1{{Destination: valid.Destination, HostPath: file, SourceKind: valid.SourceKind, Authority: runtimeHostAuthoritySharedStateV1, ReadOnly: true}}, want: "kind or access"}, {name: "unexpected", planID: "command/check", sources: []RuntimeHostSourceV1{valid, {Destination: "/mnt/extra", HostPath: root, SourceKind: deploy.RuntimeMountSourceDirectory}}, want: "unexpected"}, } { t.Run(test.name, func(t *testing.T) { - err := ValidateRuntimeHostSourcesV1(policy, test.planID, test.sources) + err := ValidateRuntimeHostSourcesV1(policy, test.planID, 1000, test.sources) if err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("error = %v, want containing %q", err, test.want) } @@ -66,14 +70,80 @@ func TestValidateRuntimeHostSourcesV1RejectsChangedFilesystemKind(t *testing.T) policy := runtimeHostPolicy([]deploy.RuntimeMountV1{{ Destination: "/mnt/config", SourceKind: deploy.RuntimeMountSourceFile, ReadOnly: true, }}) - err := ValidateRuntimeHostSourcesV1(policy, "command/check", []RuntimeHostSourceV1{{ - Destination: "/mnt/config", HostPath: root, SourceKind: deploy.RuntimeMountSourceFile, ReadOnly: true, + err := ValidateRuntimeHostSourcesV1(policy, "command/check", 1000, []RuntimeHostSourceV1{{ + Destination: "/mnt/config", HostPath: root, SourceKind: deploy.RuntimeMountSourceFile, + Authority: runtimeHostAuthorityInputV1, ReadOnly: true, }}) if err == nil || !strings.Contains(err.Error(), "not a regular file") { t.Fatalf("filesystem kind error = %v", err) } } +func TestValidateRuntimeHostSourcesV1RejectsEveryHostAuthorityForRoot(t *testing.T) { + root := t.TempDir() + for _, test := range []struct { + name string + mount deploy.RuntimeMountV1 + authority string + want string + }{ + { + name: "host input", mount: deploy.RuntimeMountV1{ + Destination: "/mnt/input", SourceKind: deploy.RuntimeMountSourceDirectory, ReadOnly: true, + }, authority: runtimeHostAuthorityInputV1, want: "host input mount", + }, + { + name: "shared state", mount: deploy.RuntimeMountV1{ + Destination: "/mnt/state", SourceKind: deploy.RuntimeMountSourceDirectory, + }, authority: runtimeHostAuthoritySharedStateV1, want: "host shared-state mount", + }, + { + name: "explicit output", mount: deploy.RuntimeMountV1{ + Destination: runtimeOutputRoot, SourceKind: deploy.RuntimeMountSourceDirectory, + }, authority: runtimeHostAuthorityOutputV1, want: "root-safe output contract", + }, + } { + t.Run(test.name, func(t *testing.T) { + policy := runtimeHostPolicy([]deploy.RuntimeMountV1{test.mount}) + err := ValidateRuntimeHostSourcesV1(policy, "command/check", 0, []RuntimeHostSourceV1{{ + Destination: test.mount.Destination, HostPath: root, SourceKind: test.mount.SourceKind, + Authority: test.authority, ReadOnly: test.mount.ReadOnly, + }}) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("root authority error = %v, want containing %q", err, test.want) + } + }) + } +} + +func TestValidateRuntimeHostSourcesV1AllowsGeneratedStorageForRoot(t *testing.T) { + policy := runtimeHostPolicy([]deploy.RuntimeMountV1{{ + Destination: "/mnt/data", SourceKind: deploy.RuntimeMountSourceGenerated, + }}) + if err := ValidateRuntimeHostSourcesV1(policy, "command/check", 0, []RuntimeHostSourceV1{}); err != nil { + t.Fatal(err) + } +} + +func TestValidateRootRuntimeHostAuthorityV1RejectsBeforeHostInspection(t *testing.T) { + missing := filepath.Join(t.TempDir(), "missing") + plan := DockerExecutionPlan{ + Sandbox: newApplicationSandboxPlanV1(RuntimeUserPlan{UID: 0, GID: 0, DockerUser: "0:0"}), + Mounts: []MountExecutionPlan{{ + Name: "config", Mode: blueprint.MountBind, Source: missing, + SourceKind: deploy.RuntimeMountSourceDirectory, Target: "/mnt/config", ReadOnly: true, + }}, + } + policy := runtimeHostPolicy([]deploy.RuntimeMountV1{{ + Destination: "/mnt/config", SourceKind: deploy.RuntimeMountSourceDirectory, ReadOnly: true, + }}) + policy.Plans[0].ID = runtimeShellPlanID + err := ValidateRootRuntimeHostAuthorityV1(policy, plan) + if err == nil || !strings.Contains(err.Error(), "root application runtime") || strings.Contains(err.Error(), "no such file") { + t.Fatalf("early root authority error = %v", err) + } +} + func TestRuntimeHostSourcesV1IncludesOnlyBindAndExplicitOutputMounts(t *testing.T) { root := t.TempDir() output := &transientOutputMount{HostDirectory: root, Variable: runtimeOutputDirectoryVariable, ContainerPath: runtimeOutputRoot} @@ -84,7 +154,9 @@ func TestRuntimeHostSourcesV1IncludesOnlyBindAndExplicitOutputMounts(t *testing. if err != nil { t.Fatal(err) } - if len(sources) != 2 || sources[0].Destination != "/mnt/config" || sources[1].Destination != runtimeOutputRoot { + if len(sources) != 2 || + sources[0].Destination != "/mnt/config" || sources[0].Authority != runtimeHostAuthorityInputV1 || + sources[1].Destination != runtimeOutputRoot || sources[1].Authority != runtimeHostAuthorityOutputV1 { t.Fatalf("host sources = %#v", sources) } } diff --git a/internal/dockerdeploy/runtime_readiness.go b/internal/dockerdeploy/runtime_readiness.go index b4023b87..5f5d8f68 100644 --- a/internal/dockerdeploy/runtime_readiness.go +++ b/internal/dockerdeploy/runtime_readiness.go @@ -158,7 +158,12 @@ func RequireRuntimeReady(input RuntimeReadinessInput) error { if !matched { return fmt.Errorf("%s", currentBuildRecoveryMessageV1(input.Current.State, "runtime build is missing or stale")) } - if err := ValidateRuntimeHostSourcesV1(input.Current.Lock.RuntimePolicy, input.PlanID, input.Sources); err != nil { + if err := ValidateRuntimeHostSourcesV1( + input.Current.Lock.RuntimePolicy, + input.PlanID, + input.DockerPlan.Sandbox.RuntimeUser.UID, + input.Sources, + ); err != nil { return fmt.Errorf("runtime host-source check: %w", err) } return nil diff --git a/internal/dockerdeploy/runtime_readiness_test.go b/internal/dockerdeploy/runtime_readiness_test.go index cee4f65b..82213ee0 100644 --- a/internal/dockerdeploy/runtime_readiness_test.go +++ b/internal/dockerdeploy/runtime_readiness_test.go @@ -108,6 +108,59 @@ func TestRunPublishedRuntimeContainerV1NeverRunsForStaleBuild(t *testing.T) { } } +func TestRunPublishedRuntimeContainerV1NeverRunsForRootHostBind(t *testing.T) { + current, buildInput := runtimeCurrentBuildFixture(t) + hostSource := t.TempDir() + plan := buildInput.DockerPlan + plan.Sandbox = testApplicationSandboxPlanV1(0, 0) + plan.Mounts = []MountExecutionPlan{{ + Name: "config", Mode: blueprint.MountBind, Source: hostSource, + SourceKind: deploy.RuntimeMountSourceDirectory, Target: "/mnt/config", ReadOnly: true, + }} + account, err := applicationLocalAccountV1(plan.Sandbox) + if err != nil { + t.Fatal(err) + } + current.Lock.RuntimeLayer.Account = account + current.Lock.RuntimeLayer.TransactionDigest, err = deploy.ApplicationRuntimeLayerTransactionDigestV1( + current.Lock.RuntimeLayer.Verifier, + account, + current.Lock.RuntimeLayer.Upstream, + current.Lock.Platform, + ) + if err != nil { + t.Fatal(err) + } + plans, err := RuntimePlansV1(buildInput.Document, plan) + if err != nil { + t.Fatal(err) + } + current.Lock.RuntimePolicy, err = CompileRuntimePolicyFromLockV1(buildInput.Document, current.Lock, plans) + if err != nil { + t.Fatal(err) + } + refreshCurrentBuildReuseGeneration(t, ¤t) + invocation, err := ShellRuntimeInvocationV1(plan) + if err != nil { + t.Fatal(err) + } + runs := 0 + err = runPublishedRuntimeContainerV1(t.Context(), PublishedRuntimeContainerInput{ + Environment: "demo", DeploymentDir: "/srv/demo", DockerPlan: plan, Invocation: invocation, + }, func(context.Context, *deploy.OperationLock, providerstore.Store, string, string) (CurrentBuild, bool, error) { + return current, true, nil + }, func(context.Context, CurrentBuild) error { + runs++ + return nil + }) + if err == nil || !strings.Contains(err.Error(), "root application runtime") { + t.Fatalf("root host-bind error = %v", err) + } + if runs != 0 { + t.Fatalf("runner called %d times", runs) + } +} + func TestRunPublishedRuntimeContainerV1RejectsMissingBoundaryInputs(t *testing.T) { if err := runPublishedRuntimeContainerV1(t.Context(), PublishedRuntimeContainerInput{}, nil, func(context.Context, CurrentBuild) error { return nil @@ -215,7 +268,8 @@ func TestRequireRuntimeReadyChecksHostSourceAfterExactBuildMatch(t *testing.T) { Current: current, DockerPlan: buildInput.DockerPlan, PlanID: "shell", Sources: []RuntimeHostSourceV1{{ Destination: "/mnt/config", HostPath: config, - SourceKind: deploy.RuntimeMountSourceDirectory, ReadOnly: true, + SourceKind: deploy.RuntimeMountSourceDirectory, + Authority: runtimeHostAuthorityInputV1, ReadOnly: true, }}, }) if err == nil || !strings.Contains(err.Error(), "host-source") || !errors.Is(err, os.ErrNotExist) { diff --git a/internal/probe/local_account_linux_test.go b/internal/probe/local_account_linux_test.go index d612bfd3..7050c181 100644 --- a/internal/probe/local_account_linux_test.go +++ b/internal/probe/local_account_linux_test.go @@ -54,6 +54,28 @@ func TestInstallLocalAccountFilesCreatesMissingDatabaseFiles(t *testing.T) { } } +func TestInstallLocalAccountFilesRealizesRootRuntimeAccount(t *testing.T) { + root := t.TempDir() + passwd := filepath.Join(root, "passwd") + group := filepath.Join(root, "group") + if err := os.WriteFile(passwd, []byte("root:x:0:0:root:/root:/bin/sh\nnobody:x:65534:65534::/:/sbin/nologin\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(group, []byte("root:x:0:\nnogroup:x:65534:\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := installLocalAccountFiles("root", "0", "0", "/mnt/reploy-home", passwd, group); err != nil { + t.Fatal(err) + } + passwdContent, err := os.ReadFile(passwd) + if err != nil { + t.Fatal(err) + } + if got := string(passwdContent); !strings.HasPrefix(got, "root:x:0:0::/mnt/reploy-home:/sbin/nologin\n") || strings.Count(got, "root:x:0:0:") != 1 { + t.Fatalf("root passwd = %q", got) + } +} + func TestInstallLocalAccountFilesRejectsNameCollisionAndSpecialDatabase(t *testing.T) { root := t.TempDir() passwd := filepath.Join(root, "passwd")