Skip to content
Merged
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/+mapped-code-proof.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
kind: Security
body: Require exact protected-code and mount evidence before internal mapped startup release. Public mapped execution remains gated on production admission and runtime qualification.
132 changes: 132 additions & 0 deletions internal/dockerdeploy/mapped_sandbox_proof.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package dockerdeploy

import (
"context"
"fmt"
"path"
"slices"
"strconv"
"strings"
"unicode/utf8"

"github.com/omry/reploy/internal/canonical"
"github.com/omry/reploy/internal/deploy"
"github.com/omry/reploy/internal/mappedlaunch"
)

// The trusted F effect owner supplies the immutable-image code closure and
// fresh observations of the inhibited instance. This is not operator input or
// a qualification flag. No production observation provider is supplied by E.
// The call runs inside the existing admission writer: it must not reacquire
// OperationLock. Build-lock reads and expected-plan construction precede it.
type mappedSandboxEvidenceV1 interface {
MappedSandbox(context.Context, deploy.StateV1, mappedSandboxRequestV1) (mappedSandboxProofV1, error)
}

type mappedSandboxRequestV1 struct {
Binding mappedlaunch.Binding `json:"binding"`
Container string `json:"container"`
PID string `json:"pid"`
RuntimePolicy canonical.Digest `json:"runtime_policy"`
NetworkPolicy canonical.Digest `json:"network_policy"`
RequiredCode []string `json:"required_code"`
}

type mappedCodePathV1 struct {
Path, Resolved string
}

type mappedSandboxMountV1 struct {
Target, Resolved string
}

type mappedSandboxProofV1 struct {
Request canonical.Digest
NetworkPolicy canonical.Digest
ReadOnlyRoot bool
// Code is F's complete closure from the pinned image, including loaders,
// interpreters and dependency search paths. ObservedCode is its live view.
Code, ObservedCode []mappedCodePathV1
// Complete non-root mounts, including resolved symlink destinations.
Mounts []mappedSandboxMountV1
}

func (a *ownedMappedWaiterV1) sandboxRequest() (mappedSandboxRequestV1, error) {
p := a.prepared
if p.ops.Operation == nil || p.ops.ValidateProfileOwner == nil || p.state.Current == nil {
return mappedSandboxRequestV1{}, fmt.Errorf("mapped sandbox requires selected build evidence")
}
lock, found, err := p.ops.Operation.ReadBuildLock(p.state.Current.BuildLockDigest, p.ops.ValidateProfileOwner)
if err != nil || !found {
return mappedSandboxRequestV1{}, fmt.Errorf("mapped sandbox build evidence unavailable: %v", err)
}
policy, err := deploy.RuntimePolicyDigestV1(lock.RuntimePolicy)
if err != nil || policy != p.state.Current.RuntimePolicyDigest || lock.FinalImage.ConfigDigest != a.binding.Image ||
p.state.Current.BuildLockDigest != a.binding.Generation || lock.FinalImage.Digest != p.state.Current.ImageDigest ||
lock.FinalImage.RootFSSubject != p.state.Current.RootFSSubject {
return mappedSandboxRequestV1{}, fmt.Errorf("mapped sandbox generation or image changed")
}
body, _, err := podmanApplicationCreateRequest(p.spec, p.namespace)
separator := slices.Index(body.Cmd, "--")
if err != nil || separator < 0 || separator+1 >= len(body.Cmd) || !mappedCodeAbsoluteV1(body.Cmd[separator+1]) {
return mappedSandboxRequestV1{}, fmt.Errorf("mapped sandbox executable is unavailable")
}
required := []string{deploy.ApplicationStartupVerifierPathV1, body.Cmd[separator+1]}
for _, protected := range lock.RuntimePolicy.ProtectedPaths {
required = append(required, protected.Path)
}
slices.Sort(required)
// ReadBuildLock already validated the closed string-only network policy.
network, _ := canonical.Sum("mapped-network", "v1", lock.RuntimePolicy.Network)
return mappedSandboxRequestV1{a.binding, a.container, strconv.Itoa(a.pid), policy, network, slices.Compact(required)}, nil
}

func verifyMappedSandboxV1(ctx context.Context, state deploy.StateV1, evidence deploy.IdentityMappingEvidenceV1, request mappedSandboxRequestV1) error {
owner, ok := evidence.(mappedSandboxEvidenceV1)
if !ok || ctx.Err() != nil {
return fmt.Errorf("mapped sandbox observation provider is unavailable")
}
digest, err := canonical.Sum("mapped-sandbox", "v1", request)
if err != nil {
return err
}
copy := request
copy.RequiredCode = slices.Clone(request.RequiredCode)
proof, err := owner.MappedSandbox(ctx, state, copy)
if err != nil {
return fmt.Errorf("mapped sandbox observation failed: %w", err)
}
if ctx.Err() != nil || proof.Request != digest || proof.NetworkPolicy != request.NetworkPolicy || !proof.ReadOnlyRoot ||
len(proof.Code) == 0 || !slices.Equal(proof.Code, proof.ObservedCode) || proof.Mounts == nil {
return fmt.Errorf("mapped sandbox proof is missing, stale or substituted")
}
paths := make(map[string]bool, len(proof.Code))
for index, code := range proof.Code {
if !mappedCodeAbsoluteV1(code.Path) || !mappedCodeAbsoluteV1(code.Resolved) || index > 0 && proof.Code[index-1].Path >= code.Path {
return fmt.Errorf("mapped code closure must contain sorted unique absolute paths")
}
paths[code.Path] = true
}
for _, required := range request.RequiredCode {
if !paths[required] {
return fmt.Errorf("mapped code closure omits required path %q", required)
}
}
for _, mount := range proof.Mounts {
if !mappedCodeAbsoluteV1(mount.Target) || !mappedCodeAbsoluteV1(mount.Resolved) {
return fmt.Errorf("mapped mount observation has an invalid destination")
}
for _, code := range proof.Code {
for _, destination := range []string{mount.Target, mount.Resolved} {
if runtimePolicyPathsOverlap(destination, code.Path) || runtimePolicyPathsOverlap(destination, code.Resolved) {
return fmt.Errorf("mapped mount shadows protected code %q", code.Path)
}
}
}
}
return nil
}

func mappedCodeAbsoluteV1(value string) bool {
return value != "/" && utf8.ValidString(value) && path.IsAbs(value) && path.Clean(value) == value && !strings.ContainsAny(value, "\x00\r\n")
}
214 changes: 214 additions & 0 deletions internal/dockerdeploy/mapped_sandbox_proof_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
package dockerdeploy

import (
"context"
"errors"
"slices"
"testing"

"github.com/omry/reploy/internal/canonical"
"github.com/omry/reploy/internal/deploy"
"github.com/omry/reploy/internal/providers/registry"
)

// Embedding only the old interface deliberately omits the new F observation
// contract. An existing generic Verify success cannot grant code authority.
type withoutMappedSandboxV1 struct {
deploy.IdentityMappingEvidenceV1
}

func TestMappedSandboxRetainsCompiledProtectedRootsV1(t *testing.T) {
f := newWaiterFixtureV1(t)
a := f.start(t)
lock, found, err := a.prepared.ops.Operation.ReadBuildLock(a.binding.Generation, registry.ValidateRequirementProfileV1)
if err != nil || !found {
t.Fatal(err)
}
lock.RuntimePolicy.ProtectedPaths = []deploy.ProtectedPathV1{{Path: "/opt/reploy", Kind: deploy.ProtectedPathReployRoot, Owner: "reploy"}}
digest, err := a.prepared.ops.Operation.PublishBuildLock(lock, registry.ValidateRequirementProfileV1)
if err != nil {
t.Fatal(err)
}
a.prepared.state.Current.BuildLockDigest = digest
a.prepared.state.Current.RuntimePolicyDigest, err = deploy.RuntimePolicyDigestV1(lock.RuntimePolicy)
if err != nil {
t.Fatal(err)
}
a.binding.Generation = digest
request, err := a.sandboxRequest()
if err != nil || !slices.Contains(request.RequiredCode, "/opt/reploy") {
t.Fatal("compiled protected root was lost", request, err)
}
f.effect.sandboxChange = func(proof *mappedSandboxProofV1) {
proof.Code = slices.DeleteFunc(proof.Code, func(code mappedCodePathV1) bool { return code.Path == "/opt/reploy" })
proof.ObservedCode = slices.Clone(proof.Code)
}
if err := verifyMappedSandboxV1(f.ctx, a.prepared.state, f.effect, request); err == nil {
t.Fatal("observation omitted a compiled protected root")
}
}

func TestMappedSandboxProofV1(t *testing.T) {
f := newWaiterFixtureV1(t)
a := f.start(t)
request, err := a.sandboxRequest()
if err != nil {
t.Fatal(err)
}
if request.Binding != f.binding || request.Container != f.id || request.PID != "1234" ||
!slices.Contains(request.RequiredCode, "/reploy-probe") || len(request.RequiredCode) < 2 {
t.Fatalf("incomplete exact plan: %+v", request)
}
if err := verifyMappedSandboxV1(f.ctx, f.prepared.state, f.effect, request); err != nil {
t.Fatal(err)
}
if err := verifyMappedSandboxV1(f.ctx, f.prepared.state, withoutMappedSandboxV1{f.effect}, request); err == nil {
t.Fatal("generic effect proof substituted for missing code proof")
}
ctx, cancel := context.WithCancel(f.ctx)
cancel()
if err := verifyMappedSandboxV1(ctx, f.prepared.state, f.effect, request); err == nil {
t.Fatal("cancelled proof accepted")
}
f.effect.sandboxFailure = errors.New("observation unavailable")
if err := verifyMappedSandboxV1(f.ctx, f.prepared.state, f.effect, request); err == nil {
t.Fatal("provider failure accepted")
}
f.effect.sandboxFailure = nil
invalidRequest := request
invalidRequest.RequiredCode = []string{"/invalid-\xff"}
if err := verifyMappedSandboxV1(f.ctx, f.prepared.state, f.effect, invalidRequest); err == nil {
t.Fatal("uncanonical request accepted")
}
finishing, finish := context.WithCancel(f.ctx)
f.effect.sandboxChange = func(*mappedSandboxProofV1) { finish() }
if err := verifyMappedSandboxV1(finishing, f.prepared.state, f.effect, request); err == nil {
t.Fatal("proof completing after cancellation accepted")
}
for _, test := range []struct {
name string
change func(*mappedSandboxProofV1)
}{
{"wrong request", func(p *mappedSandboxProofV1) { p.Request = rendererDigest("9") }},
{"wrong network", func(p *mappedSandboxProofV1) { p.NetworkPolicy = rendererDigest("9") }},
{"writable root", func(p *mappedSandboxProofV1) { p.ReadOnlyRoot = false }},
{"missing closure", func(p *mappedSandboxProofV1) { p.Code = nil; p.ObservedCode = nil }},
{"missing observation", func(p *mappedSandboxProofV1) { p.ObservedCode = nil }},
{"substituted dependency", func(p *mappedSandboxProofV1) { p.ObservedCode[0].Resolved = "/tmp/replacement" }},
{"missing mount inventory", func(p *mappedSandboxProofV1) { p.Mounts = nil }},
{"omitted required code", func(p *mappedSandboxProofV1) { p.Code = p.Code[1:]; p.ObservedCode = slices.Clone(p.Code) }},
{"relative code", func(p *mappedSandboxProofV1) { p.Code[0].Path = "relative"; p.ObservedCode = slices.Clone(p.Code) }},
{"unclean code", func(p *mappedSandboxProofV1) { p.Code[0].Path = "/app/../app"; p.ObservedCode = slices.Clone(p.Code) }},
{"root closure shortcut", func(p *mappedSandboxProofV1) { p.Code[0].Path = "/"; p.ObservedCode = slices.Clone(p.Code) }},
{"invalid code bytes", func(p *mappedSandboxProofV1) { p.Code[0].Path = "/bad\x00path"; p.ObservedCode = slices.Clone(p.Code) }},
{"relative resolution", func(p *mappedSandboxProofV1) { p.Code[0].Resolved = "relative"; p.ObservedCode = slices.Clone(p.Code) }},
{"invalid code UTF8", func(p *mappedSandboxProofV1) { p.Code[0].Path = "/bad-\xff"; p.ObservedCode = slices.Clone(p.Code) }},
{"duplicate closure", func(p *mappedSandboxProofV1) {
p.Code = append(p.Code, p.Code[0])
p.ObservedCode = slices.Clone(p.Code)
}},
{"unsorted closure", func(p *mappedSandboxProofV1) { slices.Reverse(p.Code); p.ObservedCode = slices.Clone(p.Code) }},
{"relative mount", func(p *mappedSandboxProofV1) { p.Mounts = []mappedSandboxMountV1{{"relative", "/data"}} }},
{"relative mount resolution", func(p *mappedSandboxProofV1) { p.Mounts = []mappedSandboxMountV1{{"/data", "relative"}} }},
{"direct code shadow", func(p *mappedSandboxProofV1) { p.Mounts = []mappedSandboxMountV1{{p.Code[0].Path, "/data"}} }},
{"resolved mount shadow", func(p *mappedSandboxProofV1) { p.Mounts = []mappedSandboxMountV1{{"/data", p.Code[0].Path}} }},
{"code symlink shadow", func(p *mappedSandboxProofV1) {
p.Code[0].Resolved = "/private/code"
p.ObservedCode = slices.Clone(p.Code)
p.Mounts = []mappedSandboxMountV1{{"/private", "/private"}}
}},
} {
t.Run(test.name, func(t *testing.T) {
f.effect.sandboxChange = test.change
if err := verifyMappedSandboxV1(f.ctx, f.prepared.state, f.effect, request); err == nil {
t.Fatal("invalid proof accepted")
}
})
}
f.effect.sandboxChange = func(p *mappedSandboxProofV1) {
p.Mounts = []mappedSandboxMountV1{{"/unrelated-data", "/unrelated-data"}}
}
if err := verifyMappedSandboxV1(f.ctx, f.prepared.state, f.effect, request); err != nil {
t.Fatal("unrelated data mount refused", err)
}
f.effect.sandboxChange = nil
for _, change := range []func(*mappedSandboxRequestV1){
func(r *mappedSandboxRequestV1) { r.Binding.Generation = rendererDigest("9") },
func(r *mappedSandboxRequestV1) { r.Binding.Image = rendererDigest("9") },
func(r *mappedSandboxRequestV1) { r.Binding.Revision = "99" },
func(r *mappedSandboxRequestV1) { r.Binding.Role = "other-role" },
func(r *mappedSandboxRequestV1) { r.Binding.Plan = rendererDigest("9") },
func(r *mappedSandboxRequestV1) { r.Container = "another-container" },
func(r *mappedSandboxRequestV1) { r.PID = "999" },
func(r *mappedSandboxRequestV1) { r.RuntimePolicy = rendererDigest("9") },
} {
changed := request
change(&changed)
original, _ := canonical.Sum("mapped-sandbox", "v1", request)
f.effect.sandboxChange = func(p *mappedSandboxProofV1) { p.Request = original }
if err := verifyMappedSandboxV1(f.ctx, f.prepared.state, f.effect, changed); err == nil {
t.Fatal("proof replayed for a different owner or plan")
}
}
}

func TestOwnedMappedWaiterRequiresSandboxProofV1(t *testing.T) {
for _, mode := range []string{"stale", "missing", "missing-build-authority"} {
t.Run(mode, func(t *testing.T) {
f := newWaiterFixtureV1(t)
a := f.start(t)
if mode == "missing-build-authority" {
a.prepared.ops.ValidateProfileOwner = nil
} else if mode == "missing" {
a.prepared.ops.Evidence = withoutMappedSandboxV1{f.effect}
} else {
f.effect.sandboxChange = func(p *mappedSandboxProofV1) { p.Request = rendererDigest("9") }
}
if _, err := a.release(); err == nil {
t.Fatal("unproved sandbox released")
}
if f.admissions != 0 || f.application.Load() != 0 || f.removed != 1 {
t.Fatal("refusal changed admission, exposed stdin or lost owned cleanup")
}
if _, err := a.release(); err == nil || f.removed != 1 {
t.Fatal("failed sandbox attempt retried release or cleanup")
}
})
}
}

func TestMappedSandboxSelectedPlanRefusalsV1(t *testing.T) {
for _, test := range []struct {
name string
change func(*ownedMappedWaiterV1)
}{
{"no operation", func(a *ownedMappedWaiterV1) { a.prepared.ops.Operation = nil }},
{"no profile authority", func(a *ownedMappedWaiterV1) { a.prepared.ops.ValidateProfileOwner = nil }},
{"no generation", func(a *ownedMappedWaiterV1) { a.prepared.state.Current = nil }},
{"missing lock", func(a *ownedMappedWaiterV1) { a.prepared.state.Current.BuildLockDigest = rendererDigest("9") }},
{"changed policy", func(a *ownedMappedWaiterV1) { a.prepared.state.Current.RuntimePolicyDigest = rendererDigest("9") }},
{"changed config", func(a *ownedMappedWaiterV1) { a.binding.Image = rendererDigest("9") }},
{"changed generation", func(a *ownedMappedWaiterV1) { a.binding.Generation = rendererDigest("9") }},
{"changed image", func(a *ownedMappedWaiterV1) { a.prepared.state.Current.ImageDigest = rendererDigest("9") }},
{"changed rootfs", func(a *ownedMappedWaiterV1) { a.prepared.state.Current.RootFSSubject = rendererDigest("9") }},
{"invalid create", func(a *ownedMappedWaiterV1) { a.prepared.spec.Args = nil }},
{"missing separator", func(a *ownedMappedWaiterV1) {
a.prepared.spec.Args = slices.DeleteFunc(a.prepared.spec.Args, func(s string) bool { return s == "--" })
}},
{"missing executable", func(a *ownedMappedWaiterV1) {
a.prepared.spec.Args = a.prepared.spec.Args[:slices.Index(a.prepared.spec.Args, "--")+1]
}},
{"relative executable", func(a *ownedMappedWaiterV1) {
a.prepared.spec.Args[slices.Index(a.prepared.spec.Args, "--")+1] = "relative"
}},
} {
t.Run(test.name, func(t *testing.T) {
f := newWaiterFixtureV1(t)
a := f.start(t)
test.change(a)
if _, err := a.sandboxRequest(); err == nil {
t.Fatal("stale or incomplete selected plan accepted")
}
})
}
}
9 changes: 9 additions & 0 deletions internal/dockerdeploy/podman_mapped_waiter.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,16 @@ func (a *ownedMappedWaiterV1) release() (acceptedState deploy.StateV1, failure e
if err != nil || image != string(a.binding.Image) || namespace != p.namespace || client.version != p.version {
return deploy.StateV1{}, fmt.Errorf("mapped release image or engine changed: %v", err)
}
// Read the selected immutable plan outside the admission writer. Fresh F
// observations and the final process fence run inside its existing proof.
sandbox, err := a.sandboxRequest()
if err != nil {
return deploy.StateV1{}, err
}
verify := func(ctx context.Context) error {
if err := verifyMappedSandboxV1(ctx, p.state, p.ops.Evidence, sandbox); err != nil {
return err
}
pid, err := a.observeContainer(ctx, client)
if err != nil || pid != a.pid {
return fmt.Errorf("mapped waiter is no longer the pinned process: %v", err)
Expand Down
Loading
Loading