From acdad4745df19bb0378c69a3dbcd94327b7ac7a7 Mon Sep 17 00:00:00 2001 From: Mahati Chamarthy Date: Thu, 7 May 2026 22:06:48 +0100 Subject: [PATCH 01/56] CWCOW: Persist environment variable Capture and apply envToKeep from policy enforcement in createContainer, external exec, and in-container exec. Previously the filtered env list was discarded. Add ociEnvToProcessParamEnv and rewriteExecRequest helpers with tests. Signed-off-by: Mahati Chamarthy --- internal/gcs-sidecar/handlers.go | 58 ++++++++++++++++++- internal/gcs-sidecar/handlers_test.go | 80 +++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 3 deletions(-) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index a51c3fd8f5..81e5f9e03f 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -124,12 +124,17 @@ func (b *Bridge) createContainer(req *request) (err error) { user := securitypolicy.IDName{ Name: spec.Process.User.Username, } - _, _, _, err := b.hostState.securityOptions.PolicyEnforcer.EnforceCreateContainerPolicyV2(req.ctx, containerID, spec.Process.Args, spec.Process.Env, spec.Process.Cwd, spec.Mounts, user, nil) + envToKeep, _, allowStdio, err := b.hostState.securityOptions.PolicyEnforcer.EnforceCreateContainerPolicyV2(req.ctx, containerID, spec.Process.Args, spec.Process.Env, spec.Process.Cwd, spec.Mounts, user, nil) if err != nil { return fmt.Errorf("CreateContainer operation is denied by policy: %w", err) } + if envToKeep != nil { + spec.Process.Env = []string(envToKeep) + } + _ = allowStdio // TODO: enforce stdio access for Windows containers + commandLine := len(spec.Process.Args) > 0 c := &Container{ id: containerID, @@ -215,6 +220,39 @@ func processParamEnvToOCIEnv(environment map[string]string) []string { return environmentList } +// ociEnvToProcessParamEnv is the inverse of processParamEnvToOCIEnv. It converts +// an OCI-style env list (["KEY=VALUE", ...]) back to a ProcessParameters +// Environment map. +func ociEnvToProcessParamEnv(envs []string) map[string]string { + paramEnv := make(map[string]string, len(envs)) + for _, env := range envs { + parts := strings.SplitN(env, "=", 2) + if len(parts) == 2 { + paramEnv[parts[0]] = parts[1] + } + } + return paramEnv +} + +// rewriteExecRequest re-marshals an execute process request with updated +// ProcessParameters (e.g., after env filtering by policy). +func rewriteExecRequest(req *request, r prot.ContainerExecuteProcess, params hcsschema.ProcessParameters) (*request, error) { + r.Settings.ProcessParameters.Value = ¶ms + + buf, err := json.Marshal(r) + if err != nil { + return nil, fmt.Errorf("failed to marshal updated exec request: %w", err) + } + + newReq := &request{ + ctx: req.ctx, + header: req.header, + message: buf, + } + newReq.header.Size = uint32(len(buf)) + prot.HdrSize + return newReq, nil +} + func (b *Bridge) startContainer(req *request) (err error) { _, span := oc.StartSpan(req.ctx, "sidecar::startContainer") defer span.End() @@ -283,7 +321,7 @@ func (b *Bridge) executeProcess(req *request) (err error) { if containerID == UVMContainerID { log.G(req.ctx).Tracef("Enforcing policy on external exec process") - _, _, err := b.hostState.securityOptions.PolicyEnforcer.EnforceExecExternalProcessPolicy( + envToKeep, _, err := b.hostState.securityOptions.PolicyEnforcer.EnforceExecExternalProcessPolicy( req.ctx, commandLine, processParamEnvToOCIEnv(processParams.Environment), @@ -292,6 +330,13 @@ func (b *Bridge) executeProcess(req *request) (err error) { if err != nil { return errors.Wrapf(err, "exec is denied due to policy") } + if envToKeep != nil { + processParams.Environment = ociEnvToProcessParamEnv(envToKeep) + req, err = rewriteExecRequest(req, r, processParams) + if err != nil { + return fmt.Errorf("failed to rewrite exec request with filtered env: %w", err) + } + } b.forwardRequestToGcs(req) } else { // fetch the container command line @@ -315,7 +360,7 @@ func (b *Bridge) executeProcess(req *request) (err error) { Name: processParams.User, } log.G(req.ctx).Tracef("Enforcing policy on exec in container") - _, _, _, err = b.hostState.securityOptions.PolicyEnforcer. + envToKeep, _, _, err := b.hostState.securityOptions.PolicyEnforcer. EnforceExecInContainerPolicyV2( req.ctx, containerID, @@ -328,6 +373,13 @@ func (b *Bridge) executeProcess(req *request) (err error) { if err != nil { return errors.Wrapf(err, "exec in container denied due to policy") } + if envToKeep != nil { + processParams.Environment = ociEnvToProcessParamEnv(envToKeep) + req, err = rewriteExecRequest(req, r, processParams) + if err != nil { + return fmt.Errorf("failed to rewrite exec request with filtered env: %w", err) + } + } } headerID := req.header.ID diff --git a/internal/gcs-sidecar/handlers_test.go b/internal/gcs-sidecar/handlers_test.go index 6de3a0a605..575eae2112 100644 --- a/internal/gcs-sidecar/handlers_test.go +++ b/internal/gcs-sidecar/handlers_test.go @@ -327,3 +327,83 @@ func TestModifySettings_PolicyFragment_TypeAssertionFailure(t *testing.T) { t.Fatal("expected error for empty fragment, got nil") } } + +// Tests for environment variable filtering helpers (envlist persistence) + +func TestOciEnvToProcessParamEnv_Basic(t *testing.T) { + input := []string{"FOO=bar", "PATH=/usr/bin", "EMPTY="} + result := ociEnvToProcessParamEnv(input) + + if result["FOO"] != "bar" { + t.Errorf("FOO = %q, want %q", result["FOO"], "bar") + } + if result["PATH"] != "/usr/bin" { + t.Errorf("PATH = %q, want %q", result["PATH"], "/usr/bin") + } + if result["EMPTY"] != "" { + t.Errorf("EMPTY = %q, want %q", result["EMPTY"], "") + } + if len(result) != 3 { + t.Errorf("len = %d, want 3", len(result)) + } +} + +func TestOciEnvToProcessParamEnv_ValueWithEquals(t *testing.T) { + input := []string{"CONN=host=db;port=5432"} + result := ociEnvToProcessParamEnv(input) + + if result["CONN"] != "host=db;port=5432" { + t.Errorf("CONN = %q, want %q", result["CONN"], "host=db;port=5432") + } +} + +func TestOciEnvToProcessParamEnv_MalformedSkipped(t *testing.T) { + input := []string{"GOOD=value", "NOEQUALS", "ALSO_GOOD=yes"} + result := ociEnvToProcessParamEnv(input) + + if len(result) != 2 { + t.Errorf("len = %d, want 2 (malformed entry should be skipped)", len(result)) + } + if result["GOOD"] != "value" { + t.Errorf("GOOD = %q, want %q", result["GOOD"], "value") + } + if result["ALSO_GOOD"] != "yes" { + t.Errorf("ALSO_GOOD = %q, want %q", result["ALSO_GOOD"], "yes") + } +} + +func TestOciEnvToProcessParamEnv_Empty(t *testing.T) { + result := ociEnvToProcessParamEnv([]string{}) + if len(result) != 0 { + t.Errorf("len = %d, want 0", len(result)) + } +} + +func TestOciEnvToProcessParamEnv_Nil(t *testing.T) { + result := ociEnvToProcessParamEnv(nil) + if result == nil { + t.Error("result should be non-nil empty map, got nil") + } + if len(result) != 0 { + t.Errorf("len = %d, want 0", len(result)) + } +} + +func TestProcessParamEnvToOCIEnv_Roundtrip(t *testing.T) { + original := map[string]string{ + "FOO": "bar", + "PATH": "/usr/bin", + } + + ociEnv := processParamEnvToOCIEnv(original) + roundtripped := ociEnvToProcessParamEnv(ociEnv) + + if len(roundtripped) != len(original) { + t.Fatalf("roundtrip len = %d, want %d", len(roundtripped), len(original)) + } + for k, v := range original { + if roundtripped[k] != v { + t.Errorf("roundtrip[%q] = %q, want %q", k, roundtripped[k], v) + } + } +} From d83b7529d3ff779f1e8e41cf5334ef065e2f2a5a Mon Sep 17 00:00:00 2001 From: Mahati Chamarthy Date: Thu, 7 May 2026 22:07:09 +0100 Subject: [PATCH 02/56] CWCOW: Enforce MappedDirectory inside gcs-sidecar Add EnforceMappedDirectoryMountPolicy/UnmountPolicy to enforce VSMB directory shares for confidential Windows containers. Writable mapped directories are denied; duplicates at the same container path are prevented. Also add path pattern validation for MappedVirtualDisk and MappedVirtualDiskForContainerScratch to ensure SCSI mounts only target c:\mounts\scsi\m. Signed-off-by: Mahati Chamarthy --- internal/gcs-sidecar/handlers.go | 29 +++ pkg/securitypolicy/api.rego | 2 + pkg/securitypolicy/framework.rego | 44 +++++ pkg/securitypolicy/open_door.rego | 2 + pkg/securitypolicy/policy.rego | 2 + pkg/securitypolicy/regopolicy_windows_test.go | 180 ++++++++++++++++++ pkg/securitypolicy/securitypolicyenforcer.go | 18 ++ .../securitypolicyenforcer_rego.go | 17 ++ 8 files changed, 294 insertions(+) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index 81e5f9e03f..35ce682b9b 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -9,6 +9,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "strings" "time" @@ -701,6 +702,13 @@ func (b *Bridge) modifySettings(req *request) (err error) { case guestresource.ResourceTypeMappedVirtualDisk: wcowMappedVirtualDisk := modifyGuestSettingsRequest.Settings.(*guestresource.WCOWMappedVirtualDisk) log.G(ctx).Tracef("wcowMappedVirtualDisk { %v}", wcowMappedVirtualDisk) + if wcowMappedVirtualDisk.ContainerPath != "" { + matched, merr := regexp.MatchString(`(?i)^[Cc]:\\mounts\\scsi\\m[0-9]+$`, wcowMappedVirtualDisk.ContainerPath) + if merr != nil || !matched { + return fmt.Errorf("virtual disk mount path %q does not match expected pattern c:\\mounts\\scsi\\m", + wcowMappedVirtualDisk.ContainerPath) + } + } case guestresource.ResourceTypeHvSocket: hvSocketAddress := modifyGuestSettingsRequest.Settings.(*hcsschema.HvSocketAddress) @@ -709,6 +717,18 @@ func (b *Bridge) modifySettings(req *request) (err error) { case guestresource.ResourceTypeMappedDirectory: settings := modifyGuestSettingsRequest.Settings.(*hcsschema.MappedDirectory) log.G(ctx).Tracef("hcsschema.MappedDirectory { %v }", settings) + switch modifyGuestSettingsRequest.RequestType { + case guestrequest.RequestTypeAdd: + if err := b.hostState.securityOptions.PolicyEnforcer.EnforceMappedDirectoryMountPolicy( + ctx, settings.ContainerPath, settings.ReadOnly); err != nil { + return fmt.Errorf("mapped directory mount is denied by policy: %w", err) + } + case guestrequest.RequestTypeRemove: + if err := b.hostState.securityOptions.PolicyEnforcer.EnforceMappedDirectoryUnmountPolicy( + ctx, settings.ContainerPath); err != nil { + return fmt.Errorf("mapped directory unmount is denied by policy: %w", err) + } + } case guestresource.ResourceTypeSecurityPolicy: securityPolicyRequest := modifyGuestSettingsRequest.Settings.(*guestresource.ConfidentialOptions) @@ -867,6 +887,15 @@ func (b *Bridge) modifySettings(req *request) (err error) { wcowMappedVirtualDisk := modifyGuestSettingsRequest.Settings.(*guestresource.WCOWMappedVirtualDisk) log.G(ctx).Tracef("ResourceTypeMappedVirtualDiskForContainerScratch: { %v }", wcowMappedVirtualDisk) + // Validate the scratch disk mount path matches the expected pattern + if wcowMappedVirtualDisk.ContainerPath != "" { + matched, merr := regexp.MatchString(`(?i)^[Cc]:\\mounts\\scsi\\m[0-9]+$`, wcowMappedVirtualDisk.ContainerPath) + if merr != nil || !matched { + return fmt.Errorf("scratch disk mount path %q does not match expected pattern c:\\mounts\\scsi\\m", + wcowMappedVirtualDisk.ContainerPath) + } + } + // This will return the volume path of the mounted scratch. // Scratch disk should be >= 30 GB for refs formatter to work. // fsFormatter understands only virtualDevObjectPathFormat. Therefore fetch the diff --git a/pkg/securitypolicy/api.rego b/pkg/securitypolicy/api.rego index 88c3d64d14..3b89a6d139 100644 --- a/pkg/securitypolicy/api.rego +++ b/pkg/securitypolicy/api.rego @@ -24,4 +24,6 @@ enforcement_points := { "load_fragment": {"introducedVersion": "0.9.0", "default_results": {"allowed": false, "add_module": false}, "use_framework": false}, "scratch_mount": {"introducedVersion": "0.10.0", "default_results": {"allowed": true}, "use_framework": false}, "scratch_unmount": {"introducedVersion": "0.10.0", "default_results": {"allowed": true}, "use_framework": false}, + "mapped_directory_mount": {"introducedVersion": "0.11.0", "default_results": {"allowed": true}, "use_framework": false}, + "mapped_directory_unmount": {"introducedVersion": "0.11.0", "default_results": {"allowed": true}, "use_framework": false}, } diff --git a/pkg/securitypolicy/framework.rego b/pkg/securitypolicy/framework.rego index daa0fe864e..c919169f7b 100644 --- a/pkg/securitypolicy/framework.rego +++ b/pkg/securitypolicy/framework.rego @@ -1299,6 +1299,35 @@ scratch_unmount := {"metadata": [remove_scratch_mount], "allowed": true} { } } +# Mapped directory (VSMB share) validation for Windows containers +default mapped_directory_mount := {"allowed": false} + +mapped_directory_mounted(target) { + data.metadata.mapped_directories[target] +} + +mapped_directory_mount := {"metadata": [add_mapped_dir], "allowed": true} { + not mapped_directory_mounted(input.containerPath) + input.readOnly + add_mapped_dir := { + "name": "mapped_directories", + "action": "add", + "key": input.containerPath, + "value": {"readOnly": input.readOnly}, + } +} + +default mapped_directory_unmount := {"allowed": false} + +mapped_directory_unmount := {"metadata": [remove_mapped_dir], "allowed": true} { + mapped_directory_mounted(input.unmountTarget) + remove_mapped_dir := { + "name": "mapped_directories", + "action": "remove", + "key": input.unmountTarget, + } +} + # Registry changes validation default registry_changes := {"allowed": false} @@ -1827,6 +1856,21 @@ errors["no scratch at path to unmount"] { not scratch_mounted(input.unmountTarget) } +errors["mapped directory already mounted at path"] { + input.rule == "mapped_directory_mount" + mapped_directory_mounted(input.containerPath) +} + +errors["writable mapped directory not allowed"] { + input.rule == "mapped_directory_mount" + not input.readOnly +} + +errors["no mapped directory at path to unmount"] { + input.rule == "mapped_directory_unmount" + not mapped_directory_mounted(input.unmountTarget) +} + errors[framework_version_error] { policy_framework_version == null framework_version_error := concat(" ", ["framework_version is missing. Current version:", version]) diff --git a/pkg/securitypolicy/open_door.rego b/pkg/securitypolicy/open_door.rego index 02da3fa9b6..44e89499e9 100644 --- a/pkg/securitypolicy/open_door.rego +++ b/pkg/securitypolicy/open_door.rego @@ -23,3 +23,5 @@ runtime_logging := {"allowed": true} load_fragment := {"allowed": true} scratch_mount := {"allowed": true} scratch_unmount := {"allowed": true} +mapped_directory_mount := {"allowed": true} +mapped_directory_unmount := {"allowed": true} diff --git a/pkg/securitypolicy/policy.rego b/pkg/securitypolicy/policy.rego index 195d462931..f8336280b5 100644 --- a/pkg/securitypolicy/policy.rego +++ b/pkg/securitypolicy/policy.rego @@ -26,4 +26,6 @@ runtime_logging := data.framework.runtime_logging load_fragment := data.framework.load_fragment scratch_mount := data.framework.scratch_mount scratch_unmount := data.framework.scratch_unmount +mapped_directory_mount := data.framework.mapped_directory_mount +mapped_directory_unmount := data.framework.mapped_directory_unmount reason := data.framework.reason diff --git a/pkg/securitypolicy/regopolicy_windows_test.go b/pkg/securitypolicy/regopolicy_windows_test.go index 33b49a64f8..aa7372d75f 100644 --- a/pkg/securitypolicy/regopolicy_windows_test.go +++ b/pkg/securitypolicy/regopolicy_windows_test.go @@ -1514,3 +1514,183 @@ func substituteUVMPath(sandboxID string, m mountInternal) mountInternal { _ = sandboxID return m } + +// Tests for MappedDirectory enforcement + +func Test_Rego_EnforceMappedDirectoryMountPolicy_ReadOnly_Allowed_Windows(t *testing.T) { + policy, err := newRegoPolicy( + openDoorRego, + []oci.Mount{}, + []oci.Mount{}, + testOSType, + ) + if err != nil { + t.Fatalf("failed to create policy: %v", err) + } + + ctx := context.Background() + err = policy.EnforceMappedDirectoryMountPolicy(ctx, `C:\data`, true) + if err != nil { + t.Errorf("expected readonly mapped directory to be allowed: %v", err) + } +} + +func Test_Rego_EnforceMappedDirectoryMountPolicy_Writable_Denied_Windows(t *testing.T) { + f := func(p *generatedWindowsConstraints) bool { + securityPolicy := p.toPolicy() + policy, err := newRegoPolicy( + securityPolicy.marshalWindowsRego(), + []oci.Mount{}, + []oci.Mount{}, + testOSType, + ) + if err != nil { + t.Errorf("failed to create policy: %v", err) + return false + } + + ctx := context.Background() + err = policy.EnforceMappedDirectoryMountPolicy(ctx, `C:\data`, false) + if err == nil { + t.Errorf("expected writable mapped directory to be denied") + return false + } + return true + } + + if err := quick.Check(f, &quick.Config{MaxCount: 5, Rand: testRand}); err != nil { + t.Errorf("Test_Rego_EnforceMappedDirectoryMountPolicy_Writable_Denied_Windows: %v", err) + } +} + +func Test_Rego_EnforceMappedDirectoryMountPolicy_Duplicate_Denied_Windows(t *testing.T) { + f := func(p *generatedWindowsConstraints) bool { + securityPolicy := p.toPolicy() + policy, err := newRegoPolicy( + securityPolicy.marshalWindowsRego(), + []oci.Mount{}, + []oci.Mount{}, + testOSType, + ) + if err != nil { + t.Errorf("failed to create policy: %v", err) + return false + } + + ctx := context.Background() + containerPath := `C:\testmount` + + // First mount should succeed + err = policy.EnforceMappedDirectoryMountPolicy(ctx, containerPath, true) + if err != nil { + t.Errorf("first mount should succeed: %v", err) + return false + } + + // Second mount at same path should fail + err = policy.EnforceMappedDirectoryMountPolicy(ctx, containerPath, true) + if err == nil { + t.Errorf("duplicate mount at same path should be denied") + return false + } + return true + } + + if err := quick.Check(f, &quick.Config{MaxCount: 5, Rand: testRand}); err != nil { + t.Errorf("Test_Rego_EnforceMappedDirectoryMountPolicy_Duplicate_Denied_Windows: %v", err) + } +} + +func Test_Rego_EnforceMappedDirectoryUnmountPolicy_Windows(t *testing.T) { + f := func(p *generatedWindowsConstraints) bool { + securityPolicy := p.toPolicy() + policy, err := newRegoPolicy( + securityPolicy.marshalWindowsRego(), + []oci.Mount{}, + []oci.Mount{}, + testOSType, + ) + if err != nil { + t.Errorf("failed to create policy: %v", err) + return false + } + + ctx := context.Background() + containerPath := `C:\unmounttest` + + // Mount first + err = policy.EnforceMappedDirectoryMountPolicy(ctx, containerPath, true) + if err != nil { + t.Errorf("mount should succeed: %v", err) + return false + } + + // Unmount should succeed + err = policy.EnforceMappedDirectoryUnmountPolicy(ctx, containerPath) + if err != nil { + t.Errorf("unmount should succeed: %v", err) + return false + } + + return true + } + + if err := quick.Check(f, &quick.Config{MaxCount: 5, Rand: testRand}); err != nil { + t.Errorf("Test_Rego_EnforceMappedDirectoryUnmountPolicy_Windows: %v", err) + } +} + +func Test_Rego_EnforceMappedDirectoryUnmountPolicy_NotMounted_Denied_Windows(t *testing.T) { + f := func(p *generatedWindowsConstraints) bool { + securityPolicy := p.toPolicy() + policy, err := newRegoPolicy( + securityPolicy.marshalWindowsRego(), + []oci.Mount{}, + []oci.Mount{}, + testOSType, + ) + if err != nil { + t.Errorf("failed to create policy: %v", err) + return false + } + + ctx := context.Background() + // Unmount without mounting should fail + err = policy.EnforceMappedDirectoryUnmountPolicy(ctx, `C:\notmounted`) + if err == nil { + t.Errorf("unmount of non-mounted path should be denied") + return false + } + + return true + } + + if err := quick.Check(f, &quick.Config{MaxCount: 5, Rand: testRand}); err != nil { + t.Errorf("Test_Rego_EnforceMappedDirectoryUnmountPolicy_NotMounted_Denied_Windows: %v", err) + } +} + +func Test_Rego_EnforceMappedDirectoryMountPolicy_OpenDoor_AllowsAll_Windows(t *testing.T) { + policy, err := newRegoPolicy( + openDoorRego, + []oci.Mount{}, + []oci.Mount{}, + testOSType, + ) + if err != nil { + t.Fatalf("failed to create policy: %v", err) + } + + ctx := context.Background() + + // Open door should allow both readonly and writable + err = policy.EnforceMappedDirectoryMountPolicy(ctx, `C:\readonly`, true) + if err != nil { + t.Errorf("open door should allow readonly mount: %v", err) + } + + err = policy.EnforceMappedDirectoryMountPolicy(ctx, `C:\writable`, false) + if err != nil { + t.Errorf("open door should allow writable mount: %v", err) + } +} diff --git a/pkg/securitypolicy/securitypolicyenforcer.go b/pkg/securitypolicy/securitypolicyenforcer.go index 2a4edefce1..5302dafdec 100644 --- a/pkg/securitypolicy/securitypolicyenforcer.go +++ b/pkg/securitypolicy/securitypolicyenforcer.go @@ -125,6 +125,8 @@ type SecurityPolicyEnforcer interface { LoadFragment(ctx context.Context, issuer string, feed string, rego string) error EnforceScratchMountPolicy(ctx context.Context, scratchPath string, encrypted bool) (err error) EnforceScratchUnmountPolicy(ctx context.Context, scratchPath string) (err error) + EnforceMappedDirectoryMountPolicy(ctx context.Context, containerPath string, readOnly bool) (err error) + EnforceMappedDirectoryUnmountPolicy(ctx context.Context, containerPath string) (err error) GetUserInfo(spec *oci.Process, rootPath string) (IDName, []IDName, string, error) EnforceVerifiedCIMsPolicy(ctx context.Context, containerID string, layerHashes []string, mountedCim []string) (err error) EnforceRegistryChangesPolicy(ctx context.Context, containerID string, registryValues interface{}) error @@ -312,6 +314,14 @@ func (OpenDoorSecurityPolicyEnforcer) EnforceScratchUnmountPolicy(context.Contex return nil } +func (OpenDoorSecurityPolicyEnforcer) EnforceMappedDirectoryMountPolicy(context.Context, string, bool) error { + return nil +} + +func (OpenDoorSecurityPolicyEnforcer) EnforceMappedDirectoryUnmountPolicy(context.Context, string) error { + return nil +} + func (OpenDoorSecurityPolicyEnforcer) GetUserInfo(spec *oci.Process, rootPath string) (IDName, []IDName, string, error) { return IDName{}, nil, "", nil } @@ -441,6 +451,14 @@ func (ClosedDoorSecurityPolicyEnforcer) EnforceScratchUnmountPolicy(context.Cont return errors.New("unmounting scratch is denied by the policy") } +func (ClosedDoorSecurityPolicyEnforcer) EnforceMappedDirectoryMountPolicy(context.Context, string, bool) error { + return errors.New("mounting mapped directory is denied by the policy") +} + +func (ClosedDoorSecurityPolicyEnforcer) EnforceMappedDirectoryUnmountPolicy(context.Context, string) error { + return errors.New("unmounting mapped directory is denied by the policy") +} + func (ClosedDoorSecurityPolicyEnforcer) GetUserInfo(spec *oci.Process, rootPath string) (IDName, []IDName, string, error) { return IDName{}, nil, "", nil } diff --git a/pkg/securitypolicy/securitypolicyenforcer_rego.go b/pkg/securitypolicy/securitypolicyenforcer_rego.go index 96c5613dd6..846bb2278f 100644 --- a/pkg/securitypolicy/securitypolicyenforcer_rego.go +++ b/pkg/securitypolicy/securitypolicyenforcer_rego.go @@ -1157,6 +1157,23 @@ func (policy *regoEnforcer) EnforceScratchUnmountPolicy(ctx context.Context, scr return nil } +func (policy *regoEnforcer) EnforceMappedDirectoryMountPolicy(ctx context.Context, containerPath string, readOnly bool) error { + input := inputData{ + "containerPath": containerPath, + "readOnly": readOnly, + } + _, err := policy.enforce(ctx, "mapped_directory_mount", input) + return err +} + +func (policy *regoEnforcer) EnforceMappedDirectoryUnmountPolicy(ctx context.Context, containerPath string) error { + input := inputData{ + "unmountTarget": containerPath, + } + _, err := policy.enforce(ctx, "mapped_directory_unmount", input) + return err +} + func (policy *regoEnforcer) EnforceVerifiedCIMsPolicy(ctx context.Context, containerID string, layerHashes []string, mountedCim []string) error { log.G(ctx).Tracef("Enforcing verified cims in securitypolicy pkg %+v", layerHashes) input := inputData{ From 7fa1b7bf412d380992a80a7857bc9c7af57ab66f Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Mon, 1 Jun 2026 16:07:19 +0100 Subject: [PATCH 03/56] Use Windows path in environment variable tests Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/gcs-sidecar/handlers_test.go b/internal/gcs-sidecar/handlers_test.go index 575eae2112..ca81472be4 100644 --- a/internal/gcs-sidecar/handlers_test.go +++ b/internal/gcs-sidecar/handlers_test.go @@ -331,14 +331,14 @@ func TestModifySettings_PolicyFragment_TypeAssertionFailure(t *testing.T) { // Tests for environment variable filtering helpers (envlist persistence) func TestOciEnvToProcessParamEnv_Basic(t *testing.T) { - input := []string{"FOO=bar", "PATH=/usr/bin", "EMPTY="} + input := []string{"FOO=bar", `PATH=C:\Windows\System32`, "EMPTY="} result := ociEnvToProcessParamEnv(input) if result["FOO"] != "bar" { t.Errorf("FOO = %q, want %q", result["FOO"], "bar") } - if result["PATH"] != "/usr/bin" { - t.Errorf("PATH = %q, want %q", result["PATH"], "/usr/bin") + if result["PATH"] != `C:\Windows\System32` { + t.Errorf("PATH = %q, want %q", result["PATH"], `C:\Windows\System32`) } if result["EMPTY"] != "" { t.Errorf("EMPTY = %q, want %q", result["EMPTY"], "") @@ -392,7 +392,7 @@ func TestOciEnvToProcessParamEnv_Nil(t *testing.T) { func TestProcessParamEnvToOCIEnv_Roundtrip(t *testing.T) { original := map[string]string{ "FOO": "bar", - "PATH": "/usr/bin", + "PATH": `C:\Windows\System32`, } ociEnv := processParamEnvToOCIEnv(original) From 6dc7c8fbf9fbbd7478a14653b1e1d46c0bdd0437 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Mon, 1 Jun 2026 17:56:29 +0100 Subject: [PATCH 04/56] CWCOW: marshal sidecar exec rewrite via pointer Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index 35ce682b9b..de27b72af9 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -240,7 +240,7 @@ func ociEnvToProcessParamEnv(envs []string) map[string]string { func rewriteExecRequest(req *request, r prot.ContainerExecuteProcess, params hcsschema.ProcessParameters) (*request, error) { r.Settings.ProcessParameters.Value = ¶ms - buf, err := json.Marshal(r) + buf, err := json.Marshal(&r) if err != nil { return nil, fmt.Errorf("failed to marshal updated exec request: %w", err) } From 8a9015bdc0f56eae05c25daf672d0db0595e654e Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Tue, 2 Jun 2026 13:03:19 +0100 Subject: [PATCH 05/56] CWCOW: strict-decode ProcessParameters in sidecar executeProcess Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index de27b72af9..ae827d603a 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -4,6 +4,7 @@ package bridge import ( + "bytes" "encoding/hex" "encoding/json" "fmt" @@ -314,7 +315,14 @@ func (b *Bridge) executeProcess(req *request) (err error) { } containerID := r.ContainerID var processParams hcsschema.ProcessParameters - if err := commonutils.UnmarshalJSONWithHresult(processParamSettings, &processParams); err != nil { + // Strict-decode ProcessParameters so any field the sidecar's hcsschema + // struct doesn't model is rejected up-front. No field unknown to the + // sidecar should be forwarded to inbox GCS silently. When a new field + // is added, the sidecar must be made aware of it and enforce policy + // on it if needed. + dec := json.NewDecoder(bytes.NewReader(processParamSettings)) + dec.DisallowUnknownFields() + if err := dec.Decode(&processParams); err != nil { return fmt.Errorf("executeProcess: invalid params type for request: %w", err) } From e768db523c73db0511c9d0a02731c99d769473fc Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Tue, 23 Jun 2026 14:55:43 +0100 Subject: [PATCH 06/56] Add test for executeProcess env var filtering Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers_test.go | 95 +++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/internal/gcs-sidecar/handlers_test.go b/internal/gcs-sidecar/handlers_test.go index ca81472be4..b389d3e811 100644 --- a/internal/gcs-sidecar/handlers_test.go +++ b/internal/gcs-sidecar/handlers_test.go @@ -7,11 +7,13 @@ import ( "context" "encoding/json" "io" + "reflect" "testing" "time" "github.com/Microsoft/go-winio/pkg/guid" "github.com/Microsoft/hcsshim/internal/gcs/prot" + hcsschema "github.com/Microsoft/hcsshim/internal/hcs/schema2" "github.com/Microsoft/hcsshim/internal/protocol/guestrequest" "github.com/Microsoft/hcsshim/internal/protocol/guestresource" "github.com/Microsoft/hcsshim/pkg/securitypolicy" @@ -407,3 +409,96 @@ func TestProcessParamEnvToOCIEnv_Roundtrip(t *testing.T) { } } } + +// envFilterEnforcer wraps OpenDoorSecurityPolicyEnforcer and overrides the +// external-exec env-filtering hook to return a caller-specified subset. +// Embedding OpenDoor satisfies the rest of the SecurityPolicyEnforcer +// interface (all return-allow / no-op behaviour), so a single overridden +// method is enough to drive the env-filter code path in executeProcess. +type envFilterEnforcer struct { + securitypolicy.OpenDoorSecurityPolicyEnforcer + keep []string +} + +func (e *envFilterEnforcer) EnforceExecExternalProcessPolicy( + _ context.Context, _ []string, _ []string, _ string, +) (securitypolicy.EnvList, bool, error) { + return securitypolicy.EnvList(e.keep), true, nil +} + +// TestExecuteProcess_External_AppliesFilteredEnv exercises the env-filter +// rewrite path of the external-exec (UVMContainerID) branch of +// executeProcess. The fake enforcer returns a strict subset of the input +// env; the test asserts the request forwarded to GCS carries exactly that +// subset in ProcessParameters.Environment. +func TestExecuteProcess_External_AppliesFilteredEnv(t *testing.T) { + enf := &envFilterEnforcer{ + keep: []string{`PATH=C:\Windows\System32`, "KEEP=1"}, + } + b := newTestBridge(enf) + + params := hcsschema.ProcessParameters{ + CommandLine: "cmd.exe /c exit", + Environment: map[string]string{ + "PATH": `C:\Windows\System32`, + "KEEP": "1", + "DROP": "secret", + }, + } + r := prot.ContainerExecuteProcess{ + RequestBase: prot.RequestBase{ + ContainerID: UVMContainerID, + ActivityID: guid.GUID{}, + }, + Settings: prot.ExecuteProcessSettings{ + ProcessParameters: prot.AnyInString{Value: ¶ms}, + }, + } + msg, err := json.Marshal(&r) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + + req := &request{ + ctx: context.Background(), + header: messageHeader{ + Type: prot.MsgTypeRequest | prot.MsgType(prot.RPCExecuteProcess), + Size: uint32(len(msg)) + prot.HdrSize, + ID: 1, + }, + message: msg, + } + + if err := b.executeProcess(req); err != nil { + t.Fatalf("executeProcess: %v", err) + } + + var got request + select { + case got = <-b.sendToGCSCh: + case <-time.After(time.Second): + t.Fatal("nothing forwarded to GCS") + } + + // Unwrap the re-marshalled request and pull the inner ProcessParameters + // JSON back out via the same *json.RawMessage trick that the handler + // uses, then decode it as hcsschema.ProcessParameters. + var outer prot.ContainerExecuteProcess + var paramsRaw json.RawMessage + outer.Settings.ProcessParameters.Value = ¶msRaw + if err := json.Unmarshal(got.message, &outer); err != nil { + t.Fatalf("unmarshal forwarded outer: %v", err) + } + var gotParams hcsschema.ProcessParameters + if err := json.Unmarshal(paramsRaw, &gotParams); err != nil { + t.Fatalf("unmarshal forwarded ProcessParameters: %v", err) + } + + want := map[string]string{ + "PATH": `C:\Windows\System32`, + "KEEP": "1", + } + if !reflect.DeepEqual(gotParams.Environment, want) { + t.Errorf("forwarded Environment = %v, want %v", gotParams.Environment, want) + } +} From 5c658a58ca5c48335c1ef55055bad529ae5bb880 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Wed, 24 Jun 2026 10:44:34 +0100 Subject: [PATCH 07/56] Define UnmarshalJSONWithHresultStrict Signed-off-by: Takuro Sato --- internal/bridgeutils/commonutils/utilities.go | 12 ++++++++++++ internal/gcs-sidecar/handlers.go | 5 +---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/internal/bridgeutils/commonutils/utilities.go b/internal/bridgeutils/commonutils/utilities.go index 4409825ad1..a5ce72af40 100644 --- a/internal/bridgeutils/commonutils/utilities.go +++ b/internal/bridgeutils/commonutils/utilities.go @@ -1,6 +1,7 @@ package commonutils import ( + "bytes" "encoding/json" "fmt" "io" @@ -30,6 +31,17 @@ func UnmarshalJSONWithHresult(data []byte, v interface{}) error { return nil } +// UnmarshalJSONWithHresultStrict behaves like [UnmarshalJSONWithHresult] but +// rejects any JSON object key the Go type v does not declare. +func UnmarshalJSONWithHresultStrict(data []byte, v interface{}) error { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + if err := dec.Decode(v); err != nil { + return gcserr.WrapHresult(err, gcserr.HrVmcomputeInvalidJSON) + } + return nil +} + // DecodeJSONWithHresult decodes the JSON from the given reader into the given // interface, and wraps any error returned in an HRESULT error. func DecodeJSONWithHresult(r io.Reader, v interface{}) error { diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index ae827d603a..bf075a183f 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -4,7 +4,6 @@ package bridge import ( - "bytes" "encoding/hex" "encoding/json" "fmt" @@ -320,9 +319,7 @@ func (b *Bridge) executeProcess(req *request) (err error) { // sidecar should be forwarded to inbox GCS silently. When a new field // is added, the sidecar must be made aware of it and enforce policy // on it if needed. - dec := json.NewDecoder(bytes.NewReader(processParamSettings)) - dec.DisallowUnknownFields() - if err := dec.Decode(&processParams); err != nil { + if err := commonutils.UnmarshalJSONWithHresultStrict(processParamSettings, &processParams); err != nil { return fmt.Errorf("executeProcess: invalid params type for request: %w", err) } From b69031daa65f2ac68248a94adea2baac248c7abf Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Wed, 24 Jun 2026 11:08:20 +0100 Subject: [PATCH 08/56] Revert "Define UnmarshalJSONWithHresultStrict" This reverts commit 5c658a58ca5c48335c1ef55055bad529ae5bb880. Signed-off-by: Takuro Sato --- internal/bridgeutils/commonutils/utilities.go | 12 ------------ internal/gcs-sidecar/handlers.go | 5 ++++- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/internal/bridgeutils/commonutils/utilities.go b/internal/bridgeutils/commonutils/utilities.go index a5ce72af40..4409825ad1 100644 --- a/internal/bridgeutils/commonutils/utilities.go +++ b/internal/bridgeutils/commonutils/utilities.go @@ -1,7 +1,6 @@ package commonutils import ( - "bytes" "encoding/json" "fmt" "io" @@ -31,17 +30,6 @@ func UnmarshalJSONWithHresult(data []byte, v interface{}) error { return nil } -// UnmarshalJSONWithHresultStrict behaves like [UnmarshalJSONWithHresult] but -// rejects any JSON object key the Go type v does not declare. -func UnmarshalJSONWithHresultStrict(data []byte, v interface{}) error { - dec := json.NewDecoder(bytes.NewReader(data)) - dec.DisallowUnknownFields() - if err := dec.Decode(v); err != nil { - return gcserr.WrapHresult(err, gcserr.HrVmcomputeInvalidJSON) - } - return nil -} - // DecodeJSONWithHresult decodes the JSON from the given reader into the given // interface, and wraps any error returned in an HRESULT error. func DecodeJSONWithHresult(r io.Reader, v interface{}) error { diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index bf075a183f..ae827d603a 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -4,6 +4,7 @@ package bridge import ( + "bytes" "encoding/hex" "encoding/json" "fmt" @@ -319,7 +320,9 @@ func (b *Bridge) executeProcess(req *request) (err error) { // sidecar should be forwarded to inbox GCS silently. When a new field // is added, the sidecar must be made aware of it and enforce policy // on it if needed. - if err := commonutils.UnmarshalJSONWithHresultStrict(processParamSettings, &processParams); err != nil { + dec := json.NewDecoder(bytes.NewReader(processParamSettings)) + dec.DisallowUnknownFields() + if err := dec.Decode(&processParams); err != nil { return fmt.Errorf("executeProcess: invalid params type for request: %w", err) } From d4923f5ac32203e68ac4ba8c42d6f8b883719181 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Wed, 24 Jun 2026 11:08:34 +0100 Subject: [PATCH 09/56] Revert "CWCOW: strict-decode ProcessParameters in sidecar executeProcess" This reverts commit 8a9015bdc0f56eae05c25daf672d0db0595e654e. Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index ae827d603a..de27b72af9 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -4,7 +4,6 @@ package bridge import ( - "bytes" "encoding/hex" "encoding/json" "fmt" @@ -315,14 +314,7 @@ func (b *Bridge) executeProcess(req *request) (err error) { } containerID := r.ContainerID var processParams hcsschema.ProcessParameters - // Strict-decode ProcessParameters so any field the sidecar's hcsschema - // struct doesn't model is rejected up-front. No field unknown to the - // sidecar should be forwarded to inbox GCS silently. When a new field - // is added, the sidecar must be made aware of it and enforce policy - // on it if needed. - dec := json.NewDecoder(bytes.NewReader(processParamSettings)) - dec.DisallowUnknownFields() - if err := dec.Decode(&processParams); err != nil { + if err := commonutils.UnmarshalJSONWithHresult(processParamSettings, &processParams); err != nil { return fmt.Errorf("executeProcess: invalid params type for request: %w", err) } From 5bf365738b69c024f6ecba168396581f97078d45 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Thu, 25 Jun 2026 13:34:24 +0100 Subject: [PATCH 10/56] Remove duplicated test Signed-off-by: Takuro Sato --- pkg/securitypolicy/regopolicy_windows_test.go | 38 +++++-------------- 1 file changed, 10 insertions(+), 28 deletions(-) diff --git a/pkg/securitypolicy/regopolicy_windows_test.go b/pkg/securitypolicy/regopolicy_windows_test.go index aa7372d75f..1aa79d9f05 100644 --- a/pkg/securitypolicy/regopolicy_windows_test.go +++ b/pkg/securitypolicy/regopolicy_windows_test.go @@ -1517,7 +1517,7 @@ func substituteUVMPath(sandboxID string, m mountInternal) mountInternal { // Tests for MappedDirectory enforcement -func Test_Rego_EnforceMappedDirectoryMountPolicy_ReadOnly_Allowed_Windows(t *testing.T) { +func Test_Rego_EnforceMappedDirectoryMountPolicy_OpenDoor_AllowsAll_Windows(t *testing.T) { policy, err := newRegoPolicy( openDoorRego, []oci.Mount{}, @@ -1529,9 +1529,16 @@ func Test_Rego_EnforceMappedDirectoryMountPolicy_ReadOnly_Allowed_Windows(t *tes } ctx := context.Background() - err = policy.EnforceMappedDirectoryMountPolicy(ctx, `C:\data`, true) + + // Open door should allow both readonly and writable + err = policy.EnforceMappedDirectoryMountPolicy(ctx, `C:\readonly`, true) + if err != nil { + t.Errorf("open door should allow readonly mount: %v", err) + } + + err = policy.EnforceMappedDirectoryMountPolicy(ctx, `C:\writable`, false) if err != nil { - t.Errorf("expected readonly mapped directory to be allowed: %v", err) + t.Errorf("open door should allow writable mount: %v", err) } } @@ -1669,28 +1676,3 @@ func Test_Rego_EnforceMappedDirectoryUnmountPolicy_NotMounted_Denied_Windows(t * t.Errorf("Test_Rego_EnforceMappedDirectoryUnmountPolicy_NotMounted_Denied_Windows: %v", err) } } - -func Test_Rego_EnforceMappedDirectoryMountPolicy_OpenDoor_AllowsAll_Windows(t *testing.T) { - policy, err := newRegoPolicy( - openDoorRego, - []oci.Mount{}, - []oci.Mount{}, - testOSType, - ) - if err != nil { - t.Fatalf("failed to create policy: %v", err) - } - - ctx := context.Background() - - // Open door should allow both readonly and writable - err = policy.EnforceMappedDirectoryMountPolicy(ctx, `C:\readonly`, true) - if err != nil { - t.Errorf("open door should allow readonly mount: %v", err) - } - - err = policy.EnforceMappedDirectoryMountPolicy(ctx, `C:\writable`, false) - if err != nil { - t.Errorf("open door should allow writable mount: %v", err) - } -} From ec3f8bac214c962f87ebb3a530c04d92c8952bba Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Thu, 25 Jun 2026 17:15:58 +0100 Subject: [PATCH 11/56] Fix a bug where host can prevent security context dir from being put Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 4 ---- internal/guest/runtime/hcsv2/uvm.go | 7 +++---- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index de27b72af9..a8bad15c2a 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -158,14 +158,10 @@ func (b *Bridge) createContainer(req *request) (err error) { } }() - if oci.ParseAnnotationsBool(ctx, spec.Annotations, annotations.WCOWSecurityPolicyEnv, true) { if err := b.hostState.securityOptions.WriteSecurityContextDir(&spec); err != nil { return fmt.Errorf("failed to write security context dir: %w", err) } - cwcowHostedSystemConfig.Spec = spec - } - // Strip the spec field hostedSystemBytes, err := json.Marshal(cwcowHostedSystem) if err != nil { diff --git a/internal/guest/runtime/hcsv2/uvm.go b/internal/guest/runtime/hcsv2/uvm.go index bbec0b7564..fa7876a197 100644 --- a/internal/guest/runtime/hcsv2/uvm.go +++ b/internal/guest/runtime/hcsv2/uvm.go @@ -645,10 +645,9 @@ func (h *Host) CreateContainer(ctx context.Context, id string, settings *prot.VM settings.OCISpecification.Process.Capabilities = capsToKeep } - if oci.ParseAnnotationsBool(ctx, settings.OCISpecification.Annotations, annotations.LCOWSecurityPolicyEnv, true) { - if err := h.securityOptions.WriteSecurityContextDir(settings.OCISpecification); err != nil { - return nil, fmt.Errorf("failed to write security context dir: %w", err) - } + // It should not be controlled by host using the annotation + if err := h.securityOptions.WriteSecurityContextDir(settings.OCISpecification); err != nil { + return nil, fmt.Errorf("failed to write security context dir: %w", err) } // Create the BundlePath From 61f0114363f6ab851b5ce148e234208be8b36958 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Thu, 25 Jun 2026 17:16:50 +0100 Subject: [PATCH 12/56] Add comments per discussion Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 72 ++++++++++++++++++++++++++------ 1 file changed, 59 insertions(+), 13 deletions(-) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index a8bad15c2a..56dd152ff1 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -21,12 +21,10 @@ import ( hcsschema "github.com/Microsoft/hcsshim/internal/hcs/schema2" "github.com/Microsoft/hcsshim/internal/log" "github.com/Microsoft/hcsshim/internal/oc" - oci "github.com/Microsoft/hcsshim/internal/oci" "github.com/Microsoft/hcsshim/internal/protocol/guestrequest" "github.com/Microsoft/hcsshim/internal/protocol/guestresource" "github.com/Microsoft/hcsshim/internal/vm/vmutils/etw" "github.com/Microsoft/hcsshim/internal/windevice" - "github.com/Microsoft/hcsshim/pkg/annotations" "github.com/Microsoft/hcsshim/pkg/cimfs" "github.com/Microsoft/hcsshim/pkg/securitypolicy" "github.com/pkg/errors" @@ -122,6 +120,7 @@ func (b *Bridge) createContainer(req *request) (err error) { len(container.RegistryChanges.AddValues), len(defaultValues), len(nonDefaultValues)) } + // We enforce `spec`, which is not passed to inbox gcs within this function TODO.... user := securitypolicy.IDName{ Name: spec.Process.User.Username, } @@ -147,7 +146,7 @@ func (b *Bridge) createContainer(req *request) (err error) { log.G(ctx).Tracef("Adding ContainerID: %v", containerID) if err := b.hostState.AddContainer(req.ctx, containerID, c); err != nil { - log.G(ctx).Tracef("Container exists in the map.") + log.G(ctx).Tracef("Container exists in the map. containerID: %v", containerID) return err } defer func() { @@ -158,9 +157,34 @@ func (b *Bridge) createContainer(req *request) (err error) { } }() - if err := b.hostState.securityOptions.WriteSecurityContextDir(&spec); err != nil { - return fmt.Errorf("failed to write security context dir: %w", err) + if err := b.hostState.securityOptions.WriteSecurityContextDir(&spec); err != nil { + return fmt.Errorf("failed to write security context dir: %w", err) + } + + // TODO!! enforce over various fields in HostedSystem. + /* + type Container struct { + GuestOs *GuestOs `json:"GuestOs,omitempty"` + ->? Storage *Storage `json:"Storage,omitempty"` + -> MappedDirectories []MappedDirectory `json:"MappedDirectories,omitempty"` + ->? MappedPipes []MappedPipe `json:"MappedPipes,omitempty"` + Memory *Memory `json:"Memory,omitempty"` # We can't do anything about this. Host can do denial of service attack anyway. + ? Processor *Processor `json:"Processor,omitempty"` + Networking *Networking `json:"Networking,omitempty"` + HvSocket *HvSocket `json:"HvSocket,omitempty"` + ContainerCredentialGuard *ContainerCredentialGuardState `json:"ContainerCredentialGuard,omitempty"` + -> RegistryChanges *RegistryChanges `json:"RegistryChanges,omitempty"` + ->? AssignedDevices []Device `json:"AssignedDevices,omitempty"` + ->? AdditionalDeviceNamespace *ContainerDefinitionDevice `json:"AdditionalDeviceNamespace,omitempty"` } + */ + + // TODO: Delete? It's not used anymore? + // cwcowHostedSystemConfig.Spec = spec + + // Marshal the original cwcowHostedSystem from the request. + // That's safe because we've done enforcement on `spec` and + // later we will hostedSystemBytes, err := json.Marshal(cwcowHostedSystem) @@ -255,6 +279,8 @@ func (b *Bridge) startContainer(req *request) (err error) { defer span.End() defer func() { oc.SetSpanStatus(span, err) }() + // TODO: do we need enforcement? + var r prot.RequestBase if err := commonutils.UnmarshalJSONWithHresult(req.message, &r); err != nil { return fmt.Errorf("failed to unmarshal startContainer: %w", err) @@ -343,11 +369,11 @@ func (b *Bridge) executeProcess(req *request) (err error) { return fmt.Errorf("failed to get created container: %w", err) } - c.processesMutex.Lock() + c.processesMutex.Lock() // TODO: maybe move to the top of the block? isCreateExec := c.commandLine && !c.commandLineExec if isCreateExec { // if this is an exec of Container command line, then it's already enforced - // during container creation, hence skip it here + // during container creation, hence skip it here -> TODO!! c.commandLineExec = true } @@ -681,21 +707,37 @@ func (b *Bridge) modifySettings(req *request) (err error) { return fmt.Errorf("invald guestRequestType %v", guestRequestType) } + // Question: should we enforce policy for each type? Maybe just reject if we don't implement policy? if guestResourceType != "" { switch guestResourceType { case guestresource.ResourceTypeCombinedLayers: settings := modifyGuestSettingsRequest.Settings.(*guestresource.WCOWCombinedLayers) log.G(ctx).Tracef("WCOWCombinedLayers: {%v}", settings) + // TODO: Reject this type of request or enforce policy for it. + // guestresource.ResourceTypeCWCOWCombinedLayers (ResourceTypeCombinedLayers' content + ContainerID) is used for CWCOW. + // Without special reason gcs-sidecar should be able to handle + // normal WCOW. So ideally enforce policy rather than reject everything. + + // TODO: Consider removing this. Or support normal WCOW. TBD. - case guestresource.ResourceTypeNetworkNamespace: + case guestresource.ResourceTypeNetworkNamespace: // logged. settings := modifyGuestSettingsRequest.Settings.(*hcn.HostComputeNamespace) log.G(ctx).Tracef("HostComputeNamespaces { %v}", settings) + // We don't enforce policy for network namespace. + // TODO: Maybe we could enforce NamespaceType and SchemaVersion? + // What's the justification not to enforce them? + // TODO: see what lcow does - case guestresource.ResourceTypeNetwork: + case guestresource.ResourceTypeNetwork: // logged settings := modifyGuestSettingsRequest.Settings.(*guestrequest.NetworkModifyRequest) log.G(ctx).Tracef("NetworkModifyRequest { %v}", settings) + // We don't enforce policy for network setttings. + // There is no field that policy authors can expect a value to be set. + // TODO: see what lcow does case guestresource.ResourceTypeMappedVirtualDisk: + // We don't know if it's used for CWCOW. + // The change is added in case it's used for CWCOW. TODO: to see if it's used or not, maybe try attaching a test VHD through pod.json wcowMappedVirtualDisk := modifyGuestSettingsRequest.Settings.(*guestresource.WCOWMappedVirtualDisk) log.G(ctx).Tracef("wcowMappedVirtualDisk { %v}", wcowMappedVirtualDisk) if wcowMappedVirtualDisk.ContainerPath != "" { @@ -709,8 +751,10 @@ func (b *Bridge) modifySettings(req *request) (err error) { case guestresource.ResourceTypeHvSocket: hvSocketAddress := modifyGuestSettingsRequest.Settings.(*hcsschema.HvSocketAddress) log.G(ctx).Tracef("hvSocketAddress { %v }", hvSocketAddress) + // If host doesn't use it maybe remove it TODO case guestresource.ResourceTypeMappedDirectory: + // WE don't have hostpath enforcement because anyway contents of the dir can be changed by the host. settings := modifyGuestSettingsRequest.Settings.(*hcsschema.MappedDirectory) log.G(ctx).Tracef("hcsschema.MappedDirectory { %v }", settings) switch modifyGuestSettingsRequest.RequestType { @@ -760,7 +804,7 @@ func (b *Bridge) modifySettings(req *request) (err error) { } return b.sendResponseToShim(req.ctx, prot.RPCModifySettings, req.header.ID, resp) - case guestresource.ResourceTypeWCOWBlockCims: + case guestresource.ResourceTypeWCOWBlockCims: // logged // This is request to mount the merged cim at given volumeGUID switch modifyGuestSettingsRequest.RequestType { case guestrequest.RequestTypeAdd: @@ -879,7 +923,9 @@ func (b *Bridge) modifySettings(req *request) (err error) { } return nil - case guestresource.ResourceTypeMappedVirtualDiskForContainerScratch: + case guestresource.ResourceTypeMappedVirtualDiskForContainerScratch: // logged + // It doesn't have an enforcement point within this case block, but it has EnforceScratchMountPolicy + // in ResourceTypeCWCOWCombinedLayers. wcowMappedVirtualDisk := modifyGuestSettingsRequest.Settings.(*guestresource.WCOWMappedVirtualDisk) log.G(ctx).Tracef("ResourceTypeMappedVirtualDiskForContainerScratch: { %v }", wcowMappedVirtualDisk) @@ -940,7 +986,7 @@ func (b *Bridge) modifySettings(req *request) (err error) { newRequest.header.Size = uint32(len(buf)) + prot.HdrSize newRequest.message = buf req = &newRequest - case guestresource.ResourceTypeCWCOWCombinedLayers: + case guestresource.ResourceTypeCWCOWCombinedLayers: // logged settings := modifyGuestSettingsRequest.Settings.(*guestresource.CWCOWCombinedLayers) switch modifyGuestSettingsRequest.RequestType { case guestrequest.RequestTypeAdd: @@ -981,7 +1027,7 @@ func (b *Bridge) modifySettings(req *request) (err error) { } } - //Since unencrypted scratch is not an option, always pass true + //Since unencrypted scratch is not an option, always pass true TODO: fix. if err := b.hostState.securityOptions.PolicyEnforcer.EnforceScratchMountPolicy(ctx, settings.CombinedLayers.ContainerRootPath, true); err != nil { return fmt.Errorf("scratch mounting denied by policy: %w", err) } From b43f38165b94ce59f708ffbf53369192376643a4 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Thu, 25 Jun 2026 17:17:27 +0100 Subject: [PATCH 13/56] Add comments for create container enforcer per discussion Signed-off-by: Takuro Sato --- pkg/securitypolicy/securitypolicyenforcer_rego.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/securitypolicy/securitypolicyenforcer_rego.go b/pkg/securitypolicy/securitypolicyenforcer_rego.go index 846bb2278f..0d2f34c4c4 100644 --- a/pkg/securitypolicy/securitypolicyenforcer_rego.go +++ b/pkg/securitypolicy/securitypolicyenforcer_rego.go @@ -763,6 +763,8 @@ func (policy *regoEnforcer) EnforceCreateContainerPolicyV2( log.G(ctx).WithError(err).Warn("failed to obtain policy metadata snapshot") } + // TODO: check if `mounts` is missing. + // TODO: we should handle registry here? for narrowing input = inputData{ "containerID": containerID, "argList": argList, From 3db501f16a1bc74c32f14942c33f1d43a53ce351 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Thu, 25 Jun 2026 17:17:59 +0100 Subject: [PATCH 14/56] Add a TODO comment for allow_registry_changes_dropping Signed-off-by: Takuro Sato --- pkg/securitypolicy/framework.rego | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/securitypolicy/framework.rego b/pkg/securitypolicy/framework.rego index c919169f7b..a468c02e93 100644 --- a/pkg/securitypolicy/framework.rego +++ b/pkg/securitypolicy/framework.rego @@ -1421,6 +1421,8 @@ filtered_registry_values(input_values, policy_values) := [input_val | registry_value_matches(policy_val, input_val) ] +# TODO: have allow_registry_changes_dropping switch like environment variable's allow_environment_variable_dropping. + registry_changes := {"allowed": true} { containers := data.metadata.matches[input.containerID] container := containers[_] From 7151b1c3336f4afb6e90e2fdd567c8e6e81d067e Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Fri, 26 Jun 2026 13:59:41 +0100 Subject: [PATCH 15/56] Enforce ContainerPath and ReadOnly for mapped_directory policy Signed-off-by: Takuro Sato --- pkg/securitypolicy/framework.rego | 24 +- pkg/securitypolicy/rego_utils_test.go | 2 + pkg/securitypolicy/regopolicy_windows_test.go | 224 ++++++++---------- pkg/securitypolicy/securitypolicy.go | 9 + pkg/securitypolicy/securitypolicy_internal.go | 1 + pkg/securitypolicy/securitypolicy_marshal.go | 15 ++ 6 files changed, 153 insertions(+), 122 deletions(-) diff --git a/pkg/securitypolicy/framework.rego b/pkg/securitypolicy/framework.rego index a468c02e93..4e47e37c9f 100644 --- a/pkg/securitypolicy/framework.rego +++ b/pkg/securitypolicy/framework.rego @@ -1306,9 +1306,27 @@ mapped_directory_mounted(target) { data.metadata.mapped_directories[target] } +default mapped_directory_ok := false + +# allowed by an entry in the base policy +mapped_directory_ok { + mapped_directory := data.policy.mapped_directories[_] + input.containerPath == mapped_directory.container_path + input.readOnly == mapped_directory.read_only +} + +# allowed by an entry loaded from a fragment +mapped_directory_ok { + feed := data.metadata.issuers[_].feeds[_] + some fragment in feed + mapped_directory := fragment.mapped_directories[_] + input.containerPath == mapped_directory.container_path + input.readOnly == mapped_directory.read_only +} + mapped_directory_mount := {"metadata": [add_mapped_dir], "allowed": true} { not mapped_directory_mounted(input.containerPath) - input.readOnly + mapped_directory_ok add_mapped_dir := { "name": "mapped_directories", "action": "add", @@ -1863,9 +1881,9 @@ errors["mapped directory already mounted at path"] { mapped_directory_mounted(input.containerPath) } -errors["writable mapped directory not allowed"] { +errors["no matching mapped directory in policy"] { input.rule == "mapped_directory_mount" - not input.readOnly + not mapped_directory_ok } errors["no mapped directory at path to unmount"] { diff --git a/pkg/securitypolicy/rego_utils_test.go b/pkg/securitypolicy/rego_utils_test.go index 2967b5a6b7..f0434be454 100644 --- a/pkg/securitypolicy/rego_utils_test.go +++ b/pkg/securitypolicy/rego_utils_test.go @@ -2916,6 +2916,7 @@ type generatedWindowsConstraints struct { containers []*securityPolicyWindowsContainer externalProcesses []*externalProcess fragments []*fragment + mappedDirectories []WindowsMappedDirectoryRule allowGetProperties bool allowDumpStacks bool allowRuntimeLogging bool @@ -2932,6 +2933,7 @@ func (constraints *generatedWindowsConstraints) toPolicy() *securityPolicyWindow Containers: constraints.containers, ExternalProcesses: constraints.externalProcesses, Fragments: constraints.fragments, + MappedDirectories: constraints.mappedDirectories, AllowPropertiesAccess: constraints.allowGetProperties, AllowDumpStacks: constraints.allowDumpStacks, AllowRuntimeLogging: constraints.allowRuntimeLogging, diff --git a/pkg/securitypolicy/regopolicy_windows_test.go b/pkg/securitypolicy/regopolicy_windows_test.go index 1aa79d9f05..28941c52bc 100644 --- a/pkg/securitypolicy/regopolicy_windows_test.go +++ b/pkg/securitypolicy/regopolicy_windows_test.go @@ -1517,7 +1517,7 @@ func substituteUVMPath(sandboxID string, m mountInternal) mountInternal { // Tests for MappedDirectory enforcement -func Test_Rego_EnforceMappedDirectoryMountPolicy_OpenDoor_AllowsAll_Windows(t *testing.T) { +func Test_Rego_EnforceMappedDirectoryPolicy_OpenDoor_AllowsAll_Windows(t *testing.T) { policy, err := newRegoPolicy( openDoorRego, []oci.Mount{}, @@ -1530,149 +1530,135 @@ func Test_Rego_EnforceMappedDirectoryMountPolicy_OpenDoor_AllowsAll_Windows(t *t ctx := context.Background() - // Open door should allow both readonly and writable - err = policy.EnforceMappedDirectoryMountPolicy(ctx, `C:\readonly`, true) - if err != nil { + // Open door should allow both readonly and writable mounts, regardless of + // path, and an unmount of a never-mounted path. + if err := policy.EnforceMappedDirectoryMountPolicy(ctx, `C:\readonly`, true); err != nil { t.Errorf("open door should allow readonly mount: %v", err) } - - err = policy.EnforceMappedDirectoryMountPolicy(ctx, `C:\writable`, false) - if err != nil { + if err := policy.EnforceMappedDirectoryMountPolicy(ctx, `C:\writable`, false); err != nil { t.Errorf("open door should allow writable mount: %v", err) } + if err := policy.EnforceMappedDirectoryUnmountPolicy(ctx, `C:\never_mounted`); err != nil { + t.Errorf("open door should allow unmount of any path: %v", err) + } } -func Test_Rego_EnforceMappedDirectoryMountPolicy_Writable_Denied_Windows(t *testing.T) { - f := func(p *generatedWindowsConstraints) bool { - securityPolicy := p.toPolicy() - policy, err := newRegoPolicy( - securityPolicy.marshalWindowsRego(), - []oci.Mount{}, - []oci.Mount{}, - testOSType, - ) - if err != nil { - t.Errorf("failed to create policy: %v", err) - return false - } +func Test_Rego_EnforceMappedDirectoryPolicy_ClosedDoor_DeniesAll_Windows(t *testing.T) { + // Mirror of the open-door case: a hand-rolled policy that explicitly + // returns {"allowed": false} for both mapped-directory rules. This + // verifies the Go-side enforcer surfaces the deny decision regardless + // of input. + closedDoorRego := fmt.Sprintf(`package policy +api_version := "%s" - ctx := context.Background() - err = policy.EnforceMappedDirectoryMountPolicy(ctx, `C:\data`, false) - if err == nil { - t.Errorf("expected writable mapped directory to be denied") - return false - } - return true - } +mapped_directory_mount := {"allowed": false} +mapped_directory_unmount := {"allowed": false} +`, apiVersion) - if err := quick.Check(f, &quick.Config{MaxCount: 5, Rand: testRand}); err != nil { - t.Errorf("Test_Rego_EnforceMappedDirectoryMountPolicy_Writable_Denied_Windows: %v", err) + policy, err := newRegoPolicy( + closedDoorRego, + []oci.Mount{}, + []oci.Mount{}, + testOSType, + ) + if err != nil { + t.Fatalf("failed to create policy: %v", err) } -} -func Test_Rego_EnforceMappedDirectoryMountPolicy_Duplicate_Denied_Windows(t *testing.T) { - f := func(p *generatedWindowsConstraints) bool { - securityPolicy := p.toPolicy() - policy, err := newRegoPolicy( - securityPolicy.marshalWindowsRego(), - []oci.Mount{}, - []oci.Mount{}, - testOSType, - ) - if err != nil { - t.Errorf("failed to create policy: %v", err) - return false - } - - ctx := context.Background() - containerPath := `C:\testmount` - - // First mount should succeed - err = policy.EnforceMappedDirectoryMountPolicy(ctx, containerPath, true) - if err != nil { - t.Errorf("first mount should succeed: %v", err) - return false - } + ctx := context.Background() - // Second mount at same path should fail - err = policy.EnforceMappedDirectoryMountPolicy(ctx, containerPath, true) - if err == nil { - t.Errorf("duplicate mount at same path should be denied") - return false - } - return true + if err := policy.EnforceMappedDirectoryMountPolicy(ctx, `C:\readonly`, true); err == nil { + t.Error("closed door should deny readonly mount") } - - if err := quick.Check(f, &quick.Config{MaxCount: 5, Rand: testRand}); err != nil { - t.Errorf("Test_Rego_EnforceMappedDirectoryMountPolicy_Duplicate_Denied_Windows: %v", err) + if err := policy.EnforceMappedDirectoryMountPolicy(ctx, `C:\writable`, false); err == nil { + t.Error("closed door should deny writable mount") + } + if err := policy.EnforceMappedDirectoryUnmountPolicy(ctx, `C:\any_path`); err == nil { + t.Error("closed door should deny unmount of any path") } } -func Test_Rego_EnforceMappedDirectoryUnmountPolicy_Windows(t *testing.T) { - f := func(p *generatedWindowsConstraints) bool { - securityPolicy := p.toPolicy() - policy, err := newRegoPolicy( - securityPolicy.marshalWindowsRego(), - []oci.Mount{}, - []oci.Mount{}, - testOSType, - ) - if err != nil { - t.Errorf("failed to create policy: %v", err) - return false - } - - ctx := context.Background() - containerPath := `C:\unmounttest` - - // Mount first - err = policy.EnforceMappedDirectoryMountPolicy(ctx, containerPath, true) - if err != nil { - t.Errorf("mount should succeed: %v", err) - return false - } +// newMappedDirTestPolicy builds a regoEnforcer whose mapped_directories +// whitelist contains exactly the supplied rules. The rest of the policy is +// empty, which is enough to exercise the mapped-directory mount/unmount +// rules in isolation. +func newMappedDirTestPolicy(t *testing.T, rules []WindowsMappedDirectoryRule) *regoEnforcer { + t.Helper() + gc := &generatedWindowsConstraints{ + ctx: context.Background(), + mappedDirectories: rules, + } + policy, err := newRegoPolicy(gc.toPolicy().marshalWindowsRego(), []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("failed to create mapped-dir test policy: %v", err) + } + return policy +} - // Unmount should succeed - err = policy.EnforceMappedDirectoryUnmountPolicy(ctx, containerPath) - if err != nil { - t.Errorf("unmount should succeed: %v", err) - return false - } +func Test_Rego_EnforceMappedDirectoryMountPolicy_Allowed_Windows(t *testing.T) { + policy := newMappedDirTestPolicy(t, []WindowsMappedDirectoryRule{ + {ContainerPath: `C:\readonly`, ReadOnly: true}, + {ContainerPath: `C:\writable`, ReadOnly: false}, + }) + ctx := context.Background() - return true + if err := policy.EnforceMappedDirectoryMountPolicy(ctx, `C:\readonly`, true); err != nil { + t.Errorf("whitelisted read-only mount unexpectedly denied: %v", err) } + if err := policy.EnforceMappedDirectoryMountPolicy(ctx, `C:\writable`, false); err != nil { + t.Errorf("whitelisted writable mount unexpectedly denied: %v", err) + } +} - if err := quick.Check(f, &quick.Config{MaxCount: 5, Rand: testRand}); err != nil { - t.Errorf("Test_Rego_EnforceMappedDirectoryUnmountPolicy_Windows: %v", err) +func Test_Rego_EnforceMappedDirectoryMountPolicy_NotInWhitelist_Denied_Windows(t *testing.T) { + policy := newMappedDirTestPolicy(t, []WindowsMappedDirectoryRule{ + {ContainerPath: `C:\data`, ReadOnly: true}, + }) + if err := policy.EnforceMappedDirectoryMountPolicy(context.Background(), `C:\other`, true); err == nil { + t.Fatal("mount of non-whitelisted path unexpectedly allowed") } } -func Test_Rego_EnforceMappedDirectoryUnmountPolicy_NotMounted_Denied_Windows(t *testing.T) { - f := func(p *generatedWindowsConstraints) bool { - securityPolicy := p.toPolicy() - policy, err := newRegoPolicy( - securityPolicy.marshalWindowsRego(), - []oci.Mount{}, - []oci.Mount{}, - testOSType, - ) - if err != nil { - t.Errorf("failed to create policy: %v", err) - return false - } +func Test_Rego_EnforceMappedDirectoryMountPolicy_WrongReadOnly_Denied_Windows(t *testing.T) { + policy := newMappedDirTestPolicy(t, []WindowsMappedDirectoryRule{ + {ContainerPath: `C:\data`, ReadOnly: true}, + }) + if err := policy.EnforceMappedDirectoryMountPolicy(context.Background(), `C:\data`, false); err == nil { + t.Fatal("writable mount at read-only-only path unexpectedly allowed") + } +} - ctx := context.Background() - // Unmount without mounting should fail - err = policy.EnforceMappedDirectoryUnmountPolicy(ctx, `C:\notmounted`) - if err == nil { - t.Errorf("unmount of non-mounted path should be denied") - return false - } +func Test_Rego_EnforceMappedDirectoryMountPolicy_Duplicate_Denied_Windows(t *testing.T) { + policy := newMappedDirTestPolicy(t, []WindowsMappedDirectoryRule{ + {ContainerPath: `C:\data`, ReadOnly: true}, + }) + ctx := context.Background() + if err := policy.EnforceMappedDirectoryMountPolicy(ctx, `C:\data`, true); err != nil { + t.Fatalf("first mount unexpectedly denied: %v", err) + } + if err := policy.EnforceMappedDirectoryMountPolicy(ctx, `C:\data`, true); err == nil { + t.Fatal("duplicate mount at same path unexpectedly allowed") + } +} - return true +func Test_Rego_EnforceMappedDirectoryUnmountPolicy_Windows(t *testing.T) { + policy := newMappedDirTestPolicy(t, []WindowsMappedDirectoryRule{ + {ContainerPath: `C:\data`, ReadOnly: true}, + }) + ctx := context.Background() + if err := policy.EnforceMappedDirectoryMountPolicy(ctx, `C:\data`, true); err != nil { + t.Fatalf("mount unexpectedly denied: %v", err) + } + if err := policy.EnforceMappedDirectoryUnmountPolicy(ctx, `C:\data`); err != nil { + t.Fatalf("unmount of mounted path unexpectedly denied: %v", err) } +} - if err := quick.Check(f, &quick.Config{MaxCount: 5, Rand: testRand}); err != nil { - t.Errorf("Test_Rego_EnforceMappedDirectoryUnmountPolicy_NotMounted_Denied_Windows: %v", err) +func Test_Rego_EnforceMappedDirectoryUnmountPolicy_NotMounted_Denied_Windows(t *testing.T) { + policy := newMappedDirTestPolicy(t, []WindowsMappedDirectoryRule{ + {ContainerPath: `C:\data`, ReadOnly: true}, + }) + if err := policy.EnforceMappedDirectoryUnmountPolicy(context.Background(), `C:\data`); err == nil { + t.Fatal("unmount of non-mounted path unexpectedly allowed") } } diff --git a/pkg/securitypolicy/securitypolicy.go b/pkg/securitypolicy/securitypolicy.go index 5b05160492..1360964bcf 100644 --- a/pkg/securitypolicy/securitypolicy.go +++ b/pkg/securitypolicy/securitypolicy.go @@ -94,6 +94,15 @@ type FragmentConfig struct { Includes []string `json:"includes" toml:"include"` } +// WindowsMappedDirectoryRule describes a single whitelisted VSMB mapped +// directory share for a Windows UVM. Mapped directories are mounted at the +// UVM level (before any container is started), so the rule is keyed only on +// the container-visible path and the read-only flag. +type WindowsMappedDirectoryRule struct { + ContainerPath string `json:"container_path" toml:"container_path"` + ReadOnly bool `json:"read_only" toml:"read_only"` +} + // AuthConfig contains toml or JSON config for registry authentication. type AuthConfig struct { Username string `json:"username" toml:"username"` diff --git a/pkg/securitypolicy/securitypolicy_internal.go b/pkg/securitypolicy/securitypolicy_internal.go index c736fb58ed..5d4740902d 100644 --- a/pkg/securitypolicy/securitypolicy_internal.go +++ b/pkg/securitypolicy/securitypolicy_internal.go @@ -26,6 +26,7 @@ type securityPolicyWindowsInternal struct { Containers []*securityPolicyWindowsContainer ExternalProcesses []*externalProcess Fragments []*fragment + MappedDirectories []WindowsMappedDirectoryRule AllowPropertiesAccess bool AllowDumpStacks bool AllowRuntimeLogging bool diff --git a/pkg/securitypolicy/securitypolicy_marshal.go b/pkg/securitypolicy/securitypolicy_marshal.go index 665dc9e4f0..4a250c5d95 100644 --- a/pkg/securitypolicy/securitypolicy_marshal.go +++ b/pkg/securitypolicy/securitypolicy_marshal.go @@ -585,6 +585,20 @@ func addFragments(builder *strings.Builder, fragments []*fragment) { writeLine(builder, "]") } +func addWindowsMappedDirectories(builder *strings.Builder, rules []WindowsMappedDirectoryRule) { + if len(rules) == 0 { + return + } + + writeLine(builder, "mapped_directories := [") + + for _, rule := range rules { + writeLine(builder, `%s{"container_path": %q, "read_only": %t},`, indentUsing, rule.ContainerPath, rule.ReadOnly) + } + + writeLine(builder, "]") +} + func (p securityPolicyInternal) marshalRego() string { builder := new(strings.Builder) addFragments(builder, p.Fragments) @@ -615,6 +629,7 @@ func (p securityPolicyWindowsInternal) marshalWindowsRego() string { addFragments(builder, p.Fragments) addWindowsContainers(builder, p.Containers) addExternalProcesses(builder, p.ExternalProcesses) + addWindowsMappedDirectories(builder, p.MappedDirectories) writeLine(builder, `allow_properties_access := %t`, p.AllowPropertiesAccess) writeLine(builder, `allow_dump_stacks := %t`, p.AllowDumpStacks) writeLine(builder, `allow_runtime_logging := %t`, p.AllowRuntimeLogging) From 50f3410c0411a869efe1b5652059d17f7d9716d5 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Fri, 26 Jun 2026 15:01:35 +0100 Subject: [PATCH 16/56] Rewrite tests with existing pattern Signed-off-by: Takuro Sato --- pkg/securitypolicy/rego_utils_test.go | 49 +++++ pkg/securitypolicy/regopolicy_windows_test.go | 178 ++++++++++++------ 2 files changed, 166 insertions(+), 61 deletions(-) diff --git a/pkg/securitypolicy/rego_utils_test.go b/pkg/securitypolicy/rego_utils_test.go index f0434be454..aa35dc5d9b 100644 --- a/pkg/securitypolicy/rego_utils_test.go +++ b/pkg/securitypolicy/rego_utils_test.go @@ -63,6 +63,8 @@ const ( maxGeneratedMountOptionLength = 32 maxGeneratedExecProcesses = 4 maxGeneratedWorkingDirLength = 128 + maxGeneratedMappedDirectories = 8 + maxGeneratedMappedDirectoryPathLength = 64 maxSignalNumber = 64 maxGeneratedNameLength = 8 maxGeneratedGroupNames = 4 @@ -712,6 +714,27 @@ type regoExternalPolicyTestConfig struct { policy *regoEnforcer } +func setupWindowsMappedDirectoriesTest(gc *generatedWindowsConstraints) (tc *regoMappedDirectoriesTestConfig, err error) { + gc.mappedDirectories = generateMappedDirectories(testRand) + securityPolicy := gc.toPolicy() + + policy, err := newRegoPolicy(securityPolicy.marshalWindowsRego(), + []oci.Mount{}, + []oci.Mount{}, + testOSType) + if err != nil { + return nil, err + } + + return ®oMappedDirectoriesTestConfig{ + policy: policy, + }, nil +} + +type regoMappedDirectoriesTestConfig struct { + policy *regoEnforcer +} + func setupGetPropertiesTest(gc *generatedConstraints, allowPropertiesAccess bool) (tc *regoGetPropertiesTestConfig, err error) { gc.allowGetProperties = allowPropertiesAccess @@ -1998,6 +2021,10 @@ func selectWindowsExternalProcessFromConstraints(constraints *generatedWindowsCo return constraints.externalProcesses[r.Intn(numberOfProcessesInConstraints)] } +func selectMappedDirectoryFromConstraints(constraints *generatedWindowsConstraints, r *rand.Rand) WindowsMappedDirectoryRule { + return constraints.mappedDirectories[r.Intn(len(constraints.mappedDirectories))] +} + func (constraints *generatedConstraints) toPolicy() *securityPolicyInternal { return &securityPolicyInternal{ Containers: constraints.containers, @@ -2384,6 +2411,28 @@ func generateWorkingDir(r *rand.Rand) string { return randVariableString(r, maxGeneratedWorkingDirLength) } +func generateMappedDirectory(r *rand.Rand) WindowsMappedDirectoryRule { + return WindowsMappedDirectoryRule{ + ContainerPath: `C:\` + randVariableString(r, maxGeneratedMappedDirectoryPathLength), + ReadOnly: randBool(r), + } +} + +func generateMappedDirectories(r *rand.Rand) []WindowsMappedDirectoryRule { + numRules := atLeastOneAtMost(r, maxGeneratedMappedDirectories) + rules := make([]WindowsMappedDirectoryRule, 0, numRules) + seen := make(map[string]struct{}, numRules) + for int32(len(rules)) < numRules { + rule := generateMappedDirectory(r) + if _, dup := seen[rule.ContainerPath]; dup { + continue + } + seen[rule.ContainerPath] = struct{}{} + rules = append(rules, rule) + } + return rules +} + func generateWindowsUser(r *rand.Rand) string { return randVariableString(r, maxGeneratedWorkingDirLength) } diff --git a/pkg/securitypolicy/regopolicy_windows_test.go b/pkg/securitypolicy/regopolicy_windows_test.go index 28941c52bc..c795a040a6 100644 --- a/pkg/securitypolicy/regopolicy_windows_test.go +++ b/pkg/securitypolicy/regopolicy_windows_test.go @@ -1578,87 +1578,143 @@ mapped_directory_unmount := {"allowed": false} } } -// newMappedDirTestPolicy builds a regoEnforcer whose mapped_directories -// whitelist contains exactly the supplied rules. The rest of the policy is -// empty, which is enough to exercise the mapped-directory mount/unmount -// rules in isolation. -func newMappedDirTestPolicy(t *testing.T, rules []WindowsMappedDirectoryRule) *regoEnforcer { - t.Helper() - gc := &generatedWindowsConstraints{ - ctx: context.Background(), - mappedDirectories: rules, - } - policy, err := newRegoPolicy(gc.toPolicy().marshalWindowsRego(), []oci.Mount{}, []oci.Mount{}, testOSType) - if err != nil { - t.Fatalf("failed to create mapped-dir test policy: %v", err) +func Test_Rego_EnforceMappedDirectoryMountPolicy_Matches_Windows(t *testing.T) { + f := func(gc *generatedWindowsConstraints) bool { + tc, err := setupWindowsMappedDirectoriesTest(gc) + if err != nil { + t.Error(err) + return false + } + + rule := selectMappedDirectoryFromConstraints(gc, testRand) + + err = tc.policy.EnforceMappedDirectoryMountPolicy(gc.ctx, rule.ContainerPath, rule.ReadOnly) + + // getting an error means something is broken + return err == nil + } + + if err := quick.Check(f, &quick.Config{MaxCount: 50, Rand: testRand}); err != nil { + t.Errorf("Test_Rego_EnforceMappedDirectoryMountPolicy_Matches_Windows failed: %v", err) } - return policy } -func Test_Rego_EnforceMappedDirectoryMountPolicy_Allowed_Windows(t *testing.T) { - policy := newMappedDirTestPolicy(t, []WindowsMappedDirectoryRule{ - {ContainerPath: `C:\readonly`, ReadOnly: true}, - {ContainerPath: `C:\writable`, ReadOnly: false}, - }) - ctx := context.Background() +func Test_Rego_EnforceMappedDirectoryMountPolicy_No_Matches_Windows(t *testing.T) { + f := func(gc *generatedWindowsConstraints) bool { + tc, err := setupWindowsMappedDirectoriesTest(gc) + if err != nil { + t.Error(err) + return false + } - if err := policy.EnforceMappedDirectoryMountPolicy(ctx, `C:\readonly`, true); err != nil { - t.Errorf("whitelisted read-only mount unexpectedly denied: %v", err) + fresh := generateMappedDirectory(testRand) + + err = tc.policy.EnforceMappedDirectoryMountPolicy(gc.ctx, fresh.ContainerPath, fresh.ReadOnly) + + return assertDecisionJSONContains(t, err, "no matching mapped directory in policy") } - if err := policy.EnforceMappedDirectoryMountPolicy(ctx, `C:\writable`, false); err != nil { - t.Errorf("whitelisted writable mount unexpectedly denied: %v", err) + + if err := quick.Check(f, &quick.Config{MaxCount: 50, Rand: testRand}); err != nil { + t.Errorf("Test_Rego_EnforceMappedDirectoryMountPolicy_No_Matches_Windows failed: %v", err) } } -func Test_Rego_EnforceMappedDirectoryMountPolicy_NotInWhitelist_Denied_Windows(t *testing.T) { - policy := newMappedDirTestPolicy(t, []WindowsMappedDirectoryRule{ - {ContainerPath: `C:\data`, ReadOnly: true}, - }) - if err := policy.EnforceMappedDirectoryMountPolicy(context.Background(), `C:\other`, true); err == nil { - t.Fatal("mount of non-whitelisted path unexpectedly allowed") +func Test_Rego_EnforceMappedDirectoryMountPolicy_Wrong_ReadOnly_Windows(t *testing.T) { + f := func(gc *generatedWindowsConstraints) bool { + tc, err := setupWindowsMappedDirectoriesTest(gc) + if err != nil { + t.Error(err) + return false + } + + rule := selectMappedDirectoryFromConstraints(gc, testRand) + + err = tc.policy.EnforceMappedDirectoryMountPolicy(gc.ctx, rule.ContainerPath, !rule.ReadOnly) + + return assertDecisionJSONContains(t, err, "no matching mapped directory in policy") } -} -func Test_Rego_EnforceMappedDirectoryMountPolicy_WrongReadOnly_Denied_Windows(t *testing.T) { - policy := newMappedDirTestPolicy(t, []WindowsMappedDirectoryRule{ - {ContainerPath: `C:\data`, ReadOnly: true}, - }) - if err := policy.EnforceMappedDirectoryMountPolicy(context.Background(), `C:\data`, false); err == nil { - t.Fatal("writable mount at read-only-only path unexpectedly allowed") + if err := quick.Check(f, &quick.Config{MaxCount: 50, Rand: testRand}); err != nil { + t.Errorf("Test_Rego_EnforceMappedDirectoryMountPolicy_Wrong_ReadOnly_Windows failed: %v", err) } } -func Test_Rego_EnforceMappedDirectoryMountPolicy_Duplicate_Denied_Windows(t *testing.T) { - policy := newMappedDirTestPolicy(t, []WindowsMappedDirectoryRule{ - {ContainerPath: `C:\data`, ReadOnly: true}, - }) - ctx := context.Background() - if err := policy.EnforceMappedDirectoryMountPolicy(ctx, `C:\data`, true); err != nil { - t.Fatalf("first mount unexpectedly denied: %v", err) +func Test_Rego_EnforceMappedDirectoryMountPolicy_Duplicate_Container_Path_Windows(t *testing.T) { + f := func(gc *generatedWindowsConstraints) bool { + tc, err := setupWindowsMappedDirectoriesTest(gc) + if err != nil { + t.Error(err) + return false + } + + rule := selectMappedDirectoryFromConstraints(gc, testRand) + + if err := tc.policy.EnforceMappedDirectoryMountPolicy(gc.ctx, rule.ContainerPath, rule.ReadOnly); err != nil { + t.Error("Valid mapped directory mount failed. It shouldn't have.") + return false + } + + err = tc.policy.EnforceMappedDirectoryMountPolicy(gc.ctx, rule.ContainerPath, rule.ReadOnly) + if err == nil { + t.Error("Duplicate mapped directory mount target was allowed. It shouldn't have been.") + return false + } + + return assertDecisionJSONContains(t, err, "mapped directory already mounted at path") } - if err := policy.EnforceMappedDirectoryMountPolicy(ctx, `C:\data`, true); err == nil { - t.Fatal("duplicate mount at same path unexpectedly allowed") + + if err := quick.Check(f, &quick.Config{MaxCount: 50, Rand: testRand}); err != nil { + t.Errorf("Test_Rego_EnforceMappedDirectoryMountPolicy_Duplicate_Container_Path_Windows failed: %v", err) } } -func Test_Rego_EnforceMappedDirectoryUnmountPolicy_Windows(t *testing.T) { - policy := newMappedDirTestPolicy(t, []WindowsMappedDirectoryRule{ - {ContainerPath: `C:\data`, ReadOnly: true}, - }) - ctx := context.Background() - if err := policy.EnforceMappedDirectoryMountPolicy(ctx, `C:\data`, true); err != nil { - t.Fatalf("mount unexpectedly denied: %v", err) +func Test_Rego_EnforceMappedDirectoryUnmountPolicy_Removes_Mapped_Directory_Entries_Windows(t *testing.T) { + f := func(gc *generatedWindowsConstraints) bool { + tc, err := setupWindowsMappedDirectoriesTest(gc) + if err != nil { + t.Error(err) + return false + } + + rule := selectMappedDirectoryFromConstraints(gc, testRand) + + if err := tc.policy.EnforceMappedDirectoryMountPolicy(gc.ctx, rule.ContainerPath, rule.ReadOnly); err != nil { + t.Errorf("unable to mount mapped directory: %v", err) + return false + } + if err := tc.policy.EnforceMappedDirectoryUnmountPolicy(gc.ctx, rule.ContainerPath); err != nil { + t.Errorf("unable to unmount mapped directory: %v", err) + return false + } + if err := tc.policy.EnforceMappedDirectoryMountPolicy(gc.ctx, rule.ContainerPath, rule.ReadOnly); err != nil { + t.Errorf("unable to re-mount mapped directory: %v", err) + return false + } + + return true } - if err := policy.EnforceMappedDirectoryUnmountPolicy(ctx, `C:\data`); err != nil { - t.Fatalf("unmount of mounted path unexpectedly denied: %v", err) + + if err := quick.Check(f, &quick.Config{MaxCount: 50, Rand: testRand}); err != nil { + t.Errorf("Test_Rego_EnforceMappedDirectoryUnmountPolicy_Removes_Mapped_Directory_Entries_Windows failed: %v", err) } } -func Test_Rego_EnforceMappedDirectoryUnmountPolicy_NotMounted_Denied_Windows(t *testing.T) { - policy := newMappedDirTestPolicy(t, []WindowsMappedDirectoryRule{ - {ContainerPath: `C:\data`, ReadOnly: true}, - }) - if err := policy.EnforceMappedDirectoryUnmountPolicy(context.Background(), `C:\data`); err == nil { - t.Fatal("unmount of non-mounted path unexpectedly allowed") +func Test_Rego_EnforceMappedDirectoryUnmountPolicy_No_Matches_Windows(t *testing.T) { + f := func(gc *generatedWindowsConstraints) bool { + tc, err := setupWindowsMappedDirectoriesTest(gc) + if err != nil { + t.Error(err) + return false + } + + fresh := generateMappedDirectory(testRand) + + err = tc.policy.EnforceMappedDirectoryUnmountPolicy(gc.ctx, fresh.ContainerPath) + + return assertDecisionJSONContains(t, err, "no mapped directory at path to unmount") + } + + if err := quick.Check(f, &quick.Config{MaxCount: 50, Rand: testRand}); err != nil { + t.Errorf("Test_Rego_EnforceMappedDirectoryUnmountPolicy_No_Matches_Windows failed: %v", err) } } From 3fde0e5078a2d338aa9365191b5d4f9e227f4e13 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Mon, 29 Jun 2026 13:13:27 +0100 Subject: [PATCH 17/56] Update comments Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 35 ++++++++++++++++--------------- pkg/securitypolicy/framework.rego | 2 +- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index 56dd152ff1..1bb043f248 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -120,7 +120,10 @@ func (b *Bridge) createContainer(req *request) (err error) { len(container.RegistryChanges.AddValues), len(defaultValues), len(nonDefaultValues)) } - // We enforce `spec`, which is not passed to inbox gcs within this function TODO.... + // We enforce `spec`, which is not passed to inbox gcs within this createContainer. + // The result of enforcement is stored in memory and used for executeProcess. + // + // TODO: Implement the above logic in executeProcess. user := securitypolicy.IDName{ Name: spec.Process.User.Username, } @@ -179,9 +182,6 @@ func (b *Bridge) createContainer(req *request) (err error) { } */ - // TODO: Delete? It's not used anymore? - // cwcowHostedSystemConfig.Spec = spec - // Marshal the original cwcowHostedSystem from the request. // That's safe because we've done enforcement on `spec` and // later we will @@ -373,7 +373,10 @@ func (b *Bridge) executeProcess(req *request) (err error) { isCreateExec := c.commandLine && !c.commandLineExec if isCreateExec { // if this is an exec of Container command line, then it's already enforced - // during container creation, hence skip it here -> TODO!! + // during container creation. + // TODO: Use the result of enforcement from container creation to + // validate the exec command line and drop environment variable if necessary. + c.commandLineExec = true } @@ -711,16 +714,15 @@ func (b *Bridge) modifySettings(req *request) (err error) { if guestResourceType != "" { switch guestResourceType { case guestresource.ResourceTypeCombinedLayers: + // This is for non-confidential WCOW. + // Ideally gcs-sidecar supports it with policy enforcement, + // but for now we just reject it because + // we don't have a policy enforcer for it. settings := modifyGuestSettingsRequest.Settings.(*guestresource.WCOWCombinedLayers) log.G(ctx).Tracef("WCOWCombinedLayers: {%v}", settings) - // TODO: Reject this type of request or enforce policy for it. - // guestresource.ResourceTypeCWCOWCombinedLayers (ResourceTypeCombinedLayers' content + ContainerID) is used for CWCOW. - // Without special reason gcs-sidecar should be able to handle - // normal WCOW. So ideally enforce policy rather than reject everything. - - // TODO: Consider removing this. Or support normal WCOW. TBD. + return fmt.Errorf("WCOWCombinedLayers is not supported.") - case guestresource.ResourceTypeNetworkNamespace: // logged. + case guestresource.ResourceTypeNetworkNamespace: settings := modifyGuestSettingsRequest.Settings.(*hcn.HostComputeNamespace) log.G(ctx).Tracef("HostComputeNamespaces { %v}", settings) // We don't enforce policy for network namespace. @@ -728,7 +730,7 @@ func (b *Bridge) modifySettings(req *request) (err error) { // What's the justification not to enforce them? // TODO: see what lcow does - case guestresource.ResourceTypeNetwork: // logged + case guestresource.ResourceTypeNetwork: settings := modifyGuestSettingsRequest.Settings.(*guestrequest.NetworkModifyRequest) log.G(ctx).Tracef("NetworkModifyRequest { %v}", settings) // We don't enforce policy for network setttings. @@ -804,7 +806,7 @@ func (b *Bridge) modifySettings(req *request) (err error) { } return b.sendResponseToShim(req.ctx, prot.RPCModifySettings, req.header.ID, resp) - case guestresource.ResourceTypeWCOWBlockCims: // logged + case guestresource.ResourceTypeWCOWBlockCims: // This is request to mount the merged cim at given volumeGUID switch modifyGuestSettingsRequest.RequestType { case guestrequest.RequestTypeAdd: @@ -923,7 +925,7 @@ func (b *Bridge) modifySettings(req *request) (err error) { } return nil - case guestresource.ResourceTypeMappedVirtualDiskForContainerScratch: // logged + case guestresource.ResourceTypeMappedVirtualDiskForContainerScratch: // It doesn't have an enforcement point within this case block, but it has EnforceScratchMountPolicy // in ResourceTypeCWCOWCombinedLayers. wcowMappedVirtualDisk := modifyGuestSettingsRequest.Settings.(*guestresource.WCOWMappedVirtualDisk) @@ -986,7 +988,7 @@ func (b *Bridge) modifySettings(req *request) (err error) { newRequest.header.Size = uint32(len(buf)) + prot.HdrSize newRequest.message = buf req = &newRequest - case guestresource.ResourceTypeCWCOWCombinedLayers: // logged + case guestresource.ResourceTypeCWCOWCombinedLayers: settings := modifyGuestSettingsRequest.Settings.(*guestresource.CWCOWCombinedLayers) switch modifyGuestSettingsRequest.RequestType { case guestrequest.RequestTypeAdd: @@ -1027,7 +1029,6 @@ func (b *Bridge) modifySettings(req *request) (err error) { } } - //Since unencrypted scratch is not an option, always pass true TODO: fix. if err := b.hostState.securityOptions.PolicyEnforcer.EnforceScratchMountPolicy(ctx, settings.CombinedLayers.ContainerRootPath, true); err != nil { return fmt.Errorf("scratch mounting denied by policy: %w", err) } diff --git a/pkg/securitypolicy/framework.rego b/pkg/securitypolicy/framework.rego index 4e47e37c9f..fe7ccc477d 100644 --- a/pkg/securitypolicy/framework.rego +++ b/pkg/securitypolicy/framework.rego @@ -791,7 +791,7 @@ mountList_ok(mounts, allow_elevated) { } } mountList_ok(mounts, allow_elevated) { - # no-op for windows + # no-op for windows. TODO: Check if it's true. `mounts` made a dir inside a conainer. is_windows } From bbe0c649671993edb4a2714c2f6d8dc939ef5d22 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Mon, 29 Jun 2026 14:58:54 +0100 Subject: [PATCH 18/56] Enforce container's init exec using the result of create_container policy Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 55 ++++++- internal/gcs-sidecar/handlers_test.go | 199 ++++++++++++++++++++++++++ 2 files changed, 251 insertions(+), 3 deletions(-) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index 1bb043f248..34c34a4f7b 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -28,6 +28,7 @@ import ( "github.com/Microsoft/hcsshim/pkg/cimfs" "github.com/Microsoft/hcsshim/pkg/securitypolicy" "github.com/pkg/errors" + "golang.org/x/sys/windows" ) const ( @@ -122,8 +123,6 @@ func (b *Bridge) createContainer(req *request) (err error) { // We enforce `spec`, which is not passed to inbox gcs within this createContainer. // The result of enforcement is stored in memory and used for executeProcess. - // - // TODO: Implement the above logic in executeProcess. user := securitypolicy.IDName{ Name: spec.Process.User.Username, } @@ -255,6 +254,19 @@ func ociEnvToProcessParamEnv(envs []string) map[string]string { return paramEnv } +// escapeArgs builds a Windows-style escaped command line from a set of OCI +// process args. This mirrors how the host shim constructs the init process' +// ProcessParameters.CommandLine (internal/cmd.escapeArgs), so the sidecar can +// reconstruct the expected command line from the enforced spec and compare it +// against what the host actually sends in executeProcess. +func escapeArgs(args []string) string { + escaped := make([]string, len(args)) + for i, a := range args { + escaped[i] = windows.EscapeArg(a) + } + return strings.Join(escaped, " ") +} + // rewriteExecRequest re-marshals an execute process request with updated // ProcessParameters (e.g., after env filtering by policy). func rewriteExecRequest(req *request, r prot.ContainerExecuteProcess, params hcsschema.ProcessParameters) (*request, error) { @@ -374,7 +386,7 @@ func (b *Bridge) executeProcess(req *request) (err error) { if isCreateExec { // if this is an exec of Container command line, then it's already enforced // during container creation. - // TODO: Use the result of enforcement from container creation to + // We use the result of enforcement from container creation to // validate the exec command line and drop environment variable if necessary. c.commandLineExec = true @@ -406,6 +418,43 @@ func (b *Bridge) executeProcess(req *request) (err error) { return fmt.Errorf("failed to rewrite exec request with filtered env: %w", err) } } + } else { + // This is the container's init process. Its command line, working + // directory, user and environment were already validated against + // policy in createContainer, and the result is stored in c.spec. + // The host fully controls this executeProcess request though, so we + // cross-check it against the enforced spec instead of trusting it: + // otherwise a host could pass policy with a benign spec at create + // time and then launch a different init command (e.g. + // "cmd.exe /c ") or smuggle back environment variables that + // create-time enforcement dropped. + if c.spec.Process == nil { + return errors.New("exec in container denied due to policy: enforced spec has no process") + } + enforced := c.spec.Process + + expectedCmdLine := enforced.CommandLine + if expectedCmdLine == "" { + expectedCmdLine = escapeArgs(enforced.Args) + } + if processParams.CommandLine != expectedCmdLine { + return fmt.Errorf("exec in container denied due to policy: init command line %q does not match enforced %q", processParams.CommandLine, expectedCmdLine) + } + if enforced.Cwd != "" && processParams.WorkingDirectory != enforced.Cwd { + return fmt.Errorf("exec in container denied due to policy: init working directory %q does not match enforced %q", processParams.WorkingDirectory, enforced.Cwd) + } + if enforced.User.Username != "" && processParams.User != enforced.User.Username { + return fmt.Errorf("exec in container denied due to policy: init user %q does not match enforced %q", processParams.User, enforced.User.Username) + } + + // Re-apply the environment that createContainer enforcement + // produced (dropped variables removed, nothing injected) so the + // init process runs with exactly the enforced environment. + processParams.Environment = ociEnvToProcessParamEnv(enforced.Env) + req, err = rewriteExecRequest(req, r, processParams) + if err != nil { + return fmt.Errorf("failed to rewrite init exec request with enforced env: %w", err) + } } headerID := req.header.ID diff --git a/internal/gcs-sidecar/handlers_test.go b/internal/gcs-sidecar/handlers_test.go index b389d3e811..7b6e1b51ba 100644 --- a/internal/gcs-sidecar/handlers_test.go +++ b/internal/gcs-sidecar/handlers_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "io" "reflect" + "strings" "testing" "time" @@ -17,6 +18,7 @@ import ( "github.com/Microsoft/hcsshim/internal/protocol/guestrequest" "github.com/Microsoft/hcsshim/internal/protocol/guestresource" "github.com/Microsoft/hcsshim/pkg/securitypolicy" + oci "github.com/opencontainers/runtime-spec/specs-go" ) // buildModifySettingsRequest creates a serialized ModifySettings request message @@ -502,3 +504,200 @@ func TestExecuteProcess_External_AppliesFilteredEnv(t *testing.T) { t.Errorf("forwarded Environment = %v, want %v", gotParams.Environment, want) } } + +// addInitContainer registers a container in the "init process not yet exec'd" +// state (commandLine=true, commandLineExec=false) with the given enforced +// process spec, so executeProcess takes the create-exec cross-check branch. +func addInitContainer(t *testing.T, b *Bridge, id string, proc *oci.Process) { + t.Helper() + c := &Container{ + id: id, + spec: oci.Spec{Process: proc}, + processes: make(map[uint32]*containerProcess), + commandLine: true, + commandLineExec: false, + } + if err := b.hostState.AddContainer(context.Background(), id, c); err != nil { + t.Fatalf("AddContainer: %v", err) + } +} + +// buildExecRequest serializes an executeProcess request for the given container +// and process parameters. +func buildExecRequest(t *testing.T, containerID string, params hcsschema.ProcessParameters) *request { + t.Helper() + r := prot.ContainerExecuteProcess{ + RequestBase: prot.RequestBase{ + ContainerID: containerID, + ActivityID: guid.GUID{}, + }, + Settings: prot.ExecuteProcessSettings{ + ProcessParameters: prot.AnyInString{Value: ¶ms}, + }, + } + msg, err := json.Marshal(&r) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + return &request{ + ctx: context.Background(), + header: messageHeader{ + Type: prot.MsgTypeRequest | prot.MsgType(prot.RPCExecuteProcess), + Size: uint32(len(msg)) + prot.HdrSize, + ID: 7, + }, + message: msg, + } +} + +// unwrapExecParams pulls the inner ProcessParameters back out of a forwarded +// executeProcess request message. +func unwrapExecParams(t *testing.T, message []byte) hcsschema.ProcessParameters { + t.Helper() + var outer prot.ContainerExecuteProcess + var paramsRaw json.RawMessage + outer.Settings.ProcessParameters.Value = ¶msRaw + if err := json.Unmarshal(message, &outer); err != nil { + t.Fatalf("unmarshal forwarded outer: %v", err) + } + var params hcsschema.ProcessParameters + if err := json.Unmarshal(paramsRaw, ¶ms); err != nil { + t.Fatalf("unmarshal forwarded ProcessParameters: %v", err) + } + return params +} + +func assertNothingForwarded(t *testing.T, b *Bridge) { + t.Helper() + select { + case got := <-b.sendToGCSCh: + t.Fatalf("unexpected request forwarded to GCS: %+v", got) + default: + } +} + +// enforcedInitProcess is the process spec used by the create-exec tests: the +// command line, working directory, user and environment that createContainer +// enforcement would have produced. +func enforcedInitProcess() *oci.Process { + return &oci.Process{ + Args: []string{"python", "hello.py"}, + Cwd: `C:\app`, + User: oci.User{Username: "ContainerUser"}, + Env: []string{"APP_FOO=BAR"}, + } +} + +// TestExecuteProcess_InitExec_DeniesCommandLineMismatch verifies that an init +// exec whose command line does not match the enforced spec (the +// "cmd.exe /c " tamper) is denied before anything is forwarded to GCS. +func TestExecuteProcess_InitExec_DeniesCommandLineMismatch(t *testing.T) { + b := newTestBridge(&securitypolicy.OpenDoorSecurityPolicyEnforcer{}) + const cid = "container-1" + addInitContainer(t, b, cid, enforcedInitProcess()) + + req := buildExecRequest(t, cid, hcsschema.ProcessParameters{ + CommandLine: "cmd.exe /c whoami", + WorkingDirectory: `C:\app`, + User: "ContainerUser", + }) + + err := b.executeProcess(req) + if err == nil || !strings.Contains(err.Error(), "command line") { + t.Fatalf("expected command-line denial, got %v", err) + } + assertNothingForwarded(t, b) +} + +// TestExecuteProcess_InitExec_DeniesWorkingDirMismatch verifies a tampered +// working directory is denied. +func TestExecuteProcess_InitExec_DeniesWorkingDirMismatch(t *testing.T) { + b := newTestBridge(&securitypolicy.OpenDoorSecurityPolicyEnforcer{}) + const cid = "container-1" + addInitContainer(t, b, cid, enforcedInitProcess()) + + req := buildExecRequest(t, cid, hcsschema.ProcessParameters{ + CommandLine: "python hello.py", + WorkingDirectory: `C:\Windows`, + User: "ContainerUser", + }) + + err := b.executeProcess(req) + if err == nil || !strings.Contains(err.Error(), "working directory") { + t.Fatalf("expected working-directory denial, got %v", err) + } + assertNothingForwarded(t, b) +} + +// TestExecuteProcess_InitExec_DeniesUserMismatch verifies a tampered user is +// denied. +func TestExecuteProcess_InitExec_DeniesUserMismatch(t *testing.T) { + b := newTestBridge(&securitypolicy.OpenDoorSecurityPolicyEnforcer{}) + const cid = "container-1" + addInitContainer(t, b, cid, enforcedInitProcess()) + + req := buildExecRequest(t, cid, hcsschema.ProcessParameters{ + CommandLine: "python hello.py", + WorkingDirectory: `C:\app`, + User: "ContainerAdministrator", + }) + + err := b.executeProcess(req) + if err == nil || !strings.Contains(err.Error(), "user") { + t.Fatalf("expected user denial, got %v", err) + } + assertNothingForwarded(t, b) +} + +// TestExecuteProcess_InitExec_AllowsAndAppliesEnv verifies that an init exec +// matching the enforced command line/cwd/user is allowed, and that the +// environment forwarded to GCS is reduced to exactly the enforced set (extra +// host-supplied variables are dropped). +func TestExecuteProcess_InitExec_AllowsAndAppliesEnv(t *testing.T) { + b := newTestBridge(&securitypolicy.OpenDoorSecurityPolicyEnforcer{}) + const cid = "container-1" + addInitContainer(t, b, cid, enforcedInitProcess()) + + req := buildExecRequest(t, cid, hcsschema.ProcessParameters{ + CommandLine: "python hello.py", + WorkingDirectory: `C:\app`, + User: "ContainerUser", + Environment: map[string]string{ + "APP_FOO": "BAR", + "DROP": "secret", + }, + }) + + // The container path forwards to GCS and then blocks waiting for the exec + // response keyed by header ID, so run the handler in a goroutine and feed + // it a response once we've captured the forwarded request. + done := make(chan error, 1) + go func() { done <- b.executeProcess(req) }() + + var got request + select { + case got = <-b.sendToGCSCh: + case <-time.After(time.Second): + t.Fatal("nothing forwarded to GCS") + } + + // Stand in for GCS: deliver an exec response on the channel the handler + // registered under this request's header ID, which unblocks its select. + b.pendingMu.Lock() + ch := b.pending[got.header.ID] + b.pendingMu.Unlock() + if ch == nil { + t.Fatal("no pending response channel registered for forwarded request") + } + ch <- &prot.ContainerExecuteProcessResponse{ProcessID: 42} + + if err := <-done; err != nil { + t.Fatalf("executeProcess: %v", err) + } + + gotParams := unwrapExecParams(t, got.message) + want := map[string]string{"APP_FOO": "BAR"} + if !reflect.DeepEqual(gotParams.Environment, want) { + t.Errorf("forwarded Environment = %v, want %v", gotParams.Environment, want) + } +} From 59303053255df527a826fa5bb9c75ea5f79e3a82 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Mon, 29 Jun 2026 15:11:41 +0100 Subject: [PATCH 19/56] Remove an unnecessary TODO Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index 34c34a4f7b..ac254482d2 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -381,7 +381,7 @@ func (b *Bridge) executeProcess(req *request) (err error) { return fmt.Errorf("failed to get created container: %w", err) } - c.processesMutex.Lock() // TODO: maybe move to the top of the block? + c.processesMutex.Lock() isCreateExec := c.commandLine && !c.commandLineExec if isCreateExec { // if this is an exec of Container command line, then it's already enforced From 8da75d3a2f3b9ed2c0ff5bf54927d865739cb2a8 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Mon, 29 Jun 2026 15:34:59 +0100 Subject: [PATCH 20/56] Remove the support of HostedSystem Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index ac254482d2..ff5db3571d 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -60,7 +60,6 @@ func (b *Bridge) createContainer(req *request) (err error) { // containerConfig can be of type uvnConfig or hcsschema.HostedSystem or guestresource.CWCOWHostedSystem var ( uvmConfig prot.UvmConfig - hostedSystemConfig hcsschema.HostedSystem cwcowHostedSystemConfig guestresource.CWCOWHostedSystem ) if err = commonutils.UnmarshalJSONWithHresult(containerConfig, &uvmConfig); err == nil && @@ -68,11 +67,6 @@ func (b *Bridge) createContainer(req *request) (err error) { systemType := uvmConfig.SystemType timeZoneInformation := uvmConfig.TimeZoneInformation log.G(ctx).Tracef("createContainer: uvmConfig: {systemType: %v, timeZoneInformation: %v}}", systemType, timeZoneInformation) - } else if err = commonutils.UnmarshalJSONWithHresult(containerConfig, &hostedSystemConfig); err == nil && - hostedSystemConfig.SchemaVersion != nil && hostedSystemConfig.Container != nil { - schemaVersion := hostedSystemConfig.SchemaVersion - container := hostedSystemConfig.Container - log.G(ctx).Tracef("rpcCreate: HostedSystemConfig: {schemaVersion: %v, container: %v}}", schemaVersion, container) } else if err = commonutils.UnmarshalJSONWithHresult(containerConfig, &cwcowHostedSystemConfig); err == nil && cwcowHostedSystemConfig.Spec.Version != "" && cwcowHostedSystemConfig.CWCOWHostedSystem.Container != nil { cwcowHostedSystem := cwcowHostedSystemConfig.CWCOWHostedSystem From 9c42c069156481a7c6f3ce27e373dfaf359d7b0a Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Mon, 29 Jun 2026 15:35:20 +0100 Subject: [PATCH 21/56] Fix printing Container Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index ff5db3571d..3657928ff4 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -74,7 +74,8 @@ func (b *Bridge) createContainer(req *request) (err error) { container := cwcowHostedSystem.Container spec := cwcowHostedSystemConfig.Spec containerID := createContainerRequest.ContainerID - log.G(ctx).Tracef("rpcCreate: CWCOWHostedSystemConfig {spec: %v, schemaVersion: %v, container: %v}}", string(req.message), schemaVersion, container) + containerJSON, _ := json.Marshal(container) + log.G(ctx).Tracef("rpcCreate: CWCOWHostedSystemConfig {spec: %v, schemaVersion: %v, container: %s}}", string(req.message), schemaVersion, containerJSON) // Enforce registry changes policy if container != nil && container.RegistryChanges != nil { From 96a404f9b48eb26bb60b42cf6f99c2fc37997fd2 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Mon, 29 Jun 2026 16:21:25 +0100 Subject: [PATCH 22/56] Add log for container object as indented json Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index 3657928ff4..76080d8b93 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -76,6 +76,8 @@ func (b *Bridge) createContainer(req *request) (err error) { containerID := createContainerRequest.ContainerID containerJSON, _ := json.Marshal(container) log.G(ctx).Tracef("rpcCreate: CWCOWHostedSystemConfig {spec: %v, schemaVersion: %v, container: %s}}", string(req.message), schemaVersion, containerJSON) + containerJSONIndented, _ := json.MarshalIndent(container, "", " ") + log.G(ctx).Tracef("rpcCreate: container:\n%s", containerJSONIndented) // Enforce registry changes policy if container != nil && container.RegistryChanges != nil { From dfd925fa721f16712325de6e9136eff4c88f7ff6 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Mon, 29 Jun 2026 16:32:50 +0100 Subject: [PATCH 23/56] Revert "Add log for container object as indented json" This reverts commit 96a404f9b48eb26bb60b42cf6f99c2fc37997fd2. --- internal/gcs-sidecar/handlers.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index 76080d8b93..3657928ff4 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -76,8 +76,6 @@ func (b *Bridge) createContainer(req *request) (err error) { containerID := createContainerRequest.ContainerID containerJSON, _ := json.Marshal(container) log.G(ctx).Tracef("rpcCreate: CWCOWHostedSystemConfig {spec: %v, schemaVersion: %v, container: %s}}", string(req.message), schemaVersion, containerJSON) - containerJSONIndented, _ := json.MarshalIndent(container, "", " ") - log.G(ctx).Tracef("rpcCreate: container:\n%s", containerJSONIndented) // Enforce registry changes policy if container != nil && container.RegistryChanges != nil { From ea2e45af3f993f2ebf9d0369fb7fafdf1969dc52 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Tue, 30 Jun 2026 15:21:40 +0100 Subject: [PATCH 24/56] Add comment on startContainer Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index 3657928ff4..a879cb3cf6 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -286,7 +286,8 @@ func (b *Bridge) startContainer(req *request) (err error) { defer span.End() defer func() { oc.SetSpanStatus(span, err) }() - // TODO: do we need enforcement? + // We don't need any enforcement here because the container has already been created and + // this request is just to start the container. var r prot.RequestBase if err := commonutils.UnmarshalJSONWithHresult(req.message, &r); err != nil { From aa8fe5486d5900bac982eb1274fef4200c431be3 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Tue, 30 Jun 2026 15:22:44 +0100 Subject: [PATCH 25/56] Temporary comments Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 134 ++++++++++++++++++++++++++++--- 1 file changed, 121 insertions(+), 13 deletions(-) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index a879cb3cf6..acef0797ec 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -160,19 +160,127 @@ func (b *Bridge) createContainer(req *request) (err error) { // TODO!! enforce over various fields in HostedSystem. /* - type Container struct { - GuestOs *GuestOs `json:"GuestOs,omitempty"` - ->? Storage *Storage `json:"Storage,omitempty"` - -> MappedDirectories []MappedDirectory `json:"MappedDirectories,omitempty"` - ->? MappedPipes []MappedPipe `json:"MappedPipes,omitempty"` - Memory *Memory `json:"Memory,omitempty"` # We can't do anything about this. Host can do denial of service attack anyway. - ? Processor *Processor `json:"Processor,omitempty"` - Networking *Networking `json:"Networking,omitempty"` - HvSocket *HvSocket `json:"HvSocket,omitempty"` - ContainerCredentialGuard *ContainerCredentialGuardState `json:"ContainerCredentialGuard,omitempty"` - -> RegistryChanges *RegistryChanges `json:"RegistryChanges,omitempty"` - ->? AssignedDevices []Device `json:"AssignedDevices,omitempty"` - ->? AdditionalDeviceNamespace *ContainerDefinitionDevice `json:"AdditionalDeviceNamespace,omitempty"` + type Container struct { + GuestOs *GuestOs `json:"GuestOs,omitempty"` + ->? Storage *Storage `json:"Storage,omitempty"` # Looks like it's scratch. + -> MappedDirectories []MappedDirectory `json:"MappedDirectories,omitempty"` # Used with `mounts` + ->? MappedPipes []MappedPipe `json:"MappedPipes,omitempty"` + Memory *Memory `json:"Memory,omitempty"` # We can't do anything about this. Host can do denial of service attack anyway. + ? Processor *Processor `json:"Processor,omitempty"` + Networking *Networking `json:"Networking,omitempty"` + HvSocket *HvSocket `json:"HvSocket,omitempty"` # At the moment host doesn't pass it (createWindowsContainerDocument internal\hcsoci\hcsdoc_wcow.go). We just should reject any value here? + ContainerCredentialGuard *ContainerCredentialGuardState `json:"ContainerCredentialGuard,omitempty"` # TODO: what's credential guard and can we block it for now? + -> RegistryChanges *RegistryChanges `json:"RegistryChanges,omitempty"` -> It's already enforced by EnforceRegistryChangesPolicy() above. + ->? AssignedDevices []Device `json:"AssignedDevices,omitempty"` # Block these for now. See below for the details. + ->? AdditionalDeviceNamespace *ContainerDefinitionDevice `json:"AdditionalDeviceNamespace,omitempty"` # Block these for now. See below for the details. + } + + For hvsocket, UVMHyperVSocketConfigPrefix annotation seem to be available somehow. TODO: check + + AssignedDevices: Looks like it's exposing VPCI device on L1 to uvm. + host-populated from Spec.Windows.Devices + (parseAssignedDevices, internal/hcsoci/hcsdoc_wcow.go:513,529), only for v2 + argon/xenon (hcsdoc_wcow.go:508). Each device is first VPCI-assigned into the + UVM via handleAssignedDevicesWindows -> devices.AddDevice -> uvm.AssignDevice + (internal/hcsoci/resources_wcow.go:94, internal/hcsoci/devices.go:134, + internal/devices/assigned_devices.go:45). It seems to require VPCI device instance + on L1. TODO: Try it and see if we need an enforcement point here now. + + AdditionalDeviceNamespace: host-populated from getDeviceExtensions(coi.Spec.Annotations) + (internal/hcsoci/hcsdoc_wcow.go:391,395). It's driven purely by the annotation + "io.microsoft.container.wcow.deviceextensions" (pkg/annotations/annotations.go:223). + TODO: What's device extension? Do we need to support it for the first release or + can we just reject for now? + */ + + /* + Test container.json: + + { + "metadata": { + "name": "wcow-test" + }, + "image": { + "image": "takurosatodevacr.azurecr.io/payload-demo:250929" + }, + "command": [ + "python", + "hello.py" + ], + "envs": [ + { + "key": "APP_FOO", + "value": "BAR" + } + ], + "mounts": [ + { + "host_path": "C:\\share-ro", + "container_path": "C:\\mnt\\ro", + "readonly": true + }, + { + "host_path": "\\\\.\\pipe\\hostedsystem-demo", + "container_path": "\\\\.\\pipe\\hostedsystem-demo" + } + ], + "windows": { + "security_context": { + "credential_spec": "{\"CmsPlugins\":[\"ActiveDirectory\"],\"DomainJoinConfig\":{\"Sid\":\"S-1-5-21-1111111111-2222222222-3333333333\",\"MachineAccountName\":\"WebApp01\",\"Guid\":\"244818ae-87ac-4fcd-92ec-e79e5252348a\",\"DnsTreeName\":\"contoso.com\",\"DnsName\":\"contoso.com\",\"NetBiosName\":\"CONTOSO\"},\"ActiveDirectoryConfig\":{\"GroupManagedServiceAccounts\":[{\"Name\":\"WebApp01\",\"Scope\":\"contoso.com\"},{\"Name\":\"WebApp01\",\"Scope\":\"CONTOSO\"}]}}" + }, + "resources": { + "rootfs_size_in_bytes": 42949672960 + } + } + } + + HostedSystem.Container: + { + "Storage": { + "Layers": [ + { + "Id": "6e2349b7-8215-4325-a88a-38a8e1f67e18", + "Path": "\\\\?\\Volume{6e2349b7-8215-4325-a88a-38a8e1f67e18}\\" + } + ], + "Path": "c:\\mounts\\scsi\\m0" + }, + "MappedDirectories": [ + { + "HostPath": "\\\\?\\VMSMB\\VSMB-{dcc079ae-60ba-4d07-847c-3493609c0870}\\s1", + "ContainerPath": "C:\\mnt\\ro", + "ReadOnly": true + } + ], + "MappedPipes": [ + { + "ContainerPipeName": "hostedsystem-demo", + "HostPath": "\\\\?\\VMSMB\\VSMB-{dcc079ae-60ba-4d07-847c-3493609c0870}\\IPC$\\hostedsystem-demo" + } + ], + "Processor": {}, + "Networking": { + "Namespace": "644da769-7f9a-41c7-820b-8ef9e66d747b" + }, + "ContainerCredentialGuard": { + "Cookie": "01000000740069000CEBF50D32C0CF80BE559BE206B4EAF9", + "RpcEndpoint": "91571621-3782-9EC0-3C5C-C0EC10E6E763", + "Transport": "HvSocket", + "CredentialSpec": "{\"CmsPlugins\":[\"ActiveDirectory\"],\"DomainJoinConfig\":{\"Sid\":\"S-1-5-21-1111111111-2222222222-3333333333\",\"MachineAccountName\":\"WebApp01\",\"Guid\":\"244818ae-87ac-4fcd-92ec-e79e5252348a\",\"DnsTreeName\":\"contoso.com\",\"DnsName\":\"contoso.com\",\"NetBiosName\":\"CONTOSO\"},\"ActiveDirectoryConfig\":{\"GroupManagedServiceAccounts\":[{\"Name\":\"WebApp01\",\"Scope\":\"contoso.com\"},{\"Name\":\"WebApp01\",\"Scope\":\"CONTOSO\"}]}}" + }, + "RegistryChanges": { + "AddValues": [ + { + "Key": { + "Hive": "System", + "Name": "ControlSet001\\Control" + }, + "Name": "WaitToKillServiceTimeout", + "Type": "String", + "StringValue": "2147483647" + } + ] + } } */ From 243936bd29e1b30a5b27f0227fa2e8171ce15e28 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Thu, 2 Jul 2026 10:28:49 +0100 Subject: [PATCH 26/56] Add mounts enforcement point Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 115 ++++++++- pkg/securitypolicy/framework.rego | 103 ++++++-- pkg/securitypolicy/rego_utils_test.go | 1 + pkg/securitypolicy/regopolicy_windows_test.go | 229 ++++++++++++++++++ pkg/securitypolicy/securitypolicy.go | 1 + pkg/securitypolicy/securitypolicy_internal.go | 9 + pkg/securitypolicy/securitypolicy_marshal.go | 13 +- .../securitypolicyenforcer_rego.go | 2 +- 8 files changed, 452 insertions(+), 21 deletions(-) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index acef0797ec..94278b927e 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -27,6 +27,7 @@ import ( "github.com/Microsoft/hcsshim/internal/windevice" "github.com/Microsoft/hcsshim/pkg/cimfs" "github.com/Microsoft/hcsshim/pkg/securitypolicy" + oci "github.com/opencontainers/runtime-spec/specs-go" "github.com/pkg/errors" "golang.org/x/sys/windows" ) @@ -284,10 +285,19 @@ func (b *Bridge) createContainer(req *request) (err error) { } */ - // Marshal the original cwcowHostedSystem from the request. - // That's safe because we've done enforcement on `spec` and - // later we will + // Reconcile the host-provided HostedSystem mounts against the enforced + // spec. spec.Mounts has already been validated against policy by + // EnforceCreateContainerPolicyV2 above. Here we make sure the host is + // not forwarding any MappedDirectories or MappedPipes that don't map to + // an enforced spec mount, so the host can't smuggle in a mount the + // policy never saw. + if err := reconcileHostedSystemMounts(spec.Mounts, container); err != nil { + return fmt.Errorf("CreateContainer operation is denied by policy: %w", err) + } + // Marshal the original cwcowHostedSystem from the request. That's safe + // because we've enforced `spec` above and reconciled the forwarded + // MappedDirectories/MappedPipes against it. hostedSystemBytes, err := json.Marshal(cwcowHostedSystem) if err != nil { @@ -329,6 +339,103 @@ func (b *Bridge) createContainer(req *request) (err error) { return nil } +// namedPipePrefix is the prefix used for Windows named pipe paths. A mount +// whose OCI destination starts with this prefix becomes a MappedPipe in the +// HostedSystem, with ContainerPipeName set to the destination minus this +// prefix (see internal/uvm.ParseNamedPipe and internal/hcsoci/hcsdoc_wcow.go). +const namedPipePrefix = `\\.\pipe\` + +// isPipeDestination reports whether an OCI mount destination refers to a named +// pipe (and would therefore become a MappedPipe rather than a MappedDirectory). +func isPipeDestination(dest string) bool { + return strings.HasPrefix(dest, namedPipePrefix) +} + +// pipeNameFromDestination derives the ContainerPipeName that the host sets for +// a pipe mount from its OCI destination, mirroring ParseNamedPipe. +func pipeNameFromDestination(dest string) string { + return strings.TrimPrefix(dest, namedPipePrefix) +} + +// mountReadOnly reports whether an OCI mount's options request a read-only +// mount, mirroring how the host derives MappedDirectory.ReadOnly in +// internal/hcsoci/hcsdoc_wcow.go (an "ro" option, case-insensitive). +func mountReadOnly(options []string) bool { + for _, o := range options { + if strings.EqualFold(o, "ro") { + return true + } + } + return false +} + +// reconcileHostedSystemMounts verifies that every MappedDirectory and +// MappedPipe the host forwards in the HostedSystem corresponds to an enforced +// spec mount. The spec mounts have already been validated against policy, so +// this binds the forwarded HostedSystem to that enforced view and rejects any +// host-added mount the policy never saw. Note that HostPath is intentionally +// not compared: the spec source is a host-side path while the HostedSystem +// HostPath is a VSMB path, so they legitimately differ and the host controls +// both regardless. +func reconcileHostedSystemMounts(mounts []oci.Mount, container *hcsschema.Container) error { + if container == nil { + return nil + } + + // Every MappedDirectory must correspond to a (non-pipe) spec mount that + // targets the same container path with the same read-only flag. + for _, md := range container.MappedDirectories { + matched := false + for _, m := range mounts { + // Pipe mounts are reconciled against MappedPipes below, not here. + if isPipeDestination(m.Destination) { + continue + } + // Bind on container path (spec destination) + read-only. + if m.Destination == md.ContainerPath && mountReadOnly(m.Options) == md.ReadOnly { + matched = true + break + } + } + if !matched { + return fmt.Errorf("mapped directory %q (readOnly=%v) does not match any enforced spec mount", md.ContainerPath, md.ReadOnly) + } + } + + // Every MappedPipe must correspond to a pipe spec mount that yields the same + // pipe name. We match on the pipe name (derived from the spec destination), + // not the source. + // + // NB: for a pipe, the spec mount and the HostedSystem entry hold *different* + // values for the "same" pipe, which is easy to trip over: + // - spec mount source: "\\.\pipe\" (pure name, NO guid) + // - MappedPipe.HostPath: "\\?\VMSMB\VSMB-{guid}\IPC$\" (host VSMB transport, has guid) + // The spec source stays the clean "\\.\pipe\"; only the host-side + // transport path (HostPath) carries the VSMB guid. HostPath is host-controlled + // and not comparable to the spec source, so we don't compare it here; instead + // we bind on the pipe name. The clean spec source is enforced separately by + // policy (windows_mountConstraint_ok in framework.rego). + for _, mp := range container.MappedPipes { + matched := false + for _, m := range mounts { + // Non-pipe mounts are reconciled against MappedDirectories above. + if !isPipeDestination(m.Destination) { + continue + } + // Bind on the pipe name (destination minus the \\.\pipe\ prefix). + if pipeNameFromDestination(m.Destination) == mp.ContainerPipeName { + matched = true + break + } + } + if !matched { + return fmt.Errorf("mapped pipe %q does not match any enforced spec mount", mp.ContainerPipeName) + } + } + + return nil +} + // processParamEnvToOCIEnv converts an Environment field from ProcessParameters // (a map from environment variable to value) into an array of environment // variable assignments (where each is in the form "=") which @@ -909,7 +1016,7 @@ func (b *Bridge) modifySettings(req *request) (err error) { // If host doesn't use it maybe remove it TODO case guestresource.ResourceTypeMappedDirectory: - // WE don't have hostpath enforcement because anyway contents of the dir can be changed by the host. + // We don't have hostpath enforcement because anyway contents of the dir can be changed by the host. settings := modifyGuestSettingsRequest.Settings.(*hcsschema.MappedDirectory) log.G(ctx).Tracef("hcsschema.MappedDirectory { %v }", settings) switch modifyGuestSettingsRequest.RequestType { diff --git a/pkg/securitypolicy/framework.rego b/pkg/securitypolicy/framework.rego index fe7ccc477d..eff9b4c6eb 100644 --- a/pkg/securitypolicy/framework.rego +++ b/pkg/securitypolicy/framework.rego @@ -669,6 +669,7 @@ create_container := {"metadata": [updateMatches, addStarted], user_ok(container.user) workingDirectory_ok(container.working_dir) command_ok(container.command) + mountList_ok(container.mounts, false) ] count(possible_after_initial_containers) > 0 @@ -747,15 +748,11 @@ mountSource_ok(constraint, source) { constraint == source } -mountConstraint_ok(constraint, mount) { - mount.type == constraint.type - mountSource_ok(constraint.source, mount.source) - mount.destination != "" - mount.destination == constraint.destination - - # the following check is not required (as the following tests will prove this - # condition as well), however it will check whether those more expensive - # tests need to be performed. +# mountOptions_ok holds when a mount's options are exactly the constraint's +# option set: every requested option is allowed and every allowed option is +# present (no missing, no extras). The count check is a cheap pre-filter for the +# two set-containment checks that follow. +mountOptions_ok(constraint, mount) { count(mount.options) == count(constraint.options) every option in mount.options { some constraintOption in constraint.options @@ -768,6 +765,69 @@ mountConstraint_ok(constraint, mount) { } } +# is_named_pipe reports whether an OCI mount destination refers to a Windows +# named pipe. This matches how the host decides to turn a mount into a +# MappedPipe rather than a MappedDirectory (see internal/gcs-sidecar handlers' +# isPipeDestination and internal/hcsoci/hcsdoc_wcow.go). +is_named_pipe(path) { + startswith(path, `\\.\pipe\`) +} + +# windows_mount_type_ok gates which OCI mount `type` values are acceptable on +# Windows. We only handle "plain" mounts - mapped directories and named pipes - +# which carry an empty type or an explicit +# "bind". Disk/device mount types (virtual-disk / physical-disk / +# extensible-virtual-disk) are not supported and rejected. +windows_mount_type_ok(mount) { + mount.type == "" +} + +windows_mount_type_ok(mount) { + mount.type == "bind" +} + +mountConstraint_ok(constraint, mount) { + is_linux + mount.type == constraint.type + mountSource_ok(constraint.source, mount.source) + mount.destination != "" + mount.destination == constraint.destination + mountOptions_ok(constraint, mount) +} + +# Windows named pipe: the source is a stable "\\.\pipe\" path that the +# policy author can predict, so we require it to match the constraint exactly. +# This stops the host from wiring a container's expected pipe destination up to a +# different host pipe. We don't match mount.type against a policy value (it's +# empty/"bind" for real mounts), but we do reject non-plain types via +# windows_mount_type_ok so a disk/device mount can't pass as a pipe. +mountConstraint_ok(constraint, mount) { + is_windows + windows_mount_type_ok(mount) + is_named_pipe(mount.destination) + mount.destination != "" + mount.destination == constraint.destination + constraint.source == mount.source + mountOptions_ok(constraint, mount) +} + +# Windows mapped directory (anything that is not a named pipe): by the time the +# request reaches the UVM the host has rewritten the user's host_path (e.g. +# "C:\share-host") into a host-generated volume path such as +# "\\?\Volume{}\share-host". That GUID is picked by the host and +# cannot be predicted by the policy author, so we do not enforce the source and +# rely on the destination + options (mirroring the top-level mapped_directories +# rule, which matches on container_path + read_only). windows_mount_type_ok +# rejects disk/device mount types so they can't pass as a directory. +mountConstraint_ok(constraint, mount) { + is_windows + windows_mount_type_ok(mount) + not is_named_pipe(mount.destination) + mount.destination != "" + mount.destination == constraint.destination + mountOptions_ok(constraint, mount) +} + mount_ok(mounts, allow_elevated, mount) { some constraint in mounts mountConstraint_ok(constraint, mount) @@ -784,16 +844,13 @@ mount_ok(mounts, allow_elevated, mount) { mountConstraint_ok(constraint, mount) } +# mountList_ok is OS-agnostic here: the per-mount OS differences are handled by +# the is_linux/is_windows bodies of mountConstraint_ok. mountList_ok(mounts, allow_elevated) { - is_linux every mount in input.mounts { mount_ok(mounts, allow_elevated, mount) } } -mountList_ok(mounts, allow_elevated) { - # no-op for windows. TODO: Check if it's true. `mounts` made a dir inside a conainer. - is_windows -} is_linux { data.metadata.operatingsystem[ostype] == "linux" @@ -1308,6 +1365,15 @@ mapped_directory_mounted(target) { default mapped_directory_ok := false +# A mapped directory is matched on container_path + read_only only; we do not +# enforce its host-side source. This mirrors the reasoning in +# windows_mountSource_ok for directory (non-pipe) mounts: by the time the +# request reaches the UVM the host has already rewritten the user's host_path +# (e.g. "C:\share-host") into a host-generated volume path such as +# "\\?\Volume{}\share-host". That GUID is picked by the host and +# cannot be predicted by the policy author, so matching on it carries no +# security value. + # allowed by an entry in the base policy mapped_directory_ok { mapped_directory := data.policy.mapped_directories[_] @@ -1738,12 +1804,18 @@ errors["invalid working directory"] { } mount_matches(mount) { + is_linux some container in data.metadata.matches[input.containerID] mount_ok(container.mounts, container.allow_elevated, mount) } +mount_matches(mount) { + is_windows + some container in data.metadata.matches[input.containerID] + mount_ok(container.mounts, false, mount) +} + errors[mountError] { - is_linux input.rule == "create_container" bad_mounts := [mount.destination | mount := input.mounts[_] @@ -1982,6 +2054,7 @@ errors["containers only distinguishable by allow_stdio_access"] { user_ok(container.user) workingDirectory_ok(container.working_dir) command_ok(container.command) + mountList_ok(container.mounts, false) ] count(possible_after_initial_containers) > 0 diff --git a/pkg/securitypolicy/rego_utils_test.go b/pkg/securitypolicy/rego_utils_test.go index aa35dc5d9b..cdd97cf1e3 100644 --- a/pkg/securitypolicy/rego_utils_test.go +++ b/pkg/securitypolicy/rego_utils_test.go @@ -1882,6 +1882,7 @@ func (c *securityPolicyWindowsContainer) toWindowsContainer() *WindowsContainer Layers: Layers(stringArrayToStringMap(c.Layers)), MountedCim: c.MountedCim, WorkingDir: c.WorkingDir, + Mounts: mountArrayToMounts(c.Mounts), ExecProcesses: execProcesses, Signals: c.Signals, User: c.User, diff --git a/pkg/securitypolicy/regopolicy_windows_test.go b/pkg/securitypolicy/regopolicy_windows_test.go index c795a040a6..85f4eae560 100644 --- a/pkg/securitypolicy/regopolicy_windows_test.go +++ b/pkg/securitypolicy/regopolicy_windows_test.go @@ -229,6 +229,235 @@ func Test_Rego_EnforceCreateContainer_Windows(t *testing.T) { } } +func Test_Rego_MountPolicy_Matches_Windows(t *testing.T) { + f := func(p *generatedWindowsConstraints) bool { + c := selectWindowsContainerFromContainerList(p.containers, testRand) + mnt := mountInternal{ + Source: "C:\\host\\share", + Destination: "C:\\container\\share", + Options: []string{"ro"}, + } + c.Mounts = append(c.Mounts, mnt) + + tc, err := setupRegoCreateContainerTestWindows(p, c, false) + if err != nil { + t.Error(err) + return false + } + + requestMounts := []oci.Mount{ + { + Source: mnt.Source, + Destination: mnt.Destination, + Options: mnt.Options, + }, + } + + _, _, _, err = tc.policy.EnforceCreateContainerPolicyV2(p.ctx, tc.containerID, tc.argList, tc.envList, tc.workingDir, requestMounts, tc.user, nil) + if err != nil { + t.Errorf("a mount matching the policy was denied: %v", err) + return false + } + return true + } + + if err := quick.Check(f, &quick.Config{MaxCount: 10, Rand: testRand}); err != nil { + t.Errorf("Test_Rego_MountPolicy_Matches_Windows: %v", err) + } +} + +func Test_Rego_MountPolicy_DiskTypeRejected_Windows(t *testing.T) { + f := func(p *generatedWindowsConstraints) bool { + c := selectWindowsContainerFromContainerList(p.containers, testRand) + mnt := mountInternal{ + Source: "C:\\host\\share", + Destination: "C:\\container\\share", + Options: []string{"ro"}, + } + c.Mounts = append(c.Mounts, mnt) + + tc, err := setupRegoCreateContainerTestWindows(p, c, false) + if err != nil { + t.Error(err) + return false + } + + // Same destination + options the policy allows, but tagged as a disk + // mount type. A disk/device type must be rejected regardless of the + // destination match, so it can't ride in on a directory allowance. + requestMounts := []oci.Mount{ + { + Source: mnt.Source, + Destination: mnt.Destination, + Options: mnt.Options, + Type: "virtual-disk", + }, + } + + _, _, _, err = tc.policy.EnforceCreateContainerPolicyV2(p.ctx, tc.containerID, tc.argList, tc.envList, tc.workingDir, requestMounts, tc.user, nil) + if err == nil { + t.Error("a disk-type mount was allowed by policy") + return false + } + + return assertDecisionJSONContains(t, err, "invalid mount list") + } + + if err := quick.Check(f, &quick.Config{MaxCount: 10, Rand: testRand}); err != nil { + t.Errorf("Test_Rego_MountPolicy_DiskTypeRejected_Windows: %v", err) + } +} + +func Test_Rego_MountPolicy_BindTypeAllowed_Windows(t *testing.T) { + f := func(p *generatedWindowsConstraints) bool { + c := selectWindowsContainerFromContainerList(p.containers, testRand) + mnt := mountInternal{ + Source: "C:\\host\\share", + Destination: "C:\\container\\share", + Options: []string{"ro"}, + } + c.Mounts = append(c.Mounts, mnt) + + tc, err := setupRegoCreateContainerTestWindows(p, c, false) + if err != nil { + t.Error(err) + return false + } + + // An explicit "bind" type is a plain mount and must still be allowed. + requestMounts := []oci.Mount{ + { + Source: mnt.Source, + Destination: mnt.Destination, + Options: mnt.Options, + Type: "bind", + }, + } + + _, _, _, err = tc.policy.EnforceCreateContainerPolicyV2(p.ctx, tc.containerID, tc.argList, tc.envList, tc.workingDir, requestMounts, tc.user, nil) + if err != nil { + t.Errorf("a bind-type mount matching the policy was denied: %v", err) + return false + } + return true + } + + if err := quick.Check(f, &quick.Config{MaxCount: 10, Rand: testRand}); err != nil { + t.Errorf("Test_Rego_MountPolicy_BindTypeAllowed_Windows: %v", err) + } +} + +func Test_Rego_MountPolicy_NoMatches_Windows(t *testing.T) { + f := func(p *generatedWindowsConstraints) bool { + c := selectWindowsContainerFromContainerList(p.containers, testRand) + tc, err := setupRegoCreateContainerTestWindows(p, c, false) + if err != nil { + t.Error(err) + return false + } + + // The container declares no matching mount constraint, so any + // requested mount must be rejected. + requestMounts := []oci.Mount{ + { + Source: "C:\\host\\not-in-policy", + Destination: "C:\\container\\not-in-policy", + Options: []string{"rw"}, + }, + } + + _, _, _, err = tc.policy.EnforceCreateContainerPolicyV2(p.ctx, tc.containerID, tc.argList, tc.envList, tc.workingDir, requestMounts, tc.user, nil) + if err == nil { + t.Error("a mount not present in the policy did not result in an error") + return false + } + + return assertDecisionJSONContains(t, err, "invalid mount list") + } + + if err := quick.Check(f, &quick.Config{MaxCount: 10, Rand: testRand}); err != nil { + t.Errorf("Test_Rego_MountPolicy_NoMatches_Windows: %v", err) + } +} + +func Test_Rego_MountPolicy_Pipe_Matches_Windows(t *testing.T) { + f := func(p *generatedWindowsConstraints) bool { + c := selectWindowsContainerFromContainerList(p.containers, testRand) + pipe := mountInternal{ + Source: "\\\\.\\pipe\\host-pipe", + Destination: "\\\\.\\pipe\\container-pipe", + Options: []string{}, + } + c.Mounts = append(c.Mounts, pipe) + + tc, err := setupRegoCreateContainerTestWindows(p, c, false) + if err != nil { + t.Error(err) + return false + } + + requestMounts := []oci.Mount{ + { + Source: pipe.Source, + Destination: pipe.Destination, + Options: pipe.Options, + }, + } + + _, _, _, err = tc.policy.EnforceCreateContainerPolicyV2(p.ctx, tc.containerID, tc.argList, tc.envList, tc.workingDir, requestMounts, tc.user, nil) + if err != nil { + t.Errorf("a pipe mount matching the policy was denied: %v", err) + return false + } + return true + } + + if err := quick.Check(f, &quick.Config{MaxCount: 10, Rand: testRand}); err != nil { + t.Errorf("Test_Rego_MountPolicy_Pipe_Matches_Windows: %v", err) + } +} + +func Test_Rego_MountPolicy_Pipe_BadSource_Windows(t *testing.T) { + f := func(p *generatedWindowsConstraints) bool { + c := selectWindowsContainerFromContainerList(p.containers, testRand) + pipe := mountInternal{ + Source: "\\\\.\\pipe\\host-pipe", + Destination: "\\\\.\\pipe\\container-pipe", + Options: []string{}, + } + c.Mounts = append(c.Mounts, pipe) + + tc, err := setupRegoCreateContainerTestWindows(p, c, false) + if err != nil { + t.Error(err) + return false + } + + // Same (policy-matching) pipe destination, but a different host pipe + // source. Unlike a mapped directory, a pipe source is enforced, so this + // must be rejected. + requestMounts := []oci.Mount{ + { + Source: "\\\\.\\pipe\\attacker-pipe", + Destination: pipe.Destination, + Options: pipe.Options, + }, + } + + _, _, _, err = tc.policy.EnforceCreateContainerPolicyV2(p.ctx, tc.containerID, tc.argList, tc.envList, tc.workingDir, requestMounts, tc.user, nil) + if err == nil { + t.Error("a pipe mount with a non-matching source did not result in an error") + return false + } + + return assertDecisionJSONContains(t, err, "invalid mount list") + } + + if err := quick.Check(f, &quick.Config{MaxCount: 10, Rand: testRand}); err != nil { + t.Errorf("Test_Rego_MountPolicy_Pipe_BadSource_Windows: %v", err) + } +} + func Test_Rego_EnforceCreateContainer_Start_All_Containers(t *testing.T) { f := func(p *generatedWindowsConstraints) bool { securityPolicy := p.toPolicy() diff --git a/pkg/securitypolicy/securitypolicy.go b/pkg/securitypolicy/securitypolicy.go index 1360964bcf..8604c7bc60 100644 --- a/pkg/securitypolicy/securitypolicy.go +++ b/pkg/securitypolicy/securitypolicy.go @@ -315,6 +315,7 @@ type WindowsContainer struct { Layers Layers `json:"layers"` MountedCim []string `json:"mounted_cim"` WorkingDir string `json:"working_dir"` + Mounts Mounts `json:"mounts"` ExecProcesses []WindowsExecProcessConfig `json:"-"` Signals []guestrequest.SignalValueWCOW `json:"-"` AllowStdioAccess bool `json:"-"` diff --git a/pkg/securitypolicy/securitypolicy_internal.go b/pkg/securitypolicy/securitypolicy_internal.go index 5d4740902d..ae160adae4 100644 --- a/pkg/securitypolicy/securitypolicy_internal.go +++ b/pkg/securitypolicy/securitypolicy_internal.go @@ -190,6 +190,9 @@ type securityPolicyWindowsContainer struct { // WorkingDir is a path to container's working directory, which all the processes // will default to. WorkingDir string `json:"working_dir"` + // The set of mount constraints that the container is allowed to be created + // with. Matched against the OCI spec mounts at container creation time. + Mounts []mountInternal `json:"mounts"` // A list of lists of commands that can be used to execute additional // processes within the container ExecProcesses []windowsContainerExecProcess `json:"exec_processes"` @@ -317,11 +320,17 @@ func (c *WindowsContainer) toInternal() (*securityPolicyWindowsContainer, error) execProcesses[i] = windowsContainerExecProcess(ep) } + mounts, err := c.Mounts.toInternal() + if err != nil { + return nil, err + } + return &securityPolicyWindowsContainer{ Command: command, EnvRules: envRules, Layers: layers, WorkingDir: c.WorkingDir, + Mounts: mounts, ExecProcesses: execProcesses, Signals: c.Signals, AllowStdioAccess: c.AllowStdioAccess, diff --git a/pkg/securitypolicy/securitypolicy_marshal.go b/pkg/securitypolicy/securitypolicy_marshal.go index 4a250c5d95..5494f1e249 100644 --- a/pkg/securitypolicy/securitypolicy_marshal.go +++ b/pkg/securitypolicy/securitypolicy_marshal.go @@ -412,7 +412,17 @@ func writeCapabilities(builder *strings.Builder, capabilities *capabilitiesInter func (m mountInternal) marshalRego() string { options := stringArray(m.Options).marshalRego() - return fmt.Sprintf(`{"destination": "%s", "options": %s, "source": "%s", "type": "%s"}`, m.Destination, options, m.Source, m.Type) + return fmt.Sprintf(`{"destination": "%s", "options": %s, "source": "%s", "type": "%s"}`, + escapeRegoString(m.Destination), options, escapeRegoString(m.Source), escapeRegoString(m.Type)) +} + +// escapeRegoString escapes a Go string so it is a valid double-quoted Rego +// string literal. This matters for Windows mount paths, which contain +// backslashes that would otherwise be interpreted as escape sequences. +func escapeRegoString(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `"`, `\"`) + return s } func writeMounts(builder *strings.Builder, mounts []mountInternal, indent string) { @@ -463,6 +473,7 @@ func writeWindowsContainer(builder *strings.Builder, container *securityPolicyWi writeEnvRules(builder, container.EnvRules, indent+indentUsing) writeLayers(builder, container.Layers, indent+indentUsing) writeMountedCim(builder, container.MountedCim, indent+indentUsing) + writeMounts(builder, container.Mounts, indent+indentUsing) writeWindowsExecProcesses(builder, container.ExecProcesses, indent+indentUsing) writeWindowsSignals(builder, container.Signals, indent+indentUsing) writeWindowsUser(builder, container.User, indent+indentUsing) diff --git a/pkg/securitypolicy/securitypolicyenforcer_rego.go b/pkg/securitypolicy/securitypolicyenforcer_rego.go index 0d2f34c4c4..ad2616dab3 100644 --- a/pkg/securitypolicy/securitypolicyenforcer_rego.go +++ b/pkg/securitypolicy/securitypolicyenforcer_rego.go @@ -763,9 +763,9 @@ func (policy *regoEnforcer) EnforceCreateContainerPolicyV2( log.G(ctx).WithError(err).Warn("failed to obtain policy metadata snapshot") } - // TODO: check if `mounts` is missing. // TODO: we should handle registry here? for narrowing input = inputData{ + "mounts": appendMountData([]interface{}{}, mounts), "containerID": containerID, "argList": argList, "envList": envList, From 9a9e198858dd1d3265b1183db8f630052a77de77 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Thu, 2 Jul 2026 15:04:20 +0100 Subject: [PATCH 27/56] Apply result of allowStdio policy Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 58 +++++- internal/gcs-sidecar/handlers_test.go | 266 ++++++++++++++++++++++++++ internal/gcs-sidecar/host.go | 2 + 3 files changed, 320 insertions(+), 6 deletions(-) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index 94278b927e..bf65b72568 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -131,7 +131,6 @@ func (b *Bridge) createContainer(req *request) (err error) { if envToKeep != nil { spec.Process.Env = []string(envToKeep) } - _ = allowStdio // TODO: enforce stdio access for Windows containers commandLine := len(spec.Process.Args) > 0 c := &Container{ @@ -140,6 +139,7 @@ func (b *Bridge) createContainer(req *request) (err error) { processes: make(map[uint32]*containerProcess), commandLine: commandLine, commandLineExec: false, + allowStdio: allowStdio, } log.G(ctx).Tracef("Adding ContainerID: %v", containerID) @@ -496,6 +496,29 @@ func rewriteExecRequest(req *request, r prot.ContainerExecuteProcess, params hcs return newReq, nil } +// enforceStdioParams applies a stdio-access policy decision. When denied, a +// process that requires a console is rejected (there is no console without +// stdio); otherwise the stdio pipe flags are cleared. Returns whether params +// changed so callers can skip an unnecessary rewrite. +func enforceStdioParams(allowStdio bool, params *hcsschema.ProcessParameters) (bool, error) { + if allowStdio { + return false, nil + } + + // A console can't be honored without stdio, so reject rather than silently + // dropping EmulateConsole and running a non-interactive process the caller + // didn't ask for. + if params.EmulateConsole { + return false, errors.New("process that requires console access denied due to policy not allowing stdio access") + } + + changed := params.CreateStdInPipe || params.CreateStdOutPipe || params.CreateStdErrPipe + params.CreateStdInPipe = false + params.CreateStdOutPipe = false + params.CreateStdErrPipe = false + return changed, nil +} + func (b *Bridge) startContainer(req *request) (err error) { _, span := oc.StartSpan(req.ctx, "sidecar::startContainer") defer span.End() @@ -567,7 +590,7 @@ func (b *Bridge) executeProcess(req *request) (err error) { if containerID == UVMContainerID { log.G(req.ctx).Tracef("Enforcing policy on external exec process") - envToKeep, _, err := b.hostState.securityOptions.PolicyEnforcer.EnforceExecExternalProcessPolicy( + envToKeep, stdioAllowed, err := b.hostState.securityOptions.PolicyEnforcer.EnforceExecExternalProcessPolicy( req.ctx, commandLine, processParamEnvToOCIEnv(processParams.Environment), @@ -576,11 +599,20 @@ func (b *Bridge) executeProcess(req *request) (err error) { if err != nil { return errors.Wrapf(err, "exec is denied due to policy") } + needsRewrite := false if envToKeep != nil { processParams.Environment = ociEnvToProcessParamEnv(envToKeep) + needsRewrite = true + } + stdioChanged, err := enforceStdioParams(stdioAllowed, &processParams) + if err != nil { + return errors.Wrapf(err, "exec is denied due to policy") + } + needsRewrite = needsRewrite || stdioChanged + if needsRewrite { req, err = rewriteExecRequest(req, r, processParams) if err != nil { - return fmt.Errorf("failed to rewrite exec request with filtered env: %w", err) + return fmt.Errorf("failed to rewrite exec request: %w", err) } } b.forwardRequestToGcs(req) @@ -609,7 +641,7 @@ func (b *Bridge) executeProcess(req *request) (err error) { Name: processParams.User, } log.G(req.ctx).Tracef("Enforcing policy on exec in container") - envToKeep, _, _, err := b.hostState.securityOptions.PolicyEnforcer. + envToKeep, _, stdioAllowed, err := b.hostState.securityOptions.PolicyEnforcer. EnforceExecInContainerPolicyV2( req.ctx, containerID, @@ -622,11 +654,20 @@ func (b *Bridge) executeProcess(req *request) (err error) { if err != nil { return errors.Wrapf(err, "exec in container denied due to policy") } + needsRewrite := false if envToKeep != nil { processParams.Environment = ociEnvToProcessParamEnv(envToKeep) + needsRewrite = true + } + stdioChanged, err := enforceStdioParams(stdioAllowed, &processParams) + if err != nil { + return errors.Wrapf(err, "exec in container denied due to policy") + } + needsRewrite = needsRewrite || stdioChanged + if needsRewrite { req, err = rewriteExecRequest(req, r, processParams) if err != nil { - return fmt.Errorf("failed to rewrite exec request with filtered env: %w", err) + return fmt.Errorf("failed to rewrite exec request: %w", err) } } } else { @@ -662,9 +703,14 @@ func (b *Bridge) executeProcess(req *request) (err error) { // produced (dropped variables removed, nothing injected) so the // init process runs with exactly the enforced environment. processParams.Environment = ociEnvToProcessParamEnv(enforced.Env) + + if _, err = enforceStdioParams(c.allowStdio, &processParams); err != nil { + return errors.Wrapf(err, "exec in container denied due to policy") + } + req, err = rewriteExecRequest(req, r, processParams) if err != nil { - return fmt.Errorf("failed to rewrite init exec request with enforced env: %w", err) + return fmt.Errorf("failed to rewrite init exec request: %w", err) } } headerID := req.header.ID diff --git a/internal/gcs-sidecar/handlers_test.go b/internal/gcs-sidecar/handlers_test.go index 7b6e1b51ba..3235adb548 100644 --- a/internal/gcs-sidecar/handlers_test.go +++ b/internal/gcs-sidecar/handlers_test.go @@ -701,3 +701,269 @@ func TestExecuteProcess_InitExec_AllowsAndAppliesEnv(t *testing.T) { t.Errorf("forwarded Environment = %v, want %v", gotParams.Environment, want) } } + +// TestEnforceStdioParams covers the stdio-access decision helper: allowed +// leaves params untouched, denied clears the stdio pipe flags, denied with no +// pipes reports no change, and denied for a console process is rejected. +func TestEnforceStdioParams(t *testing.T) { + tests := []struct { + name string + allowStdio bool + params hcsschema.ProcessParameters + wantErr bool + wantChanged bool + wantPipes bool + }{ + { + name: "allowed leaves params untouched", + allowStdio: true, + params: hcsschema.ProcessParameters{CreateStdInPipe: true, CreateStdOutPipe: true, CreateStdErrPipe: true}, + wantChanged: false, + wantPipes: true, + }, + { + name: "denied with console is rejected", + allowStdio: false, + params: hcsschema.ProcessParameters{EmulateConsole: true}, + wantErr: true, + }, + { + name: "denied clears stdio pipes", + allowStdio: false, + params: hcsschema.ProcessParameters{CreateStdInPipe: true, CreateStdOutPipe: true, CreateStdErrPipe: true}, + wantChanged: true, + wantPipes: false, + }, + { + name: "denied with no pipes reports no change", + allowStdio: false, + params: hcsschema.ProcessParameters{}, + wantChanged: false, + wantPipes: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + params := tt.params + changed, err := enforceStdioParams(tt.allowStdio, ¶ms) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if changed != tt.wantChanged { + t.Errorf("changed = %v, want %v", changed, tt.wantChanged) + } + if params.CreateStdInPipe != tt.wantPipes || + params.CreateStdOutPipe != tt.wantPipes || + params.CreateStdErrPipe != tt.wantPipes { + t.Errorf("pipe flags = (%v,%v,%v), want all %v", + params.CreateStdInPipe, params.CreateStdOutPipe, params.CreateStdErrPipe, tt.wantPipes) + } + }) + } +} + +// stdioDenyExternalEnforcer denies stdio access on the external-exec path while +// allowing everything else via the embedded open-door enforcer. +type stdioDenyExternalEnforcer struct { + securitypolicy.OpenDoorSecurityPolicyEnforcer +} + +func (stdioDenyExternalEnforcer) EnforceExecExternalProcessPolicy( + _ context.Context, _ []string, _ []string, _ string, +) (securitypolicy.EnvList, bool, error) { + return nil, false, nil +} + +// TestExecuteProcess_External_DeniedStdioClearsPipes verifies the external-exec +// branch clears the stdio pipe flags before forwarding when policy denies stdio. +func TestExecuteProcess_External_DeniedStdioClearsPipes(t *testing.T) { + b := newTestBridge(&stdioDenyExternalEnforcer{}) + + params := hcsschema.ProcessParameters{ + CommandLine: "cmd.exe /c exit", + CreateStdInPipe: true, + CreateStdOutPipe: true, + CreateStdErrPipe: true, + } + r := prot.ContainerExecuteProcess{ + RequestBase: prot.RequestBase{ContainerID: UVMContainerID, ActivityID: guid.GUID{}}, + Settings: prot.ExecuteProcessSettings{ProcessParameters: prot.AnyInString{Value: ¶ms}}, + } + msg, err := json.Marshal(&r) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + req := &request{ + ctx: context.Background(), + header: messageHeader{ + Type: prot.MsgTypeRequest | prot.MsgType(prot.RPCExecuteProcess), + Size: uint32(len(msg)) + prot.HdrSize, + ID: 1, + }, + message: msg, + } + + if err := b.executeProcess(req); err != nil { + t.Fatalf("executeProcess: %v", err) + } + + var got request + select { + case got = <-b.sendToGCSCh: + case <-time.After(time.Second): + t.Fatal("nothing forwarded to GCS") + } + + gotParams := unwrapExecParams(t, got.message) + if gotParams.CreateStdInPipe || gotParams.CreateStdOutPipe || gotParams.CreateStdErrPipe { + t.Errorf("stdio pipes not cleared: %+v", gotParams) + } +} + +// TestExecuteProcess_External_DeniedStdioWithConsoleRejected verifies that a +// console-requesting external process is rejected (not forwarded) when policy +// denies stdio. +func TestExecuteProcess_External_DeniedStdioWithConsoleRejected(t *testing.T) { + b := newTestBridge(&stdioDenyExternalEnforcer{}) + + params := hcsschema.ProcessParameters{CommandLine: "cmd.exe", EmulateConsole: true} + r := prot.ContainerExecuteProcess{ + RequestBase: prot.RequestBase{ContainerID: UVMContainerID, ActivityID: guid.GUID{}}, + Settings: prot.ExecuteProcessSettings{ProcessParameters: prot.AnyInString{Value: ¶ms}}, + } + msg, err := json.Marshal(&r) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + req := &request{ + ctx: context.Background(), + header: messageHeader{ + Type: prot.MsgTypeRequest | prot.MsgType(prot.RPCExecuteProcess), + Size: uint32(len(msg)) + prot.HdrSize, + ID: 1, + }, + message: msg, + } + + err = b.executeProcess(req) + if err == nil || !strings.Contains(err.Error(), "console") { + t.Fatalf("expected console denial, got %v", err) + } + assertNothingForwarded(t, b) +} + +// TestExecuteProcess_InitExec_DeniedStdioClearsPipes verifies the init-process +// branch applies the create-time stdio decision (c.allowStdio=false) by +// clearing the stdio pipe flags before forwarding. +func TestExecuteProcess_InitExec_DeniedStdioClearsPipes(t *testing.T) { + b := newTestBridge(&securitypolicy.OpenDoorSecurityPolicyEnforcer{}) + const cid = "container-1" + c := &Container{ + id: cid, + spec: oci.Spec{Process: enforcedInitProcess()}, + processes: make(map[uint32]*containerProcess), + commandLine: true, + commandLineExec: false, + allowStdio: false, + } + if err := b.hostState.AddContainer(context.Background(), cid, c); err != nil { + t.Fatalf("AddContainer: %v", err) + } + + req := buildExecRequest(t, cid, hcsschema.ProcessParameters{ + CommandLine: "python hello.py", + WorkingDirectory: `C:\app`, + User: "ContainerUser", + CreateStdInPipe: true, + CreateStdOutPipe: true, + CreateStdErrPipe: true, + }) + + done := make(chan error, 1) + go func() { done <- b.executeProcess(req) }() + + var got request + select { + case got = <-b.sendToGCSCh: + case <-time.After(time.Second): + t.Fatal("nothing forwarded to GCS") + } + + b.pendingMu.Lock() + ch := b.pending[got.header.ID] + b.pendingMu.Unlock() + if ch == nil { + t.Fatal("no pending response channel registered for forwarded request") + } + ch <- &prot.ContainerExecuteProcessResponse{ProcessID: 42} + + if err := <-done; err != nil { + t.Fatalf("executeProcess: %v", err) + } + + gotParams := unwrapExecParams(t, got.message) + if gotParams.CreateStdInPipe || gotParams.CreateStdOutPipe || gotParams.CreateStdErrPipe { + t.Errorf("stdio pipes not cleared: %+v", gotParams) + } +} + +// TestExecuteProcess_InitExec_AllowsStdioKeepsPipes verifies the init-process +// branch leaves the stdio pipe flags intact when the create-time decision +// allows stdio (c.allowStdio=true). +func TestExecuteProcess_InitExec_AllowsStdioKeepsPipes(t *testing.T) { + b := newTestBridge(&securitypolicy.OpenDoorSecurityPolicyEnforcer{}) + const cid = "container-1" + c := &Container{ + id: cid, + spec: oci.Spec{Process: enforcedInitProcess()}, + processes: make(map[uint32]*containerProcess), + commandLine: true, + commandLineExec: false, + allowStdio: true, + } + if err := b.hostState.AddContainer(context.Background(), cid, c); err != nil { + t.Fatalf("AddContainer: %v", err) + } + + req := buildExecRequest(t, cid, hcsschema.ProcessParameters{ + CommandLine: "python hello.py", + WorkingDirectory: `C:\app`, + User: "ContainerUser", + CreateStdInPipe: true, + CreateStdOutPipe: true, + CreateStdErrPipe: true, + }) + + done := make(chan error, 1) + go func() { done <- b.executeProcess(req) }() + + var got request + select { + case got = <-b.sendToGCSCh: + case <-time.After(time.Second): + t.Fatal("nothing forwarded to GCS") + } + + b.pendingMu.Lock() + ch := b.pending[got.header.ID] + b.pendingMu.Unlock() + if ch == nil { + t.Fatal("no pending response channel registered for forwarded request") + } + ch <- &prot.ContainerExecuteProcessResponse{ProcessID: 42} + + if err := <-done; err != nil { + t.Fatalf("executeProcess: %v", err) + } + + gotParams := unwrapExecParams(t, got.message) + if !gotParams.CreateStdInPipe || !gotParams.CreateStdOutPipe || !gotParams.CreateStdErrPipe { + t.Errorf("stdio pipes should be preserved when allowed: %+v", gotParams) + } +} diff --git a/internal/gcs-sidecar/host.go b/internal/gcs-sidecar/host.go index 2c73f34ff3..25b2a79c8e 100644 --- a/internal/gcs-sidecar/host.go +++ b/internal/gcs-sidecar/host.go @@ -36,6 +36,8 @@ type Container struct { processes map[uint32]*containerProcess commandLine bool commandLineExec bool + // allowStdio is the create-time stdio-access policy decision. + allowStdio bool } // Process is a struct that defines the lifetime and operations associated with From 020d29a4ea0ac45419dc4384e24385c575ceceb2 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Fri, 3 Jul 2026 14:04:31 +0100 Subject: [PATCH 28/56] Make registry_changes order-robust with dropping registry_changes now narrows data.metadata.matches to the container(s) that authorize the kept subset of requested values, mirroring the environment-variable dropping pattern, and returns that subset (registry_changes_to_keep) so the host-side enforcer applies only the sanctioned values. Because both registry_changes and create_container are filters over data.metadata.matches, the decision composes regardless of which runs first. EnforceRegistryChangesPolicy now returns the policy-kept registry changes; the gcs-sidecar rebuilds the forwarded RegistryChanges as the pre-approved defaults plus the kept non-default values before forwarding to the inbox GCS. Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 53 +++-- pkg/securitypolicy/framework.rego | 106 +++++++-- pkg/securitypolicy/regopolicy_windows_test.go | 205 +++++++++++++++++- pkg/securitypolicy/securitypolicyenforcer.go | 10 +- .../securitypolicyenforcer_rego.go | 28 ++- 5 files changed, 352 insertions(+), 50 deletions(-) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index bf65b72568..c12f25a5ce 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -78,26 +78,24 @@ func (b *Bridge) createContainer(req *request) (err error) { containerJSON, _ := json.Marshal(container) log.G(ctx).Tracef("rpcCreate: CWCOWHostedSystemConfig {spec: %v, schemaVersion: %v, container: %s}}", string(req.message), schemaVersion, containerJSON) - // Enforce registry changes policy + // Enforce registry changes policy. This may drop unauthorized + // non-default registry values from the container before forwarding. if container != nil && container.RegistryChanges != nil { log.G(ctx).Trace("Container has registry changes, validating against policy") - // First, separate default values from non-default values + // First, separate default values from non-default values. var defaultValues []hcsschema.RegistryValue var nonDefaultValues []hcsschema.RegistryValue - - if container.RegistryChanges.AddValues != nil { - for _, value := range container.RegistryChanges.AddValues { - if isDefaultRegistryValue(value) { - defaultValues = append(defaultValues, value) - log.G(ctx).WithField("name", value.Name).Trace("Registry value matches default, accepting without policy check") - } else { - nonDefaultValues = append(nonDefaultValues, value) - } + for _, value := range container.RegistryChanges.AddValues { + if isDefaultRegistryValue(value) { + defaultValues = append(defaultValues, value) + log.G(ctx).WithField("name", value.Name).Trace("Registry value matches default, accepting without policy check") + } else { + nonDefaultValues = append(nonDefaultValues, value) } } - // If there are non-default values, validate them against policy + // If there are non-default values, validate them against policy. if len(nonDefaultValues) > 0 { log.G(ctx).Tracef("Validating %d registry values against policy", len(nonDefaultValues)) @@ -105,16 +103,22 @@ func (b *Bridge) createContainer(req *request) (err error) { AddValues: nonDefaultValues, } - err := b.hostState.securityOptions.PolicyEnforcer.EnforceRegistryChangesPolicy(ctx, containerID, nonDefaultChanges) + keptRaw, err := b.hostState.securityOptions.PolicyEnforcer.EnforceRegistryChangesPolicy(ctx, containerID, nonDefaultChanges) if err != nil { log.G(ctx).WithError(err).Warn("Registry changes validation failed - rejecting") return fmt.Errorf("registry entry operation is denied by policy: %w", err) } - log.G(ctx).Tracef("All container registry values validated successfully") + + // The policy uses dropping semantics: it may authorize only a + // subset of the requested non-default values. Rebuild the + // container's registry changes as the pre-approved defaults plus + // the policy-kept non-default values so the guest only applies + // what policy sanctioned. + container.RegistryChanges.AddValues = mergeKeptRegistryValues(defaultValues, keptRaw) } - log.G(ctx).Infof("Registry validation complete: %d total values (%d defaults + %d validated)", - len(container.RegistryChanges.AddValues), len(defaultValues), len(nonDefaultValues)) + log.G(ctx).Infof("Registry validation complete: %d total values now applied (%d defaults)", + len(container.RegistryChanges.AddValues), len(defaultValues)) } // We enforce `spec`, which is not passed to inbox gcs within this createContainer. @@ -339,6 +343,23 @@ func (b *Bridge) createContainer(req *request) (err error) { return nil } +// mergeKeptRegistryValues combines the pre-approved default registry values +// with the policy-kept subset returned by EnforceRegistryChangesPolicy. Because +// the policy uses dropping semantics, it may authorize only a subset of the +// requested non-default values; the returned slice is what the guest should +// apply (defaults plus the kept non-default values). +func mergeKeptRegistryValues(defaultValues []hcsschema.RegistryValue, kept interface{}) []hcsschema.RegistryValue { + var keptNonDefault []hcsschema.RegistryValue + if k, ok := kept.(*hcsschema.RegistryChanges); ok && k != nil { + keptNonDefault = k.AddValues + } + + newValues := make([]hcsschema.RegistryValue, 0, len(defaultValues)+len(keptNonDefault)) + newValues = append(newValues, defaultValues...) + newValues = append(newValues, keptNonDefault...) + return newValues +} + // namedPipePrefix is the prefix used for Windows named pipe paths. A mount // whose OCI destination starts with this prefix becomes a MappedPipe in the // HostedSystem, with ContainerPipeName set to the destination minus this diff --git a/pkg/securitypolicy/framework.rego b/pkg/securitypolicy/framework.rego index eff9b4c6eb..a992b0af90 100644 --- a/pkg/securitypolicy/framework.rego +++ b/pkg/securitypolicy/framework.rego @@ -1498,29 +1498,79 @@ registry_value_matches(policy_value, input_value) { policy_value.type == "None" } -# Filter input registry values to only include those that match policy -filtered_registry_values(input_values, policy_values) := [input_val | - input_val := input_values[_] - some policy_val in policy_values - registry_value_matches(policy_val, input_val) -] - # TODO: have allow_registry_changes_dropping switch like environment variable's allow_environment_variable_dropping. -registry_changes := {"allowed": true} { - containers := data.metadata.matches[input.containerID] - container := containers[_] - - # Check if container has registry_changes defined in policy - container.registry_changes - - # If input has registry changes, filter to only matching ones - input.registryChanges.AddValues - matched_values := filtered_registry_values(input.registryChanges.AddValues, container.registry_changes.add_values) - - # Build result with filtered AddValues - result := { - "AddValues": matched_values +# valid_registry_subset is the set of requested registry values that the +# container's policy authorizes. +valid_registry_subset(container) := values { + values := {input_value | + some input_value in input.registryChanges.AddValues + some policy_value in container.registry_changes.add_values + registry_value_matches(policy_value, input_value) + } +} + +# valid_registry_for_all selects the most specific (largest) authorized subset +# across the candidate containers, mirroring valid_envs_for_all. If several +# containers tie for the largest subset, they must authorize the same set +# (intersection == union) for the result to be decidable. +valid_registry_for_all(containers) := values { + valid := [subset | + some container in containers + subset := valid_registry_subset(container) + ] + + counts := [count(subset) | subset := valid[_]] + max_count := max(counts) + + largest_value_sets := {subset | + some i + counts[i] == max_count + subset := valid[i] + } + + values_i := intersection(largest_value_sets) + values_u := union(largest_value_sets) + values_i == values_u + values := values_i +} + +# registryValues_ok holds when the container's registry_changes policy +# authorizes every value in registryValues. This mirrors envList_ok. Note we +# pass the whole container rather than container.registry_changes because that +# field is optional: for empty registryValues the `every` is vacuously true, so +# a container with no registry_changes correctly matches the "drop everything" +# case (and the missing field is never dereferenced). +registryValues_ok(container, registryValues) { + every input_value in registryValues { + some policy_value in container.registry_changes.add_values + registry_value_matches(policy_value, input_value) + } +} + +# registry_changes uses "dropping" semantics like allow_environment_variable_dropping: +# it keeps the subset of requested values that policy authorizes (dropping the +# rest), narrows matches to the container(s) that authorize exactly that +# most-specific set, and returns those values (as registry_changes_to_keep) so +# the host-side enforcer applies only them. Recording the narrowing into +# data.metadata.matches makes the decision compose with create_container +# regardless of the order in which the two run. +registry_changes := {"metadata": [updateMatches], "registry_changes_to_keep": registry_values, "allowed": true} { + matches := data.metadata.matches[input.containerID] + registry_values := valid_registry_for_all(matches) + + containers := [container | + container := matches[_] + registryValues_ok(container, registry_values) + ] + + count(containers) > 0 + + updateMatches := { + "name": "matches", + "action": "update", + "key": input.containerID, + "value": containers, } } @@ -1963,6 +2013,20 @@ errors["no mapped directory at path to unmount"] { not mapped_directory_mounted(input.unmountTarget) } +default registry_changes_allowed := false + +registry_changes_allowed { + matches := data.metadata.matches[input.containerID] + registry_values := valid_registry_for_all(matches) + some container in matches + registryValues_ok(container, registry_values) +} + +errors["invalid registry changes"] { + input.rule == "registry_changes" + not registry_changes_allowed +} + errors[framework_version_error] { policy_framework_version == null framework_version_error := concat(" ", ["framework_version is missing. Current version:", version]) diff --git a/pkg/securitypolicy/regopolicy_windows_test.go b/pkg/securitypolicy/regopolicy_windows_test.go index 85f4eae560..03ca1fbdd4 100644 --- a/pkg/securitypolicy/regopolicy_windows_test.go +++ b/pkg/securitypolicy/regopolicy_windows_test.go @@ -1631,7 +1631,7 @@ func Test_Rego_EnforceRegistryChangesPolicy_Matches_Windows(t *testing.T) { }, } - err = tc.policy.EnforceRegistryChangesPolicy(p.ctx, container.containerID, registryChanges) + _, err = tc.policy.EnforceRegistryChangesPolicy(p.ctx, container.containerID, registryChanges) // With default values, this should be allowed if err != nil { t.Logf("Registry enforcement returned: %v", err) @@ -1671,7 +1671,7 @@ func Test_Rego_EnforceRegistryChangesPolicy_Invalid_ContainerID_Windows(t *testi }, } - err = tc.policy.EnforceRegistryChangesPolicy(p.ctx, invalidContainerID, registryChanges) + _, err = tc.policy.EnforceRegistryChangesPolicy(p.ctx, invalidContainerID, registryChanges) if err == nil { t.Error("Expected registry changes to be denied with invalid container ID") return false @@ -1720,7 +1720,7 @@ func Test_Rego_EnforceRegistryChangesPolicy_Default_Values_Allowed_Windows(t *te }, } - err = tc.policy.EnforceRegistryChangesPolicy(p.ctx, container.containerID, registryChanges) + _, err = tc.policy.EnforceRegistryChangesPolicy(p.ctx, container.containerID, registryChanges) // Default values should be allowed if err != nil { t.Logf("Default registry values enforcement returned: %v", err) @@ -1734,6 +1734,205 @@ func Test_Rego_EnforceRegistryChangesPolicy_Default_Values_Allowed_Windows(t *te } } +// regoTwoContainersSharedLayersRegistry is a hand-written policy with two +// containers that share the same layers/mounted_cim (so both survive +// mount_cims) but differ in command, and only B declares a registry_changes +// rule that sanctions a "dangerous" value. It is used to prove that +// registry_changes narrows data.metadata.matches so the decision composes with +// create_container regardless of enforcement order. +const regoTwoContainersSharedLayersRegistry = `package policy + +api_version := "%s" +framework_version := "%s" + +containers := [ + { + "allow_stdio_access": true, + "command": ["cmd"], + "env_rules": [], + "exec_processes": [], + "id": "test-image", + "layers": ["layerA", "layerB"], + "mounted_cim": ["merged"], + "mounts": [], + "name": "A", + "signals": [], + "user": "ContainerUser", + "working_dir": "C:\\app" + }, + { + "allow_stdio_access": true, + "command": ["ping"], + "env_rules": [], + "exec_processes": [], + "id": "test-image", + "layers": ["layerA", "layerB"], + "mounted_cim": ["merged"], + "mounts": [], + "name": "B", + "registry_changes": { + "add_values": [ + { + "key": {"hive": "System", "name": "TestControl"}, + "name": "Danger", + "type": "String", + "string_value": "danger" + } + ] + }, + "signals": [], + "user": "ContainerUser", + "working_dir": "C:\\app" + } +] + +fragments := [] +external_processes := [] +mapped_directories := [] + +allow_properties_access := false +allow_dump_stacks := false +allow_runtime_logging := false +allow_environment_variable_dropping := false +allow_unencrypted_scratch := false +allow_capability_dropping := false + +mount_device := data.framework.mount_device +rw_mount_device := data.framework.rw_mount_device +unmount_device := data.framework.unmount_device +rw_unmount_device := data.framework.rw_unmount_device +mount_overlay := data.framework.mount_overlay +unmount_overlay := data.framework.unmount_overlay +mount_cims := data.framework.mount_cims +registry_changes := data.framework.registry_changes +create_container := data.framework.create_container +exec_in_container := data.framework.exec_in_container +exec_external := data.framework.exec_external +shutdown_container := data.framework.shutdown_container +signal_container_process := data.framework.signal_container_process +plan9_mount := data.framework.plan9_mount +plan9_unmount := data.framework.plan9_unmount +get_properties := data.framework.get_properties +dump_stacks := data.framework.dump_stacks +runtime_logging := data.framework.runtime_logging +load_fragment := data.framework.load_fragment +scratch_mount := data.framework.scratch_mount +scratch_unmount := data.framework.scratch_unmount +mapped_directory_mount := data.framework.mapped_directory_mount +mapped_directory_unmount := data.framework.mapped_directory_unmount +reason := data.framework.reason +` + +// Test_Rego_RegistryChanges_NarrowsMatches_Windows verifies that the registry +// enforcement point uses dropping semantics that compose with create_container +// in either order. Registry narrows data.metadata.matches to the container(s) +// that authorize the kept subset, and returns that kept subset so the host can +// drop the rest. Containers A (command "cmd", no registry rule) and B (command +// "ping", authorizes a dangerous registry value) share layers, so both survive +// mount_cims; the danger is that a request could pass the dangerous value while +// running A's command. In either enforcement order, "cmd" never runs with the +// dangerous value: either create(cmd) is denied (registry narrowed to B first), +// or the dangerous value is dropped (create(cmd) narrowed to A first). +// TODO: maybe delete it if it's too much. +func Test_Rego_RegistryChanges_NarrowsMatches_Windows(t *testing.T) { + rego := fmt.Sprintf(regoTwoContainersSharedLayersRegistry, apiVersion, frameworkVersion) + + dangerous := &hcsschema.RegistryChanges{ + AddValues: []hcsschema.RegistryValue{ + { + Key: &hcsschema.RegistryKey{Hive: "System", Name: "TestControl"}, + Name: "Danger", + Type_: hcsschema.RegistryValueType_STRING, + StringValue: "danger", + }, + }, + } + + keptCount := func(t *testing.T, keptRaw interface{}) int { + t.Helper() + kept, ok := keptRaw.(*hcsschema.RegistryChanges) + if !ok || kept == nil { + t.Fatalf("expected *hcsschema.RegistryChanges, got %T", keptRaw) + } + return len(kept.AddValues) + } + + // mount_cims reverses the layer order, so pass layers reversed. + layerHashes := []string{"layerB", "layerA"} + mountedCim := []string{"merged"} + ctx := context.Background() + user := IDName{Name: "ContainerUser"} + + newPolicy := func() *regoEnforcer { + policy, err := newRegoPolicy(rego, []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("failed to create policy: %v", err) + } + return policy + } + + // Order 1: registry (kept via B) then create with A's command. Registry + // narrows matches to [B], so create with "cmd" must be denied. + t.Run("registry_then_create_denied", func(t *testing.T) { + policy := newPolicy() + cid := testDataGenerator.uniqueContainerID() + if err := policy.EnforceVerifiedCIMsPolicy(ctx, cid, layerHashes, mountedCim); err != nil { + t.Fatalf("mount_cims: %v", err) + } + kept, err := policy.EnforceRegistryChangesPolicy(ctx, cid, dangerous) + if err != nil { + t.Fatalf("registry should be allowed (kept via B): %v", err) + } + if n := keptCount(t, kept); n != 1 { + t.Errorf("expected the dangerous value kept via B, got %d kept values", n) + } + _, _, _, err = policy.EnforceCreateContainerPolicyV2(ctx, cid, []string{"cmd"}, []string{}, `C:\app`, []oci.Mount{}, user, nil) + if err == nil { + t.Error("create(cmd) after registry(dangerous) should be denied: registry narrowed matches to B (command ping)") + } + }) + + // Order 2: create with A's command (narrows to [A]) then registry. A has no + // registry rule, so the dangerous value must be dropped (kept empty), but + // the request is still allowed since dropping is permissive. + t.Run("create_then_registry_drops", func(t *testing.T) { + policy := newPolicy() + cid := testDataGenerator.uniqueContainerID() + if err := policy.EnforceVerifiedCIMsPolicy(ctx, cid, layerHashes, mountedCim); err != nil { + t.Fatalf("mount_cims: %v", err) + } + if _, _, _, err := policy.EnforceCreateContainerPolicyV2(ctx, cid, []string{"cmd"}, []string{}, `C:\app`, []oci.Mount{}, user, nil); err != nil { + t.Fatalf("create(cmd) should be allowed as A: %v", err) + } + kept, err := policy.EnforceRegistryChangesPolicy(ctx, cid, dangerous) + if err != nil { + t.Fatalf("registry(dangerous) after create(cmd) should be allowed with dropping: %v", err) + } + if n := keptCount(t, kept); n != 0 { + t.Errorf("dangerous value should be dropped for A, got %d kept values", n) + } + }) + + // Legit path: B's command with B's registry value keeps the value. + t.Run("create_ping_then_registry_keeps", func(t *testing.T) { + policy := newPolicy() + cid := testDataGenerator.uniqueContainerID() + if err := policy.EnforceVerifiedCIMsPolicy(ctx, cid, layerHashes, mountedCim); err != nil { + t.Fatalf("mount_cims: %v", err) + } + if _, _, _, err := policy.EnforceCreateContainerPolicyV2(ctx, cid, []string{"ping"}, []string{}, `C:\app`, []oci.Mount{}, user, nil); err != nil { + t.Fatalf("create(ping) should be allowed as B: %v", err) + } + kept, err := policy.EnforceRegistryChangesPolicy(ctx, cid, dangerous) + if err != nil { + t.Fatalf("registry(dangerous) after create(ping) should be allowed via B: %v", err) + } + if n := keptCount(t, kept); n != 1 { + t.Errorf("dangerous value should be kept for B, got %d kept values", n) + } + }) +} + // This is a no-op for windows. // substituteUVMPath substitutes mount prefix to an appropriate path inside // UVM. At policy generation time, it's impossible to tell what the sandboxID diff --git a/pkg/securitypolicy/securitypolicyenforcer.go b/pkg/securitypolicy/securitypolicyenforcer.go index 5302dafdec..a469c83576 100644 --- a/pkg/securitypolicy/securitypolicyenforcer.go +++ b/pkg/securitypolicy/securitypolicyenforcer.go @@ -129,7 +129,7 @@ type SecurityPolicyEnforcer interface { EnforceMappedDirectoryUnmountPolicy(ctx context.Context, containerPath string) (err error) GetUserInfo(spec *oci.Process, rootPath string) (IDName, []IDName, string, error) EnforceVerifiedCIMsPolicy(ctx context.Context, containerID string, layerHashes []string, mountedCim []string) (err error) - EnforceRegistryChangesPolicy(ctx context.Context, containerID string, registryValues interface{}) error + EnforceRegistryChangesPolicy(ctx context.Context, containerID string, registryValues interface{}) (interface{}, error) } //nolint:unused @@ -330,8 +330,8 @@ func (OpenDoorSecurityPolicyEnforcer) EnforceVerifiedCIMsPolicy(ctx context.Cont return nil } -func (OpenDoorSecurityPolicyEnforcer) EnforceRegistryChangesPolicy(ctx context.Context, containerID string, registryValues interface{}) error { - return nil +func (OpenDoorSecurityPolicyEnforcer) EnforceRegistryChangesPolicy(ctx context.Context, containerID string, registryValues interface{}) (interface{}, error) { + return registryValues, nil } type ClosedDoorSecurityPolicyEnforcer struct{} @@ -467,6 +467,6 @@ func (ClosedDoorSecurityPolicyEnforcer) EnforceVerifiedCIMsPolicy(ctx context.Co return nil } -func (ClosedDoorSecurityPolicyEnforcer) EnforceRegistryChangesPolicy(ctx context.Context, containerID string, registryValues interface{}) error { - return errors.New("registry changes are denied by policy") +func (ClosedDoorSecurityPolicyEnforcer) EnforceRegistryChangesPolicy(ctx context.Context, containerID string, registryValues interface{}) (interface{}, error) { + return nil, errors.New("registry changes are denied by policy") } diff --git a/pkg/securitypolicy/securitypolicyenforcer_rego.go b/pkg/securitypolicy/securitypolicyenforcer_rego.go index ad2616dab3..d713a009d6 100644 --- a/pkg/securitypolicy/securitypolicyenforcer_rego.go +++ b/pkg/securitypolicy/securitypolicyenforcer_rego.go @@ -763,7 +763,6 @@ func (policy *regoEnforcer) EnforceCreateContainerPolicyV2( log.G(ctx).WithError(err).Warn("failed to obtain policy metadata snapshot") } - // TODO: we should handle registry here? for narrowing input = inputData{ "mounts": appendMountData([]interface{}{}, mounts), "containerID": containerID, @@ -1188,14 +1187,14 @@ func (policy *regoEnforcer) EnforceVerifiedCIMsPolicy(ctx context.Context, conta return err } -func (policy *regoEnforcer) EnforceRegistryChangesPolicy(ctx context.Context, containerID string, registryValues interface{}) error { +func (policy *regoEnforcer) EnforceRegistryChangesPolicy(ctx context.Context, containerID string, registryValues interface{}) (interface{}, error) { log.G(ctx).Trace("Enforcing registry changes policy") // Import the schema type for proper conversion regChanges, ok := registryValues.(*hcsschema.RegistryChanges) if !ok { log.G(ctx).Warn("Input registry values are not of expected type") - return errors.New("invalid registry values type") + return nil, errors.New("invalid registry values type") } input := inputData{ @@ -1203,8 +1202,27 @@ func (policy *regoEnforcer) EnforceRegistryChangesPolicy(ctx context.Context, co "registryChanges": regChanges, } - _, err := policy.enforce(ctx, "registry_changes", input) - return err + result, err := policy.enforce(ctx, "registry_changes", input) + if err != nil { + return nil, err + } + + // The policy uses dropping semantics: it authorizes a subset of the + // requested values and returns that subset in "registry_changes_to_keep". + // Round-trip it back into the schema type so the caller applies only the + // kept values. + kept := &hcsschema.RegistryChanges{} + if raw, verr := result.Value("registry_changes_to_keep"); verr == nil && raw != nil { + buf, merr := json.Marshal(raw) + if merr != nil { + return nil, fmt.Errorf("failed to marshal kept registry values: %w", merr) + } + if uerr := json.Unmarshal(buf, &kept.AddValues); uerr != nil { + return nil, fmt.Errorf("failed to unmarshal kept registry values: %w", uerr) + } + } + + return kept, nil } func (policy *regoEnforcer) GetUserInfo(process *oci.Process, rootPath string) (IDName, []IDName, string, error) { From 6c0454c6dac98a1bd5e01e9d21f66a4cd4cacf94 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Fri, 3 Jul 2026 15:16:21 +0100 Subject: [PATCH 29/56] Add allow_registry_changes_dropping swtich Signed-off-by: Takuro Sato --- internal/tools/securitypolicy/main.go | 1 + pkg/securitypolicy/framework.rego | 50 ++++++++------- pkg/securitypolicy/opts.go | 7 ++ pkg/securitypolicy/rego_utils_test.go | 6 ++ pkg/securitypolicy/regopolicy_linux_test.go | 1 + pkg/securitypolicy/regopolicy_windows_test.go | 64 +++++++++++++++---- pkg/securitypolicy/securitypolicy.go | 5 +- pkg/securitypolicy/securitypolicy_internal.go | 4 ++ pkg/securitypolicy/securitypolicy_marshal.go | 17 ++++- test/pkg/securitypolicy/policy.go | 1 + 10 files changed, 116 insertions(+), 40 deletions(-) diff --git a/internal/tools/securitypolicy/main.go b/internal/tools/securitypolicy/main.go index d5ad89e28b..1a8306c575 100644 --- a/internal/tools/securitypolicy/main.go +++ b/internal/tools/securitypolicy/main.go @@ -68,6 +68,7 @@ func main() { config.AllowEnvironmentVariableDropping, config.AllowUnencryptedScratch, config.AllowCapabilityDropping, + config.AllowRegistryChangesDropping, ) } if err != nil { diff --git a/pkg/securitypolicy/framework.rego b/pkg/securitypolicy/framework.rego index a992b0af90..4f4e805671 100644 --- a/pkg/securitypolicy/framework.rego +++ b/pkg/securitypolicy/framework.rego @@ -1498,8 +1498,6 @@ registry_value_matches(policy_value, input_value) { policy_value.type == "None" } -# TODO: have allow_registry_changes_dropping switch like environment variable's allow_environment_variable_dropping. - # valid_registry_subset is the set of requested registry values that the # container's policy authorizes. valid_registry_subset(container) := values { @@ -1510,11 +1508,16 @@ valid_registry_subset(container) := values { } } -# valid_registry_for_all selects the most specific (largest) authorized subset -# across the candidate containers, mirroring valid_envs_for_all. If several -# containers tie for the largest subset, they must authorize the same set -# (intersection == union) for the result to be decidable. +# valid_registry_for_all selects the registry values to keep across the +# candidate containers, mirroring valid_envs_for_all. With +# allow_registry_changes_dropping it keeps the most specific (largest) +# authorized subset, dropping the rest; if several containers tie for the +# largest subset they must authorize the same set (intersection == union) for +# the result to be decidable. Without dropping it keeps every requested value, +# so a container must authorize all of them for the request to be allowed. valid_registry_for_all(containers) := values { + allow_registry_changes_dropping + valid := [subset | some container in containers subset := valid_registry_subset(container) @@ -1535,6 +1538,13 @@ valid_registry_for_all(containers) := values { values := values_i } +valid_registry_for_all(containers) := values { + not allow_registry_changes_dropping + + # no dropping: keep every requested value, so a container must authorize all + values := {input_value | some input_value in input.registryChanges.AddValues} +} + # registryValues_ok holds when the container's registry_changes policy # authorizes every value in registryValues. This mirrors envList_ok. Note we # pass the whole container rather than container.registry_changes because that @@ -1548,13 +1558,12 @@ registryValues_ok(container, registryValues) { } } -# registry_changes uses "dropping" semantics like allow_environment_variable_dropping: -# it keeps the subset of requested values that policy authorizes (dropping the -# rest), narrows matches to the container(s) that authorize exactly that -# most-specific set, and returns those values (as registry_changes_to_keep) so -# the host-side enforcer applies only them. Recording the narrowing into -# data.metadata.matches makes the decision compose with create_container -# regardless of the order in which the two run. +# registry_changes keeps the registry values that policy authorizes (via +# valid_registry_for_all, which honors allow_registry_changes_dropping), narrows +# matches to the container(s) that authorize exactly that set, and returns those +# values (as registry_changes_to_keep) so the host-side enforcer applies only +# them. Recording the narrowing into data.metadata.matches makes the decision +# compose with create_container regardless of the order in which the two run. registry_changes := {"metadata": [updateMatches], "registry_changes_to_keep": registry_values, "allowed": true} { matches := data.metadata.matches[input.containerID] registry_values := valid_registry_for_all(matches) @@ -2013,18 +2022,9 @@ errors["no mapped directory at path to unmount"] { not mapped_directory_mounted(input.unmountTarget) } -default registry_changes_allowed := false - -registry_changes_allowed { - matches := data.metadata.matches[input.containerID] - registry_values := valid_registry_for_all(matches) - some container in matches - registryValues_ok(container, registry_values) -} - errors["invalid registry changes"] { input.rule == "registry_changes" - not registry_changes_allowed + not registry_changes.allowed } errors[framework_version_error] { @@ -2518,6 +2518,10 @@ allow_capability_dropping := flag { flag := data.policy.allow_capability_dropping } +default allow_registry_changes_dropping := false + +allow_registry_changes_dropping := data.policy.allow_registry_changes_dropping + default policy_framework_version := null default policy_api_version := null diff --git a/pkg/securitypolicy/opts.go b/pkg/securitypolicy/opts.go index a11685abc4..1b6c46a740 100644 --- a/pkg/securitypolicy/opts.go +++ b/pkg/securitypolicy/opts.go @@ -122,6 +122,13 @@ func WithAllowCapabilityDropping(allow bool) PolicyConfigOpt { } } +func WithAllowRegistryChangesDropping(allow bool) PolicyConfigOpt { + return func(config *PolicyConfig) error { + config.AllowRegistryChangesDropping = allow + return nil + } +} + func WithAllowRuntimeLogging(allow bool) PolicyConfigOpt { return func(config *PolicyConfig) error { config.AllowRuntimeLogging = allow diff --git a/pkg/securitypolicy/rego_utils_test.go b/pkg/securitypolicy/rego_utils_test.go index cdd97cf1e3..7246fe8b46 100644 --- a/pkg/securitypolicy/rego_utils_test.go +++ b/pkg/securitypolicy/rego_utils_test.go @@ -2037,6 +2037,7 @@ func (constraints *generatedConstraints) toPolicy() *securityPolicyInternal { AllowEnvironmentVariableDropping: constraints.allowEnvironmentVariableDropping, AllowUnencryptedScratch: constraints.allowUnencryptedScratch, AllowCapabilityDropping: constraints.allowCapabilityDropping, + AllowRegistryChangesDropping: constraints.allowRegistryChangesDropping, } } @@ -2298,6 +2299,7 @@ func generateConstraints(r *rand.Rand, maxContainers int32) *generatedConstraint namespace: generateFragmentNamespace(testRand), svn: generateSVN(testRand), allowCapabilityDropping: false, + allowRegistryChangesDropping: false, ctx: context.Background(), } } @@ -2959,6 +2961,7 @@ type generatedConstraints struct { namespace string svn string allowCapabilityDropping bool + allowRegistryChangesDropping bool ctx context.Context } @@ -2975,6 +2978,7 @@ type generatedWindowsConstraints struct { namespace string svn string allowCapabilityDropping bool + allowRegistryChangesDropping bool ctx context.Context } @@ -2990,6 +2994,7 @@ func (constraints *generatedWindowsConstraints) toPolicy() *securityPolicyWindow AllowEnvironmentVariableDropping: constraints.allowEnvironmentVariableDropping, AllowUnencryptedScratch: constraints.allowUnencryptedScratch, AllowCapabilityDropping: constraints.allowCapabilityDropping, + AllowRegistryChangesDropping: constraints.allowRegistryChangesDropping, } } @@ -3034,6 +3039,7 @@ func generateWindowsConstraints(r *rand.Rand, maxContainers int32) *generatedWin allowEnvironmentVariableDropping: false, allowUnencryptedScratch: false, allowCapabilityDropping: false, + allowRegistryChangesDropping: false, namespace: generateFragmentNamespace(r), svn: generateSVN(r), ctx: context.Background(), diff --git a/pkg/securitypolicy/regopolicy_linux_test.go b/pkg/securitypolicy/regopolicy_linux_test.go index 8dd409fccf..4bdfe9cf2a 100644 --- a/pkg/securitypolicy/regopolicy_linux_test.go +++ b/pkg/securitypolicy/regopolicy_linux_test.go @@ -74,6 +74,7 @@ func Test_MarshalRego_Policy(t *testing.T) { p.allowEnvironmentVariableDropping, p.allowUnencryptedScratch, p.allowCapabilityDropping, + p.allowRegistryChangesDropping, ) if err != nil { t.Error(err) diff --git a/pkg/securitypolicy/regopolicy_windows_test.go b/pkg/securitypolicy/regopolicy_windows_test.go index 03ca1fbdd4..d326d14cd6 100644 --- a/pkg/securitypolicy/regopolicy_windows_test.go +++ b/pkg/securitypolicy/regopolicy_windows_test.go @@ -1796,6 +1796,7 @@ allow_runtime_logging := false allow_environment_variable_dropping := false allow_unencrypted_scratch := false allow_capability_dropping := false +allow_registry_changes_dropping := %t mount_device := data.framework.mount_device rw_mount_device := data.framework.rw_mount_device @@ -1824,19 +1825,18 @@ reason := data.framework.reason ` // Test_Rego_RegistryChanges_NarrowsMatches_Windows verifies that the registry -// enforcement point uses dropping semantics that compose with create_container -// in either order. Registry narrows data.metadata.matches to the container(s) -// that authorize the kept subset, and returns that kept subset so the host can -// drop the rest. Containers A (command "cmd", no registry rule) and B (command +// enforcement point narrows data.metadata.matches so it composes with +// create_container in either order, under both allow_registry_changes_dropping +// settings. Containers A (command "cmd", no registry rule) and B (command // "ping", authorizes a dangerous registry value) share layers, so both survive // mount_cims; the danger is that a request could pass the dangerous value while -// running A's command. In either enforcement order, "cmd" never runs with the -// dangerous value: either create(cmd) is denied (registry narrowed to B first), -// or the dangerous value is dropped (create(cmd) narrowed to A first). +// running A's command. With dropping on, "cmd" never runs with the dangerous +// value (either create(cmd) is denied when registry narrows to B first, or the +// value is dropped when create(cmd) narrows to A first). With dropping off, a +// request is only allowed if a matched container authorizes every requested +// value, so registry(dangerous) against A is denied outright. // TODO: maybe delete it if it's too much. func Test_Rego_RegistryChanges_NarrowsMatches_Windows(t *testing.T) { - rego := fmt.Sprintf(regoTwoContainersSharedLayersRegistry, apiVersion, frameworkVersion) - dangerous := &hcsschema.RegistryChanges{ AddValues: []hcsschema.RegistryValue{ { @@ -1863,7 +1863,8 @@ func Test_Rego_RegistryChanges_NarrowsMatches_Windows(t *testing.T) { ctx := context.Background() user := IDName{Name: "ContainerUser"} - newPolicy := func() *regoEnforcer { + newPolicy := func(dropping bool) *regoEnforcer { + rego := fmt.Sprintf(regoTwoContainersSharedLayersRegistry, apiVersion, frameworkVersion, dropping) policy, err := newRegoPolicy(rego, []oci.Mount{}, []oci.Mount{}, testOSType) if err != nil { t.Fatalf("failed to create policy: %v", err) @@ -1874,7 +1875,7 @@ func Test_Rego_RegistryChanges_NarrowsMatches_Windows(t *testing.T) { // Order 1: registry (kept via B) then create with A's command. Registry // narrows matches to [B], so create with "cmd" must be denied. t.Run("registry_then_create_denied", func(t *testing.T) { - policy := newPolicy() + policy := newPolicy(true) cid := testDataGenerator.uniqueContainerID() if err := policy.EnforceVerifiedCIMsPolicy(ctx, cid, layerHashes, mountedCim); err != nil { t.Fatalf("mount_cims: %v", err) @@ -1896,7 +1897,7 @@ func Test_Rego_RegistryChanges_NarrowsMatches_Windows(t *testing.T) { // registry rule, so the dangerous value must be dropped (kept empty), but // the request is still allowed since dropping is permissive. t.Run("create_then_registry_drops", func(t *testing.T) { - policy := newPolicy() + policy := newPolicy(true) cid := testDataGenerator.uniqueContainerID() if err := policy.EnforceVerifiedCIMsPolicy(ctx, cid, layerHashes, mountedCim); err != nil { t.Fatalf("mount_cims: %v", err) @@ -1915,7 +1916,44 @@ func Test_Rego_RegistryChanges_NarrowsMatches_Windows(t *testing.T) { // Legit path: B's command with B's registry value keeps the value. t.Run("create_ping_then_registry_keeps", func(t *testing.T) { - policy := newPolicy() + policy := newPolicy(true) + cid := testDataGenerator.uniqueContainerID() + if err := policy.EnforceVerifiedCIMsPolicy(ctx, cid, layerHashes, mountedCim); err != nil { + t.Fatalf("mount_cims: %v", err) + } + if _, _, _, err := policy.EnforceCreateContainerPolicyV2(ctx, cid, []string{"ping"}, []string{}, `C:\app`, []oci.Mount{}, user, nil); err != nil { + t.Fatalf("create(ping) should be allowed as B: %v", err) + } + kept, err := policy.EnforceRegistryChangesPolicy(ctx, cid, dangerous) + if err != nil { + t.Fatalf("registry(dangerous) after create(ping) should be allowed via B: %v", err) + } + if n := keptCount(t, kept); n != 1 { + t.Errorf("dangerous value should be kept for B, got %d kept values", n) + } + }) + + // With dropping disabled, a request is only allowed if a matched container + // authorizes every requested value. After create(cmd) narrows to A (no + // registry rule), registry(dangerous) must be denied rather than dropped. + t.Run("no_dropping_create_then_registry_denied", func(t *testing.T) { + policy := newPolicy(false) + cid := testDataGenerator.uniqueContainerID() + if err := policy.EnforceVerifiedCIMsPolicy(ctx, cid, layerHashes, mountedCim); err != nil { + t.Fatalf("mount_cims: %v", err) + } + if _, _, _, err := policy.EnforceCreateContainerPolicyV2(ctx, cid, []string{"cmd"}, []string{}, `C:\app`, []oci.Mount{}, user, nil); err != nil { + t.Fatalf("create(cmd) should be allowed as A: %v", err) + } + if _, err := policy.EnforceRegistryChangesPolicy(ctx, cid, dangerous); err == nil { + t.Error("registry(dangerous) after create(cmd) should be denied without dropping (A authorizes nothing)") + } + }) + + // With dropping disabled, the legit path (B authorizes the value) is still + // allowed and keeps the value. + t.Run("no_dropping_create_ping_then_registry_keeps", func(t *testing.T) { + policy := newPolicy(false) cid := testDataGenerator.uniqueContainerID() if err := policy.EnforceVerifiedCIMsPolicy(ctx, cid, layerHashes, mountedCim); err != nil { t.Fatalf("mount_cims: %v", err) diff --git a/pkg/securitypolicy/securitypolicy.go b/pkg/securitypolicy/securitypolicy.go index 8604c7bc60..5a2dad04c2 100644 --- a/pkg/securitypolicy/securitypolicy.go +++ b/pkg/securitypolicy/securitypolicy.go @@ -65,8 +65,9 @@ type PolicyConfig struct { AllowEnvironmentVariableDropping bool `json:"allow_environment_variable_dropping" toml:"allow_environment_variable_dropping"` // AllowUnencryptedScratch is a global policy configuration that allows // all containers within a pod to be run without scratch encryption. - AllowUnencryptedScratch bool `json:"allow_unencrypted_scratch" toml:"allow_unencrypted_scratch"` - AllowCapabilityDropping bool `json:"allow_capability_dropping" toml:"allow_capability_dropping"` + AllowUnencryptedScratch bool `json:"allow_unencrypted_scratch" toml:"allow_unencrypted_scratch"` + AllowCapabilityDropping bool `json:"allow_capability_dropping" toml:"allow_capability_dropping"` + AllowRegistryChangesDropping bool `json:"allow_registry_changes_dropping" toml:"allow_registry_changes_dropping"` } func NewPolicyConfig(opts ...PolicyConfigOpt) (*PolicyConfig, error) { diff --git a/pkg/securitypolicy/securitypolicy_internal.go b/pkg/securitypolicy/securitypolicy_internal.go index ae160adae4..fd4569bbac 100644 --- a/pkg/securitypolicy/securitypolicy_internal.go +++ b/pkg/securitypolicy/securitypolicy_internal.go @@ -19,6 +19,7 @@ type securityPolicyInternal struct { AllowEnvironmentVariableDropping bool AllowUnencryptedScratch bool AllowCapabilityDropping bool + AllowRegistryChangesDropping bool } // Internal version of Windows SecurityPolicy @@ -33,6 +34,7 @@ type securityPolicyWindowsInternal struct { AllowEnvironmentVariableDropping bool AllowUnencryptedScratch bool AllowCapabilityDropping bool + AllowRegistryChangesDropping bool } type securityPolicyFragment struct { @@ -98,6 +100,7 @@ func newSecurityPolicyInternal( allowDropEnvironmentVariables bool, allowUnencryptedScratch bool, allowDropCapabilities bool, + allowRegistryChangesDropping bool, ) (*securityPolicyInternal, error) { containersInternal, err := containersToInternal(containers) if err != nil { @@ -114,6 +117,7 @@ func newSecurityPolicyInternal( AllowEnvironmentVariableDropping: allowDropEnvironmentVariables, AllowUnencryptedScratch: allowUnencryptedScratch, AllowCapabilityDropping: allowDropCapabilities, + AllowRegistryChangesDropping: allowRegistryChangesDropping, }, nil } diff --git a/pkg/securitypolicy/securitypolicy_marshal.go b/pkg/securitypolicy/securitypolicy_marshal.go index 5494f1e249..febc3b3b8e 100644 --- a/pkg/securitypolicy/securitypolicy_marshal.go +++ b/pkg/securitypolicy/securitypolicy_marshal.go @@ -67,6 +67,7 @@ type OSAwareMarshalFunc func( allowEnvironmentVariableDropping bool, allowUnencryptedScratch bool, allowCapabilityDropping bool, + allowRegistryChangesDropping bool, ) (string, error) // osAwareMarshalRego handles both Linux and Windows containers @@ -83,6 +84,7 @@ func osAwareMarshalRego( allowEnvironmentVariableDropping bool, allowUnencryptedScratch bool, allowCapabilityDropping bool, + allowRegistryChangesDropping bool, ) (string, error) { if allowAll { if len(linuxContainers) > 0 || len(windowsContainers) > 0 { @@ -98,7 +100,8 @@ func osAwareMarshalRego( } return marshalRego(allowAll, linuxContainers, externalProcesses, fragments, allowPropertiesAccess, allowDumpStacks, allowRuntimeLogging, - allowEnvironmentVariableDropping, allowUnencryptedScratch, allowCapabilityDropping) + allowEnvironmentVariableDropping, allowUnencryptedScratch, allowCapabilityDropping, + allowRegistryChangesDropping) case "windows": if len(linuxContainers) > 0 { @@ -106,7 +109,8 @@ func osAwareMarshalRego( } return marshalWindowsRego(allowAll, windowsContainers, externalProcesses, fragments, allowPropertiesAccess, allowDumpStacks, allowRuntimeLogging, - allowEnvironmentVariableDropping, allowUnencryptedScratch, allowCapabilityDropping) + allowEnvironmentVariableDropping, allowUnencryptedScratch, allowCapabilityDropping, + allowRegistryChangesDropping) default: return "", fmt.Errorf("unsupported OS type: %s", osType) @@ -125,6 +129,7 @@ func marshalWindowsRego( allowEnvironmentVariableDropping bool, allowUnencryptedScratch bool, allowCapabilityDropping bool, + allowRegistryChangesDropping bool, ) (string, error) { if allowAll { if len(containers) > 0 { @@ -149,6 +154,7 @@ func marshalWindowsRego( AllowEnvironmentVariableDropping: allowEnvironmentVariableDropping, AllowUnencryptedScratch: allowUnencryptedScratch, AllowCapabilityDropping: allowCapabilityDropping, + AllowRegistryChangesDropping: allowRegistryChangesDropping, } return policy.marshalWindowsRego(), nil @@ -167,6 +173,7 @@ func marshalJSON( _ bool, _ bool, _ bool, + _ bool, ) (string, error) { var policy *SecurityPolicy if allowAll { @@ -198,6 +205,7 @@ func marshalRego( allowEnvironmentVariableDropping bool, allowUnencryptedScratch bool, allowCapabilityDropping bool, + allowRegistryChangesDropping bool, ) (string, error) { if allowAll { if len(containers) > 0 { @@ -217,6 +225,7 @@ func marshalRego( allowEnvironmentVariableDropping, allowUnencryptedScratch, allowCapabilityDropping, + allowRegistryChangesDropping, ) if err != nil { return "", err @@ -251,6 +260,7 @@ func MarshalPolicy( allowEnvironmentVariableDropping bool, allowUnencryptedScratch bool, allowCapbilitiesDropping bool, + allowRegistryChangesDropping bool, ) (string, error) { if marshaller == "" { marshaller = defaultMarshaller @@ -272,6 +282,7 @@ func MarshalPolicy( allowEnvironmentVariableDropping, allowUnencryptedScratch, allowCapbilitiesDropping, + allowRegistryChangesDropping, ) } } @@ -621,6 +632,7 @@ func (p securityPolicyInternal) marshalRego() string { writeLine(builder, "allow_environment_variable_dropping := %t", p.AllowEnvironmentVariableDropping) writeLine(builder, "allow_unencrypted_scratch := %t", p.AllowUnencryptedScratch) writeLine(builder, "allow_capability_dropping := %t", p.AllowCapabilityDropping) + writeLine(builder, "allow_registry_changes_dropping := %t", p.AllowRegistryChangesDropping) result := strings.Replace(policyRegoTemplate, "@@OBJECTS@@", builder.String(), 1) result = strings.Replace(result, "@@API_VERSION@@", apiVersion, 1) result = strings.Replace(result, "@@FRAMEWORK_VERSION@@", frameworkVersion, 1) @@ -647,6 +659,7 @@ func (p securityPolicyWindowsInternal) marshalWindowsRego() string { writeLine(builder, "allow_environment_variable_dropping := %t", p.AllowEnvironmentVariableDropping) writeLine(builder, "allow_unencrypted_scratch := %t", p.AllowUnencryptedScratch) writeLine(builder, "allow_capability_dropping := %t", p.AllowCapabilityDropping) + writeLine(builder, "allow_registry_changes_dropping := %t", p.AllowRegistryChangesDropping) result := strings.Replace(policyRegoTemplate, "@@OBJECTS@@", builder.String(), 1) result = strings.Replace(result, "@@API_VERSION@@", apiVersion, 1) result = strings.Replace(result, "@@FRAMEWORK_VERSION@@", frameworkVersion, 1) diff --git a/test/pkg/securitypolicy/policy.go b/test/pkg/securitypolicy/policy.go index eeb93f67c8..7671ef9bea 100644 --- a/test/pkg/securitypolicy/policy.go +++ b/test/pkg/securitypolicy/policy.go @@ -64,6 +64,7 @@ func PolicyWithOpts(tb testing.TB, policyType string, pOpts ...securitypolicy.Po config.AllowEnvironmentVariableDropping, config.AllowUnencryptedScratch, config.AllowCapabilityDropping, + config.AllowRegistryChangesDropping, ) if err != nil { tb.Fatal(err) From 67a1a6c88a3c20664c92fbb6bfaedaa1a4c3378d Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Fri, 3 Jul 2026 16:04:54 +0100 Subject: [PATCH 30/56] Add registry_changes.add_values support to the Windows policy producer Signed-off-by: Takuro Sato --- pkg/securitypolicy/regopolicy_windows_test.go | 132 ++++++------------ pkg/securitypolicy/securitypolicy.go | 30 ++++ pkg/securitypolicy/securitypolicy_internal.go | 49 +++++++ pkg/securitypolicy/securitypolicy_marshal.go | 42 +++++- 4 files changed, 161 insertions(+), 92 deletions(-) diff --git a/pkg/securitypolicy/regopolicy_windows_test.go b/pkg/securitypolicy/regopolicy_windows_test.go index d326d14cd6..c73cc3de3d 100644 --- a/pkg/securitypolicy/regopolicy_windows_test.go +++ b/pkg/securitypolicy/regopolicy_windows_test.go @@ -1734,95 +1734,46 @@ func Test_Rego_EnforceRegistryChangesPolicy_Default_Values_Allowed_Windows(t *te } } -// regoTwoContainersSharedLayersRegistry is a hand-written policy with two -// containers that share the same layers/mounted_cim (so both survive -// mount_cims) but differ in command, and only B declares a registry_changes -// rule that sanctions a "dangerous" value. It is used to prove that -// registry_changes narrows data.metadata.matches so the decision composes with -// create_container regardless of enforcement order. -const regoTwoContainersSharedLayersRegistry = `package policy - -api_version := "%s" -framework_version := "%s" - -containers := [ - { - "allow_stdio_access": true, - "command": ["cmd"], - "env_rules": [], - "exec_processes": [], - "id": "test-image", - "layers": ["layerA", "layerB"], - "mounted_cim": ["merged"], - "mounts": [], - "name": "A", - "signals": [], - "user": "ContainerUser", - "working_dir": "C:\\app" - }, - { - "allow_stdio_access": true, - "command": ["ping"], - "env_rules": [], - "exec_processes": [], - "id": "test-image", - "layers": ["layerA", "layerB"], - "mounted_cim": ["merged"], - "mounts": [], - "name": "B", - "registry_changes": { - "add_values": [ - { - "key": {"hive": "System", "name": "TestControl"}, - "name": "Danger", - "type": "String", - "string_value": "danger" - } - ] - }, - "signals": [], - "user": "ContainerUser", - "working_dir": "C:\\app" - } -] - -fragments := [] -external_processes := [] -mapped_directories := [] - -allow_properties_access := false -allow_dump_stacks := false -allow_runtime_logging := false -allow_environment_variable_dropping := false -allow_unencrypted_scratch := false -allow_capability_dropping := false -allow_registry_changes_dropping := %t - -mount_device := data.framework.mount_device -rw_mount_device := data.framework.rw_mount_device -unmount_device := data.framework.unmount_device -rw_unmount_device := data.framework.rw_unmount_device -mount_overlay := data.framework.mount_overlay -unmount_overlay := data.framework.unmount_overlay -mount_cims := data.framework.mount_cims -registry_changes := data.framework.registry_changes -create_container := data.framework.create_container -exec_in_container := data.framework.exec_in_container -exec_external := data.framework.exec_external -shutdown_container := data.framework.shutdown_container -signal_container_process := data.framework.signal_container_process -plan9_mount := data.framework.plan9_mount -plan9_unmount := data.framework.plan9_unmount -get_properties := data.framework.get_properties -dump_stacks := data.framework.dump_stacks -runtime_logging := data.framework.runtime_logging -load_fragment := data.framework.load_fragment -scratch_mount := data.framework.scratch_mount -scratch_unmount := data.framework.scratch_unmount -mapped_directory_mount := data.framework.mapped_directory_mount -mapped_directory_unmount := data.framework.mapped_directory_unmount -reason := data.framework.reason -` +// twoContainersSharedLayersRegistryRego builds, via the Go policy producer, a +// policy with two containers that share the same layers/mounted_cim (so both +// survive mount_cims) but differ in command, where only B declares a +// registry_changes rule that sanctions a "dangerous" value. It is used to prove +// that registry_changes narrows data.metadata.matches so the decision composes +// with create_container regardless of enforcement order. +func twoContainersSharedLayersRegistryRego(dropping bool) string { + constraints := &generatedWindowsConstraints{ + allowRegistryChangesDropping: dropping, + containers: []*securityPolicyWindowsContainer{ + { + Command: []string{"cmd"}, + Layers: []string{"layerA", "layerB"}, + MountedCim: []string{"merged"}, + WorkingDir: `C:\app`, + User: "ContainerUser", + AllowStdioAccess: true, + }, + { + Command: []string{"ping"}, + Layers: []string{"layerA", "layerB"}, + MountedCim: []string{"merged"}, + WorkingDir: `C:\app`, + User: "ContainerUser", + AllowStdioAccess: true, + RegistryChanges: registryChangesInternal{ + AddValues: []registryValueInternal{ + { + Key: registryKeyInternal{Hive: "System", Name: "TestControl"}, + Name: "Danger", + Type: "String", + StringValue: "danger", + }, + }, + }, + }, + }, + } + return constraints.toPolicy().marshalWindowsRego() +} // Test_Rego_RegistryChanges_NarrowsMatches_Windows verifies that the registry // enforcement point narrows data.metadata.matches so it composes with @@ -1864,8 +1815,7 @@ func Test_Rego_RegistryChanges_NarrowsMatches_Windows(t *testing.T) { user := IDName{Name: "ContainerUser"} newPolicy := func(dropping bool) *regoEnforcer { - rego := fmt.Sprintf(regoTwoContainersSharedLayersRegistry, apiVersion, frameworkVersion, dropping) - policy, err := newRegoPolicy(rego, []oci.Mount{}, []oci.Mount{}, testOSType) + policy, err := newRegoPolicy(twoContainersSharedLayersRegistryRego(dropping), []oci.Mount{}, []oci.Mount{}, testOSType) if err != nil { t.Fatalf("failed to create policy: %v", err) } diff --git a/pkg/securitypolicy/securitypolicy.go b/pkg/securitypolicy/securitypolicy.go index 5a2dad04c2..d7e08a331e 100644 --- a/pkg/securitypolicy/securitypolicy.go +++ b/pkg/securitypolicy/securitypolicy.go @@ -317,12 +317,42 @@ type WindowsContainer struct { MountedCim []string `json:"mounted_cim"` WorkingDir string `json:"working_dir"` Mounts Mounts `json:"mounts"` + RegistryChanges WindowsRegistryChanges `json:"registry_changes"` ExecProcesses []WindowsExecProcessConfig `json:"-"` Signals []guestrequest.SignalValueWCOW `json:"-"` AllowStdioAccess bool `json:"-"` User string `json:"-"` } +// WindowsRegistryChanges is the set of registry changes a Windows container is +// allowed to make. Registry changes are a Windows-only concept. +type WindowsRegistryChanges struct { + AddValues []WindowsRegistryValue `json:"add_values"` +} + +// WindowsRegistryKey identifies the registry key that a registry value applies +// to. +type WindowsRegistryKey struct { + Hive string `json:"hive"` + Name string `json:"name"` + Volatile bool `json:"volatile"` +} + +// WindowsRegistryValue is a single registry value a container is allowed to +// write. Type selects which of the value fields is significant, mirroring the +// registry value types understood by the runtime ("String", "ExpandedString", +// "MultiString", "DWord", "QWord", "Binary", "CustomType", "None"). +type WindowsRegistryValue struct { + Key WindowsRegistryKey `json:"key"` + Name string `json:"name"` + Type string `json:"type"` + StringValue string `json:"string_value,omitempty"` + DWordValue int32 `json:"dword_value,omitempty"` + QWordValue int32 `json:"qword_value,omitempty"` + BinaryValue string `json:"binary_value,omitempty"` + CustomType int32 `json:"custom_type,omitempty"` +} + // StringArrayMap wraps an array of strings as a string map. type StringArrayMap struct { Length int `json:"length"` diff --git a/pkg/securitypolicy/securitypolicy_internal.go b/pkg/securitypolicy/securitypolicy_internal.go index fd4569bbac..3a4090c03a 100644 --- a/pkg/securitypolicy/securitypolicy_internal.go +++ b/pkg/securitypolicy/securitypolicy_internal.go @@ -197,6 +197,9 @@ type securityPolicyWindowsContainer struct { // The set of mount constraints that the container is allowed to be created // with. Matched against the OCI spec mounts at container creation time. Mounts []mountInternal `json:"mounts"` + // The set of registry changes the container is allowed to make. Matched + // against the registry changes requested at container creation time. + RegistryChanges registryChangesInternal `json:"registry_changes,omitempty"` // A list of lists of commands that can be used to execute additional // processes within the container ExecProcesses []windowsContainerExecProcess `json:"exec_processes"` @@ -236,6 +239,30 @@ type mountInternal struct { Options []string `json:"options"` } +// Internal version of WindowsRegistryChanges +type registryChangesInternal struct { + AddValues []registryValueInternal `json:"add_values"` +} + +// Internal version of WindowsRegistryKey +type registryKeyInternal struct { + Hive string `json:"hive"` + Name string `json:"name"` + Volatile bool `json:"volatile"` +} + +// Internal version of WindowsRegistryValue +type registryValueInternal struct { + Key registryKeyInternal `json:"key"` + Name string `json:"name"` + Type string `json:"type"` + StringValue string `json:"string_value,omitempty"` + DWordValue int32 `json:"dword_value,omitempty"` + QWordValue int32 `json:"qword_value,omitempty"` + BinaryValue string `json:"binary_value,omitempty"` + CustomType int32 `json:"custom_type,omitempty"` +} + // Internal version of Capabilities type capabilitiesInternal struct { Bounding []string @@ -335,6 +362,7 @@ func (c *WindowsContainer) toInternal() (*securityPolicyWindowsContainer, error) Layers: layers, WorkingDir: c.WorkingDir, Mounts: mounts, + RegistryChanges: c.RegistryChanges.toInternal(), ExecProcesses: execProcesses, Signals: c.Signals, AllowStdioAccess: c.AllowStdioAccess, @@ -342,6 +370,27 @@ func (c *WindowsContainer) toInternal() (*securityPolicyWindowsContainer, error) }, nil } +func (r WindowsRegistryChanges) toInternal() registryChangesInternal { + addValues := make([]registryValueInternal, len(r.AddValues)) + for i, v := range r.AddValues { + addValues[i] = registryValueInternal{ + Key: registryKeyInternal{ + Hive: v.Key.Hive, + Name: v.Key.Name, + Volatile: v.Key.Volatile, + }, + Name: v.Name, + Type: v.Type, + StringValue: v.StringValue, + DWordValue: v.DWordValue, + QWordValue: v.QWordValue, + BinaryValue: v.BinaryValue, + CustomType: v.CustomType, + } + } + return registryChangesInternal{AddValues: addValues} +} + func (c CommandArgs) toInternal() ([]string, error) { return stringMapToStringArray(c.Elements) } diff --git a/pkg/securitypolicy/securitypolicy_marshal.go b/pkg/securitypolicy/securitypolicy_marshal.go index febc3b3b8e..b8f6c7d2df 100644 --- a/pkg/securitypolicy/securitypolicy_marshal.go +++ b/pkg/securitypolicy/securitypolicy_marshal.go @@ -445,6 +445,43 @@ func writeMounts(builder *strings.Builder, mounts []mountInternal, indent string writeLine(builder, `%s"mounts": [%s],`, indent, strings.Join(values, ",")) } +func (v registryValueInternal) marshalRego() string { + key := fmt.Sprintf(`{"hive": "%s", "name": "%s", "volatile": %t}`, + escapeRegoString(v.Key.Hive), escapeRegoString(v.Key.Name), v.Key.Volatile) + fields := []string{ + fmt.Sprintf(`"key": %s`, key), + fmt.Sprintf(`"name": "%s"`, escapeRegoString(v.Name)), + fmt.Sprintf(`"type": "%s"`, escapeRegoString(v.Type)), + } + // Type selects which value field is significant; emit only that one so the + // policy value matches the shape registry_value_matches compares against. + switch v.Type { + case "String", "ExpandedString", "MultiString": + fields = append(fields, fmt.Sprintf(`"string_value": "%s"`, escapeRegoString(v.StringValue))) + case "DWord": + fields = append(fields, fmt.Sprintf(`"dword_value": %d`, v.DWordValue)) + case "QWord": + fields = append(fields, fmt.Sprintf(`"qword_value": %d`, v.QWordValue)) + case "Binary": + fields = append(fields, fmt.Sprintf(`"binary_value": "%s"`, escapeRegoString(v.BinaryValue))) + case "CustomType": + fields = append(fields, fmt.Sprintf(`"custom_type": %d`, v.CustomType)) + fields = append(fields, fmt.Sprintf(`"binary_value": "%s"`, escapeRegoString(v.BinaryValue))) + case "None": + // No value to compare, just key, name and type. + } + return fmt.Sprintf("{%s}", strings.Join(fields, ", ")) +} + +func writeRegistryChanges(builder *strings.Builder, registryChanges registryChangesInternal, indent string) { + values := make([]string, len(registryChanges.AddValues)) + for i, value := range registryChanges.AddValues { + values[i] = value.marshalRego() + } + + writeLine(builder, `%s"registry_changes": {"add_values": [%s]},`, indent, strings.Join(values, ", ")) +} + // Windows-specific marshal functions func writeWindowsSignals(builder *strings.Builder, signals []guestrequest.SignalValueWCOW, indent string) { signalsArray := make([]string, len(signals)) @@ -485,10 +522,13 @@ func writeWindowsContainer(builder *strings.Builder, container *securityPolicyWi writeLayers(builder, container.Layers, indent+indentUsing) writeMountedCim(builder, container.MountedCim, indent+indentUsing) writeMounts(builder, container.Mounts, indent+indentUsing) + if len(container.RegistryChanges.AddValues) > 0 { + writeRegistryChanges(builder, container.RegistryChanges, indent+indentUsing) + } writeWindowsExecProcesses(builder, container.ExecProcesses, indent+indentUsing) writeWindowsSignals(builder, container.Signals, indent+indentUsing) writeWindowsUser(builder, container.User, indent+indentUsing) - writeLine(builder, `%s"working_dir": "%s",`, indent+indentUsing, container.WorkingDir) + writeLine(builder, `%s"working_dir": "%s",`, indent+indentUsing, escapeRegoString(container.WorkingDir)) writeLine(builder, `%s"allow_stdio_access": %t,`, indent+indentUsing, container.AllowStdioAccess) writeLine(builder, "%s},", indent) } From c5eb50e8c62cffcd4d26a6e7d5000205d068e95b Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Tue, 7 Jul 2026 11:27:14 +0100 Subject: [PATCH 31/56] Add allow_registry_changes_dropping swtich Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 73 +++++----- pkg/securitypolicy/framework.rego | 107 ++++++++++----- pkg/securitypolicy/regopolicy_windows_test.go | 125 +++++++++++++++++- pkg/securitypolicy/securitypolicy.go | 3 +- pkg/securitypolicy/securitypolicy_internal.go | 13 +- pkg/securitypolicy/securitypolicy_marshal.go | 22 ++- pkg/securitypolicy/securitypolicyenforcer.go | 8 +- .../securitypolicyenforcer_rego.go | 21 ++- 8 files changed, 280 insertions(+), 92 deletions(-) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index c12f25a5ce..6444e894b9 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -83,25 +83,15 @@ func (b *Bridge) createContainer(req *request) (err error) { if container != nil && container.RegistryChanges != nil { log.G(ctx).Trace("Container has registry changes, validating against policy") - // First, separate default values from non-default values. - var defaultValues []hcsschema.RegistryValue - var nonDefaultValues []hcsschema.RegistryValue - for _, value := range container.RegistryChanges.AddValues { - if isDefaultRegistryValue(value) { - defaultValues = append(defaultValues, value) - log.G(ctx).WithField("name", value.Name).Trace("Registry value matches default, accepting without policy check") - } else { - nonDefaultValues = append(nonDefaultValues, value) - } - } + // Separate the pre-approved defaults from the changes that must be + // validated against policy (non-default add values plus all delete + // keys). + defaultValues, nonDefaultChanges := splitRegistryChanges(container.RegistryChanges) - // If there are non-default values, validate them against policy. - if len(nonDefaultValues) > 0 { - log.G(ctx).Tracef("Validating %d registry values against policy", len(nonDefaultValues)) - - nonDefaultChanges := &hcsschema.RegistryChanges{ - AddValues: nonDefaultValues, - } + // If there are non-default values or any delete keys, validate them + // against policy. + if len(nonDefaultChanges.AddValues) > 0 || len(nonDefaultChanges.DeleteKeys) > 0 { + log.G(ctx).Tracef("Validating %d registry values and %d delete keys against policy", len(nonDefaultChanges.AddValues), len(nonDefaultChanges.DeleteKeys)) keptRaw, err := b.hostState.securityOptions.PolicyEnforcer.EnforceRegistryChangesPolicy(ctx, containerID, nonDefaultChanges) if err != nil { @@ -110,15 +100,16 @@ func (b *Bridge) createContainer(req *request) (err error) { } // The policy uses dropping semantics: it may authorize only a - // subset of the requested non-default values. Rebuild the - // container's registry changes as the pre-approved defaults plus - // the policy-kept non-default values so the guest only applies - // what policy sanctioned. - container.RegistryChanges.AddValues = mergeKeptRegistryValues(defaultValues, keptRaw) + // subset of the requested non-default values and delete keys. + // Rebuild the container's registry changes as the pre-approved + // defaults plus the policy-kept non-default values, and the + // policy-kept delete keys, so the guest only applies what policy + // sanctioned. + container.RegistryChanges.AddValues, container.RegistryChanges.DeleteKeys = mergeKeptRegistryChanges(defaultValues, keptRaw) } - log.G(ctx).Infof("Registry validation complete: %d total values now applied (%d defaults)", - len(container.RegistryChanges.AddValues), len(defaultValues)) + log.G(ctx).Infof("Registry validation complete: %d total values now applied (%d defaults), %d delete keys", + len(container.RegistryChanges.AddValues), len(defaultValues), len(container.RegistryChanges.DeleteKeys)) } // We enforce `spec`, which is not passed to inbox gcs within this createContainer. @@ -343,21 +334,43 @@ func (b *Bridge) createContainer(req *request) (err error) { return nil } -// mergeKeptRegistryValues combines the pre-approved default registry values +// splitRegistryChanges separates a container's requested registry changes into +// the pre-approved default add values (which bypass policy) and the changes +// that must be validated against policy: the non-default add values plus all +// delete keys, which have no default allowance. +func splitRegistryChanges(changes *hcsschema.RegistryChanges) (defaultValues []hcsschema.RegistryValue, nonDefaultChanges *hcsschema.RegistryChanges) { + var nonDefaultValues []hcsschema.RegistryValue + for _, value := range changes.AddValues { + if isDefaultRegistryValue(value) { + defaultValues = append(defaultValues, value) + } else { + nonDefaultValues = append(nonDefaultValues, value) + } + } + return defaultValues, &hcsschema.RegistryChanges{ + AddValues: nonDefaultValues, + DeleteKeys: changes.DeleteKeys, + } +} + +// mergeKeptRegistryChanges combines the pre-approved default registry values // with the policy-kept subset returned by EnforceRegistryChangesPolicy. Because // the policy uses dropping semantics, it may authorize only a subset of the -// requested non-default values; the returned slice is what the guest should -// apply (defaults plus the kept non-default values). -func mergeKeptRegistryValues(defaultValues []hcsschema.RegistryValue, kept interface{}) []hcsschema.RegistryValue { +// requested non-default values and delete keys; the returned slices are what +// the guest should apply (defaults plus the kept non-default values, and the +// kept delete keys). +func mergeKeptRegistryChanges(defaultValues []hcsschema.RegistryValue, kept interface{}) ([]hcsschema.RegistryValue, []hcsschema.RegistryKey) { var keptNonDefault []hcsschema.RegistryValue + var keptDeleteKeys []hcsschema.RegistryKey if k, ok := kept.(*hcsschema.RegistryChanges); ok && k != nil { keptNonDefault = k.AddValues + keptDeleteKeys = k.DeleteKeys } newValues := make([]hcsschema.RegistryValue, 0, len(defaultValues)+len(keptNonDefault)) newValues = append(newValues, defaultValues...) newValues = append(newValues, keptNonDefault...) - return newValues + return newValues, keptDeleteKeys } // namedPipePrefix is the prefix used for Windows named pipe paths. A mount diff --git a/pkg/securitypolicy/framework.rego b/pkg/securitypolicy/framework.rego index 4f4e805671..c49a097354 100644 --- a/pkg/securitypolicy/framework.rego +++ b/pkg/securitypolicy/framework.rego @@ -1498,24 +1498,50 @@ registry_value_matches(policy_value, input_value) { policy_value.type == "None" } -# valid_registry_subset is the set of requested registry values that the -# container's policy authorizes. -valid_registry_subset(container) := values { - values := {input_value | +# requested_registry_changes is the tagged set of all requested registry +# changes: each requested add value and each requested delete key, tagged by +# kind so the add and delete cases share the same dropping/narrowing machinery. +requested_registry_changes := changes { + adds := {{"kind": "add", "value": input_value} | some input_value in input.registryChanges.AddValues - some policy_value in container.registry_changes.add_values - registry_value_matches(policy_value, input_value) + } + deletes := {{"kind": "delete", "key": input_key} | + some input_key in input.registryChanges.DeleteKeys + } + changes := adds | deletes +} + +# registry_change_authorized holds when the container's registry_changes policy +# authorizes the requested change (an add value or a delete key). +registry_change_authorized(container, change) { + change.kind == "add" + some policy_value in container.registry_changes.add_values + registry_value_matches(policy_value, change.value) +} + +registry_change_authorized(container, change) { + change.kind == "delete" + some policy_key in container.registry_changes.delete_keys + registry_keys_match(policy_key, change.key) +} + +# valid_registry_subset is the set of requested registry changes that the +# container's policy authorizes. +valid_registry_subset(container) := changes { + changes := {change | + some change in requested_registry_changes + registry_change_authorized(container, change) } } -# valid_registry_for_all selects the registry values to keep across the +# valid_registry_for_all selects the registry changes to keep across the # candidate containers, mirroring valid_envs_for_all. With # allow_registry_changes_dropping it keeps the most specific (largest) # authorized subset, dropping the rest; if several containers tie for the # largest subset they must authorize the same set (intersection == union) for -# the result to be decidable. Without dropping it keeps every requested value, +# the result to be decidable. Without dropping it keeps every requested change, # so a container must authorize all of them for the request to be allowed. -valid_registry_for_all(containers) := values { +valid_registry_for_all(containers) := changes { allow_registry_changes_dropping valid := [subset | @@ -1526,55 +1552,64 @@ valid_registry_for_all(containers) := values { counts := [count(subset) | subset := valid[_]] max_count := max(counts) - largest_value_sets := {subset | + largest_change_sets := {subset | some i counts[i] == max_count subset := valid[i] } - values_i := intersection(largest_value_sets) - values_u := union(largest_value_sets) - values_i == values_u - values := values_i + changes_i := intersection(largest_change_sets) + changes_u := union(largest_change_sets) + changes_i == changes_u + changes := changes_i } -valid_registry_for_all(containers) := values { +valid_registry_for_all(containers) := changes { not allow_registry_changes_dropping - # no dropping: keep every requested value, so a container must authorize all - values := {input_value | some input_value in input.registryChanges.AddValues} + # no dropping: keep every requested change, so a container must authorize all + changes := requested_registry_changes } -# registryValues_ok holds when the container's registry_changes policy -# authorizes every value in registryValues. This mirrors envList_ok. Note we -# pass the whole container rather than container.registry_changes because that -# field is optional: for empty registryValues the `every` is vacuously true, so -# a container with no registry_changes correctly matches the "drop everything" -# case (and the missing field is never dereferenced). -registryValues_ok(container, registryValues) { - every input_value in registryValues { - some policy_value in container.registry_changes.add_values - registry_value_matches(policy_value, input_value) +# registryChanges_ok holds when the container authorizes every change in +# `changes`. +registryChanges_ok(container, changes) { + every change in changes { + registry_change_authorized(container, change) } } -# registry_changes keeps the registry values that policy authorizes (via -# valid_registry_for_all, which honors allow_registry_changes_dropping), narrows -# matches to the container(s) that authorize exactly that set, and returns those -# values (as registry_changes_to_keep) so the host-side enforcer applies only -# them. Recording the narrowing into data.metadata.matches makes the decision -# compose with create_container regardless of the order in which the two run. -registry_changes := {"metadata": [updateMatches], "registry_changes_to_keep": registry_values, "allowed": true} { +# registry_changes decides whether the requested registry changes are allowed, +# returning the add values and delete keys to keep (add_values_to_keep / +# delete_keys_to_keep). It also narrows the matched containers so the decision +# stays consistent with create_container whichever order the two run in. +registry_changes := { + "metadata": [updateMatches], + "add_values_to_keep": add_values, + "delete_keys_to_keep": delete_keys, + "allowed": true, +} { matches := data.metadata.matches[input.containerID] - registry_values := valid_registry_for_all(matches) + + # honors allow_registry_changes_dropping + kept := valid_registry_for_all(matches) containers := [container | container := matches[_] - registryValues_ok(container, registry_values) + registryChanges_ok(container, kept) ] count(containers) > 0 + add_values := [change.value | + some change in kept + change.kind == "add" + ] + delete_keys := [change.key | + some change in kept + change.kind == "delete" + ] + updateMatches := { "name": "matches", "action": "update", diff --git a/pkg/securitypolicy/regopolicy_windows_test.go b/pkg/securitypolicy/regopolicy_windows_test.go index c73cc3de3d..646b7c5811 100644 --- a/pkg/securitypolicy/regopolicy_windows_test.go +++ b/pkg/securitypolicy/regopolicy_windows_test.go @@ -1734,12 +1734,6 @@ func Test_Rego_EnforceRegistryChangesPolicy_Default_Values_Allowed_Windows(t *te } } -// twoContainersSharedLayersRegistryRego builds, via the Go policy producer, a -// policy with two containers that share the same layers/mounted_cim (so both -// survive mount_cims) but differ in command, where only B declares a -// registry_changes rule that sanctions a "dangerous" value. It is used to prove -// that registry_changes narrows data.metadata.matches so the decision composes -// with create_container regardless of enforcement order. func twoContainersSharedLayersRegistryRego(dropping bool) string { constraints := &generatedWindowsConstraints{ allowRegistryChangesDropping: dropping, @@ -1768,6 +1762,9 @@ func twoContainersSharedLayersRegistryRego(dropping bool) string { StringValue: "danger", }, }, + DeleteKeys: []registryKeyInternal{ + {Hive: "System", Name: "TestControl\\Obsolete"}, + }, }, }, }, @@ -1921,6 +1918,122 @@ func Test_Rego_RegistryChanges_NarrowsMatches_Windows(t *testing.T) { }) } +// Test_Rego_RegistryChanges_DeleteKeys_Windows verifies that delete keys flow +// through the same narrowing/dropping machinery as add values. Container B +// authorizes deleting a specific key; container A authorizes nothing. With +// dropping on, deleting B's authorized key narrows matches to B (so create(cmd) +// is then denied) or, if create(cmd) narrows to A first, the delete is dropped. +// With dropping off, the delete is only allowed against a container (B) that +// authorizes it. +// TODO: maybe delete it if it's too much. +func Test_Rego_RegistryChanges_DeleteKeys_Windows(t *testing.T) { + deleteRequest := &hcsschema.RegistryChanges{ + DeleteKeys: []hcsschema.RegistryKey{ + {Hive: "System", Name: "TestControl\\Obsolete"}, + }, + } + + keptDeleteCount := func(t *testing.T, keptRaw interface{}) int { + t.Helper() + kept, ok := keptRaw.(*hcsschema.RegistryChanges) + if !ok || kept == nil { + t.Fatalf("expected *hcsschema.RegistryChanges, got %T", keptRaw) + } + return len(kept.DeleteKeys) + } + + // mount_cims reverses the layer order, so pass layers reversed. + layerHashes := []string{"layerB", "layerA"} + mountedCim := []string{"merged"} + ctx := context.Background() + user := IDName{Name: "ContainerUser"} + + newPolicy := func(dropping bool) *regoEnforcer { + policy, err := newRegoPolicy(twoContainersSharedLayersRegistryRego(dropping), []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("failed to create policy: %v", err) + } + return policy + } + + // Order 1: delete (kept via B) then create with A's command. The delete + // narrows matches to [B], so create with "cmd" must be denied. + t.Run("registry_delete_then_create_denied", func(t *testing.T) { + policy := newPolicy(true) + cid := testDataGenerator.uniqueContainerID() + if err := policy.EnforceVerifiedCIMsPolicy(ctx, cid, layerHashes, mountedCim); err != nil { + t.Fatalf("mount_cims: %v", err) + } + kept, err := policy.EnforceRegistryChangesPolicy(ctx, cid, deleteRequest) + if err != nil { + t.Fatalf("registry delete should be allowed (kept via B): %v", err) + } + if n := keptDeleteCount(t, kept); n != 1 { + t.Errorf("expected the delete key kept via B, got %d kept keys", n) + } + _, _, _, err = policy.EnforceCreateContainerPolicyV2(ctx, cid, []string{"cmd"}, []string{}, `C:\app`, []oci.Mount{}, user, nil) + if err == nil { + t.Error("create(cmd) after registry(delete) should be denied: registry narrowed matches to B (command ping)") + } + }) + + // Order 2: create with A's command (narrows to [A]) then delete. A has no + // registry rule, so the delete must be dropped (kept empty), but the request + // is still allowed since dropping is permissive. + t.Run("create_then_registry_delete_drops", func(t *testing.T) { + policy := newPolicy(true) + cid := testDataGenerator.uniqueContainerID() + if err := policy.EnforceVerifiedCIMsPolicy(ctx, cid, layerHashes, mountedCim); err != nil { + t.Fatalf("mount_cims: %v", err) + } + if _, _, _, err := policy.EnforceCreateContainerPolicyV2(ctx, cid, []string{"cmd"}, []string{}, `C:\app`, []oci.Mount{}, user, nil); err != nil { + t.Fatalf("create(cmd) should be allowed as A: %v", err) + } + kept, err := policy.EnforceRegistryChangesPolicy(ctx, cid, deleteRequest) + if err != nil { + t.Fatalf("registry delete after create(cmd) should be allowed with dropping: %v", err) + } + if n := keptDeleteCount(t, kept); n != 0 { + t.Errorf("delete key should be dropped for A, got %d kept keys", n) + } + }) + + // With dropping disabled, the delete is only allowed against a container + // that authorizes it. After create(cmd) narrows to A, the delete is denied; + // the legit path via B keeps it. + t.Run("no_dropping_create_then_delete_denied", func(t *testing.T) { + policy := newPolicy(false) + cid := testDataGenerator.uniqueContainerID() + if err := policy.EnforceVerifiedCIMsPolicy(ctx, cid, layerHashes, mountedCim); err != nil { + t.Fatalf("mount_cims: %v", err) + } + if _, _, _, err := policy.EnforceCreateContainerPolicyV2(ctx, cid, []string{"cmd"}, []string{}, `C:\app`, []oci.Mount{}, user, nil); err != nil { + t.Fatalf("create(cmd) should be allowed as A: %v", err) + } + if _, err := policy.EnforceRegistryChangesPolicy(ctx, cid, deleteRequest); err == nil { + t.Error("registry(delete) after create(cmd) should be denied without dropping (A authorizes nothing)") + } + }) + + t.Run("no_dropping_create_ping_then_delete_keeps", func(t *testing.T) { + policy := newPolicy(false) + cid := testDataGenerator.uniqueContainerID() + if err := policy.EnforceVerifiedCIMsPolicy(ctx, cid, layerHashes, mountedCim); err != nil { + t.Fatalf("mount_cims: %v", err) + } + if _, _, _, err := policy.EnforceCreateContainerPolicyV2(ctx, cid, []string{"ping"}, []string{}, `C:\app`, []oci.Mount{}, user, nil); err != nil { + t.Fatalf("create(ping) should be allowed as B: %v", err) + } + kept, err := policy.EnforceRegistryChangesPolicy(ctx, cid, deleteRequest) + if err != nil { + t.Fatalf("registry(delete) after create(ping) should be allowed via B: %v", err) + } + if n := keptDeleteCount(t, kept); n != 1 { + t.Errorf("delete key should be kept for B, got %d kept keys", n) + } + }) +} + // This is a no-op for windows. // substituteUVMPath substitutes mount prefix to an appropriate path inside // UVM. At policy generation time, it's impossible to tell what the sandboxID diff --git a/pkg/securitypolicy/securitypolicy.go b/pkg/securitypolicy/securitypolicy.go index d7e08a331e..ff801b30d3 100644 --- a/pkg/securitypolicy/securitypolicy.go +++ b/pkg/securitypolicy/securitypolicy.go @@ -327,7 +327,8 @@ type WindowsContainer struct { // WindowsRegistryChanges is the set of registry changes a Windows container is // allowed to make. Registry changes are a Windows-only concept. type WindowsRegistryChanges struct { - AddValues []WindowsRegistryValue `json:"add_values"` + AddValues []WindowsRegistryValue `json:"add_values"` + DeleteKeys []WindowsRegistryKey `json:"delete_keys"` } // WindowsRegistryKey identifies the registry key that a registry value applies diff --git a/pkg/securitypolicy/securitypolicy_internal.go b/pkg/securitypolicy/securitypolicy_internal.go index 3a4090c03a..5bb7f775a5 100644 --- a/pkg/securitypolicy/securitypolicy_internal.go +++ b/pkg/securitypolicy/securitypolicy_internal.go @@ -241,7 +241,8 @@ type mountInternal struct { // Internal version of WindowsRegistryChanges type registryChangesInternal struct { - AddValues []registryValueInternal `json:"add_values"` + AddValues []registryValueInternal `json:"add_values"` + DeleteKeys []registryKeyInternal `json:"delete_keys"` } // Internal version of WindowsRegistryKey @@ -388,7 +389,15 @@ func (r WindowsRegistryChanges) toInternal() registryChangesInternal { CustomType: v.CustomType, } } - return registryChangesInternal{AddValues: addValues} + deleteKeys := make([]registryKeyInternal, len(r.DeleteKeys)) + for i, k := range r.DeleteKeys { + deleteKeys[i] = registryKeyInternal{ + Hive: k.Hive, + Name: k.Name, + Volatile: k.Volatile, + } + } + return registryChangesInternal{AddValues: addValues, DeleteKeys: deleteKeys} } func (c CommandArgs) toInternal() ([]string, error) { diff --git a/pkg/securitypolicy/securitypolicy_marshal.go b/pkg/securitypolicy/securitypolicy_marshal.go index b8f6c7d2df..9d623e15d6 100644 --- a/pkg/securitypolicy/securitypolicy_marshal.go +++ b/pkg/securitypolicy/securitypolicy_marshal.go @@ -445,11 +445,14 @@ func writeMounts(builder *strings.Builder, mounts []mountInternal, indent string writeLine(builder, `%s"mounts": [%s],`, indent, strings.Join(values, ",")) } +func (k registryKeyInternal) marshalRego() string { + return fmt.Sprintf(`{"hive": "%s", "name": "%s", "volatile": %t}`, + escapeRegoString(k.Hive), escapeRegoString(k.Name), k.Volatile) +} + func (v registryValueInternal) marshalRego() string { - key := fmt.Sprintf(`{"hive": "%s", "name": "%s", "volatile": %t}`, - escapeRegoString(v.Key.Hive), escapeRegoString(v.Key.Name), v.Key.Volatile) fields := []string{ - fmt.Sprintf(`"key": %s`, key), + fmt.Sprintf(`"key": %s`, v.Key.marshalRego()), fmt.Sprintf(`"name": "%s"`, escapeRegoString(v.Name)), fmt.Sprintf(`"type": "%s"`, escapeRegoString(v.Type)), } @@ -474,12 +477,17 @@ func (v registryValueInternal) marshalRego() string { } func writeRegistryChanges(builder *strings.Builder, registryChanges registryChangesInternal, indent string) { - values := make([]string, len(registryChanges.AddValues)) + addValues := make([]string, len(registryChanges.AddValues)) for i, value := range registryChanges.AddValues { - values[i] = value.marshalRego() + addValues[i] = value.marshalRego() + } + deleteKeys := make([]string, len(registryChanges.DeleteKeys)) + for i, key := range registryChanges.DeleteKeys { + deleteKeys[i] = key.marshalRego() } - writeLine(builder, `%s"registry_changes": {"add_values": [%s]},`, indent, strings.Join(values, ", ")) + writeLine(builder, `%s"registry_changes": {"add_values": [%s], "delete_keys": [%s]},`, + indent, strings.Join(addValues, ", "), strings.Join(deleteKeys, ", ")) } // Windows-specific marshal functions @@ -522,7 +530,7 @@ func writeWindowsContainer(builder *strings.Builder, container *securityPolicyWi writeLayers(builder, container.Layers, indent+indentUsing) writeMountedCim(builder, container.MountedCim, indent+indentUsing) writeMounts(builder, container.Mounts, indent+indentUsing) - if len(container.RegistryChanges.AddValues) > 0 { + if len(container.RegistryChanges.AddValues) > 0 || len(container.RegistryChanges.DeleteKeys) > 0 { writeRegistryChanges(builder, container.RegistryChanges, indent+indentUsing) } writeWindowsExecProcesses(builder, container.ExecProcesses, indent+indentUsing) diff --git a/pkg/securitypolicy/securitypolicyenforcer.go b/pkg/securitypolicy/securitypolicyenforcer.go index a469c83576..fe8b4717fa 100644 --- a/pkg/securitypolicy/securitypolicyenforcer.go +++ b/pkg/securitypolicy/securitypolicyenforcer.go @@ -129,7 +129,7 @@ type SecurityPolicyEnforcer interface { EnforceMappedDirectoryUnmountPolicy(ctx context.Context, containerPath string) (err error) GetUserInfo(spec *oci.Process, rootPath string) (IDName, []IDName, string, error) EnforceVerifiedCIMsPolicy(ctx context.Context, containerID string, layerHashes []string, mountedCim []string) (err error) - EnforceRegistryChangesPolicy(ctx context.Context, containerID string, registryValues interface{}) (interface{}, error) + EnforceRegistryChangesPolicy(ctx context.Context, containerID string, registryChanges interface{}) (interface{}, error) } //nolint:unused @@ -330,8 +330,8 @@ func (OpenDoorSecurityPolicyEnforcer) EnforceVerifiedCIMsPolicy(ctx context.Cont return nil } -func (OpenDoorSecurityPolicyEnforcer) EnforceRegistryChangesPolicy(ctx context.Context, containerID string, registryValues interface{}) (interface{}, error) { - return registryValues, nil +func (OpenDoorSecurityPolicyEnforcer) EnforceRegistryChangesPolicy(ctx context.Context, containerID string, registryChanges interface{}) (interface{}, error) { + return registryChanges, nil } type ClosedDoorSecurityPolicyEnforcer struct{} @@ -467,6 +467,6 @@ func (ClosedDoorSecurityPolicyEnforcer) EnforceVerifiedCIMsPolicy(ctx context.Co return nil } -func (ClosedDoorSecurityPolicyEnforcer) EnforceRegistryChangesPolicy(ctx context.Context, containerID string, registryValues interface{}) (interface{}, error) { +func (ClosedDoorSecurityPolicyEnforcer) EnforceRegistryChangesPolicy(ctx context.Context, containerID string, registryChanges interface{}) (interface{}, error) { return nil, errors.New("registry changes are denied by policy") } diff --git a/pkg/securitypolicy/securitypolicyenforcer_rego.go b/pkg/securitypolicy/securitypolicyenforcer_rego.go index d713a009d6..140da1e730 100644 --- a/pkg/securitypolicy/securitypolicyenforcer_rego.go +++ b/pkg/securitypolicy/securitypolicyenforcer_rego.go @@ -1187,11 +1187,11 @@ func (policy *regoEnforcer) EnforceVerifiedCIMsPolicy(ctx context.Context, conta return err } -func (policy *regoEnforcer) EnforceRegistryChangesPolicy(ctx context.Context, containerID string, registryValues interface{}) (interface{}, error) { +func (policy *regoEnforcer) EnforceRegistryChangesPolicy(ctx context.Context, containerID string, registryChanges interface{}) (interface{}, error) { log.G(ctx).Trace("Enforcing registry changes policy") // Import the schema type for proper conversion - regChanges, ok := registryValues.(*hcsschema.RegistryChanges) + regChanges, ok := registryChanges.(*hcsschema.RegistryChanges) if !ok { log.G(ctx).Warn("Input registry values are not of expected type") return nil, errors.New("invalid registry values type") @@ -1208,11 +1208,11 @@ func (policy *regoEnforcer) EnforceRegistryChangesPolicy(ctx context.Context, co } // The policy uses dropping semantics: it authorizes a subset of the - // requested values and returns that subset in "registry_changes_to_keep". - // Round-trip it back into the schema type so the caller applies only the - // kept values. + // requested changes and returns the kept add values and delete keys in + // "add_values_to_keep" / "delete_keys_to_keep". Round-trip them back into + // the schema type so the caller applies only the kept changes. kept := &hcsschema.RegistryChanges{} - if raw, verr := result.Value("registry_changes_to_keep"); verr == nil && raw != nil { + if raw, verr := result.Value("add_values_to_keep"); verr == nil && raw != nil { buf, merr := json.Marshal(raw) if merr != nil { return nil, fmt.Errorf("failed to marshal kept registry values: %w", merr) @@ -1221,6 +1221,15 @@ func (policy *regoEnforcer) EnforceRegistryChangesPolicy(ctx context.Context, co return nil, fmt.Errorf("failed to unmarshal kept registry values: %w", uerr) } } + if raw, verr := result.Value("delete_keys_to_keep"); verr == nil && raw != nil { + buf, merr := json.Marshal(raw) + if merr != nil { + return nil, fmt.Errorf("failed to marshal kept registry delete keys: %w", merr) + } + if uerr := json.Unmarshal(buf, &kept.DeleteKeys); uerr != nil { + return nil, fmt.Errorf("failed to unmarshal kept registry delete keys: %w", uerr) + } + } return kept, nil } From 543d7db2a46a075f7b63fcbeb406089b313a64e0 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Tue, 7 Jul 2026 13:08:14 +0100 Subject: [PATCH 32/56] Add unmount_cim policy Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 23 ++-- pkg/securitypolicy/api.rego | 1 + pkg/securitypolicy/framework.rego | 37 +++++- pkg/securitypolicy/open_door.rego | 1 + pkg/securitypolicy/policy.rego | 1 + pkg/securitypolicy/rego_utils_test.go | 6 +- pkg/securitypolicy/regopolicy_windows_test.go | 113 ++++++++++++++++-- pkg/securitypolicy/securitypolicyenforcer.go | 15 ++- .../securitypolicyenforcer_rego.go | 13 +- 9 files changed, 187 insertions(+), 23 deletions(-) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index 6444e894b9..ad0d39018b 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -1213,14 +1213,14 @@ func (b *Bridge) modifySettings(req *request) (err error) { hashesToVerify = layerHashes[1:] } - err := b.hostState.securityOptions.PolicyEnforcer.EnforceVerifiedCIMsPolicy(req.ctx, containerID, hashesToVerify, mountedCim) + // Volume GUID from request. + volGUID := wcowBlockCimMounts.VolumeGUID + + err := b.hostState.securityOptions.PolicyEnforcer.EnforceVerifiedCIMsPolicy(req.ctx, containerID, hashesToVerify, mountedCim, volGUID.String()) if err != nil { return errors.Wrap(err, "CIM mount is denied by policy") } - // Volume GUID from request - volGUID := wcowBlockCimMounts.VolumeGUID - // Cache hashes along with volGUID b.hostState.blockCIMVolumeHashes[volGUID] = layerHashes @@ -1247,12 +1247,21 @@ func (b *Bridge) modifySettings(req *request) (err error) { case guestrequest.RequestTypeRemove: log.G(ctx).Tracef("WCOWBlockCIMMounts: Remove") wcowBlockCimMounts := modifyGuestSettingsRequest.Settings.(*guestresource.CWCOWBlockCIMMounts) - volumePath := fmt.Sprintf(cimfs.VolumePathFormat, wcowBlockCimMounts.VolumeGUID.String()) - err := cimfs.Unmount(volumePath) + volGUID := wcowBlockCimMounts.VolumeGUID + + if err := b.hostState.securityOptions.PolicyEnforcer.EnforceCIMUnmountPolicy(req.ctx, volGUID.String()); err != nil { + return fmt.Errorf("CIM unmount is denied by policy: %w", err) + } + volumePath := fmt.Sprintf(cimfs.VolumePathFormat, volGUID.String()) + err := cimfs.Unmount(volumePath) if err != nil { return fmt.Errorf("error unmounting block cim: %w", err) } + + // Drop the cached mount state now that the volume is gone. + delete(b.hostState.blockCIMVolumeHashes, volGUID) + delete(b.hostState.blockCIMVolumeContainers, volGUID) } // Send response back to shim resp := &prot.ResponseBase{ @@ -1361,7 +1370,7 @@ func (b *Bridge) modifySettings(req *request) (err error) { if len(hashes) > 1 { hashesToVerify = hashes[1:] } - if err := b.hostState.securityOptions.PolicyEnforcer.EnforceVerifiedCIMsPolicy(ctx, containerID, hashesToVerify, mountedCim); err != nil { + if err := b.hostState.securityOptions.PolicyEnforcer.EnforceVerifiedCIMsPolicy(ctx, containerID, hashesToVerify, mountedCim, volGUID.String()); err != nil { return fmt.Errorf("CIM mount is denied by policy for this container: %w", err) } log.G(ctx).Tracef("Verified CIM hashes for reused mount volume %s (container %s)", volGUID.String(), containerID) diff --git a/pkg/securitypolicy/api.rego b/pkg/securitypolicy/api.rego index 3b89a6d139..13a5d0cb70 100644 --- a/pkg/securitypolicy/api.rego +++ b/pkg/securitypolicy/api.rego @@ -26,4 +26,5 @@ enforcement_points := { "scratch_unmount": {"introducedVersion": "0.10.0", "default_results": {"allowed": true}, "use_framework": false}, "mapped_directory_mount": {"introducedVersion": "0.11.0", "default_results": {"allowed": true}, "use_framework": false}, "mapped_directory_unmount": {"introducedVersion": "0.11.0", "default_results": {"allowed": true}, "use_framework": false}, + "unmount_cims": {"introducedVersion": "0.11.0", "default_results": {"allowed": true}, "use_framework": false}, } diff --git a/pkg/securitypolicy/framework.rego b/pkg/securitypolicy/framework.rego index c49a097354..346838947b 100644 --- a/pkg/securitypolicy/framework.rego +++ b/pkg/securitypolicy/framework.rego @@ -168,7 +168,7 @@ candidate_containers := containers { default mount_cims := {"allowed": false} -mount_cims := {"metadata": [addMatches], "allowed": true} { +mount_cims := {"metadata": [addMatches, addCimVolume], "allowed": true} { not overlay_exists containers := [container | @@ -184,6 +184,36 @@ mount_cims := {"metadata": [addMatches], "allowed": true} { "key": input.containerID, "value": containers, } + + # Record the host-minted volume GUID (a runtime handle, not a policy-authored + # value) so unmount_cims can require it later. A single block CIM volume is + # shared by every container from the same image and is mounted/unmounted once + # for its whole lifetime: the host physically mounts it once, then re-drives + # this rule per container that reuses it, and unmounts it once when the last + # reference is gone (per-container teardown does not touch the CIM). "update" + # keeps those repeated mounts of the same volume idempotent (one record), so + # the matching single unmount stays symmetric. + addCimVolume := { + "name": "mountedCimVolumes", + "action": "update", + "key": input.volumeGUID, + "value": true, + } +} + +cim_volume_mounted(volumeGUID) { + data.metadata.mountedCimVolumes[volumeGUID] +} + +default unmount_cims := {"allowed": false} + +unmount_cims := {"metadata": [removeCimVolume], "allowed": true} { + cim_volume_mounted(input.volumeGUID) + removeCimVolume := { + "name": "mountedCimVolumes", + "action": "remove", + "key": input.volumeGUID, + } } default mount_overlay := {"allowed": false} @@ -2042,6 +2072,11 @@ errors["no scratch at path to unmount"] { not scratch_mounted(input.unmountTarget) } +errors["no CIM volume at GUID to unmount"] { + input.rule == "unmount_cims" + not cim_volume_mounted(input.volumeGUID) +} + errors["mapped directory already mounted at path"] { input.rule == "mapped_directory_mount" mapped_directory_mounted(input.containerPath) diff --git a/pkg/securitypolicy/open_door.rego b/pkg/securitypolicy/open_door.rego index 44e89499e9..e9acc46c84 100644 --- a/pkg/securitypolicy/open_door.rego +++ b/pkg/securitypolicy/open_door.rego @@ -25,3 +25,4 @@ scratch_mount := {"allowed": true} scratch_unmount := {"allowed": true} mapped_directory_mount := {"allowed": true} mapped_directory_unmount := {"allowed": true} +unmount_cims := {"allowed": true} diff --git a/pkg/securitypolicy/policy.rego b/pkg/securitypolicy/policy.rego index f8336280b5..bac4d3b2df 100644 --- a/pkg/securitypolicy/policy.rego +++ b/pkg/securitypolicy/policy.rego @@ -28,4 +28,5 @@ scratch_mount := data.framework.scratch_mount scratch_unmount := data.framework.scratch_unmount mapped_directory_mount := data.framework.mapped_directory_mount mapped_directory_unmount := data.framework.mapped_directory_unmount +unmount_cims := data.framework.unmount_cims reason := data.framework.reason diff --git a/pkg/securitypolicy/rego_utils_test.go b/pkg/securitypolicy/rego_utils_test.go index 7246fe8b46..225a75aa39 100644 --- a/pkg/securitypolicy/rego_utils_test.go +++ b/pkg/securitypolicy/rego_utils_test.go @@ -2179,6 +2179,10 @@ func setupRegoCreateContainerTestWindows(gc *generatedWindowsConstraints, testCo }, nil } +// testCIMVolumeGUID is a placeholder volume GUID for tests that mount a CIM but +// don't exercise unmount; the unmount tests use their own GUIDs. +const testCIMVolumeGUID = "test-cim-volume-guid" + //nolint:unused func mountImageForWindowsContainer(policy *regoEnforcer, container *securityPolicyWindowsContainer) (string, error) { ctx := context.Background() @@ -2194,7 +2198,7 @@ func mountImageForWindowsContainer(policy *regoEnforcer, container *securityPoli // Mount the CIMFS for the Windows container // layerHashes are the individual layer hashes, mountedCim is the merged CIM from the policy - err := policy.EnforceVerifiedCIMsPolicy(ctx, containerID, layerHashes, container.MountedCim) + err := policy.EnforceVerifiedCIMsPolicy(ctx, containerID, layerHashes, container.MountedCim, testCIMVolumeGUID) if err != nil { return "", fmt.Errorf("error mounting CIMFS: %w", err) } diff --git a/pkg/securitypolicy/regopolicy_windows_test.go b/pkg/securitypolicy/regopolicy_windows_test.go index 646b7c5811..4e0c7f7e29 100644 --- a/pkg/securitypolicy/regopolicy_windows_test.go +++ b/pkg/securitypolicy/regopolicy_windows_test.go @@ -582,7 +582,7 @@ func Test_Rego_EnforceVerifiedCIMSPolicy_Multiple_Instances_Same_Container(t *te // The runtime sends individual layers as hashesToVerify // and the merged CIM hash separately id := testDataGenerator.uniqueContainerID() - err = policy.EnforceVerifiedCIMsPolicy(constraints.ctx, id, layerHashes, container.MountedCim) + err = policy.EnforceVerifiedCIMsPolicy(constraints.ctx, id, layerHashes, container.MountedCim, testCIMVolumeGUID) if err != nil { t.Fatalf("failed with %d containers", containersToCreate) } @@ -590,6 +590,99 @@ func Test_Rego_EnforceVerifiedCIMSPolicy_Multiple_Instances_Same_Container(t *te } } +// setupMountedCIMVolume builds a generated Windows policy, mounts container[0]'s +// CIM under the given volume GUID (so mount_cims records it), and returns the +// enforcer ready for an unmount_cims call. +func setupMountedCIMVolume(t *testing.T, p *generatedWindowsConstraints, volumeGUID string) *regoEnforcer { + t.Helper() + securityPolicy := p.toPolicy() + policy, err := newRegoPolicy(securityPolicy.marshalWindowsRego(), []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Fatalf("failed to create policy: %v", err) + } + + container := p.containers[0] + layerHashes := make([]string, len(container.Layers)) + for i, layer := range container.Layers { + layerHashes[len(container.Layers)-1-i] = layer + } + + id := testDataGenerator.uniqueContainerID() + if err := policy.EnforceVerifiedCIMsPolicy(context.Background(), id, layerHashes, container.MountedCim, volumeGUID); err != nil { + t.Fatalf("mount should succeed: %v", err) + } + return policy +} + +// Unmounting a CIM volume that was recorded at mount time is allowed. +func Test_Rego_EnforceCIMUnmountPolicy_Windows(t *testing.T) { + f := func(p *generatedWindowsConstraints) bool { + if len(p.containers) == 0 { + return true + } + volumeGUID := "12345678-1234-1234-1234-123456789abc" + policy := setupMountedCIMVolume(t, p, volumeGUID) + + if err := policy.EnforceCIMUnmountPolicy(context.Background(), volumeGUID); err != nil { + t.Errorf("unmount of a mounted CIM volume should succeed: %v", err) + return false + } + return true + } + + if err := quick.Check(f, &quick.Config{MaxCount: 5, Rand: testRand}); err != nil { + t.Errorf("Test_Rego_EnforceCIMUnmountPolicy_Windows: %v", err) + } +} + +// Unmounting a CIM volume GUID that was never mounted is denied (no symmetry). +func Test_Rego_EnforceCIMUnmountPolicy_NotMounted_Denied_Windows(t *testing.T) { + f := func(p *generatedWindowsConstraints) bool { + securityPolicy := p.toPolicy() + policy, err := newRegoPolicy(securityPolicy.marshalWindowsRego(), []oci.Mount{}, []oci.Mount{}, testOSType) + if err != nil { + t.Errorf("failed to create policy: %v", err) + return false + } + + if err := policy.EnforceCIMUnmountPolicy(context.Background(), "never-mounted-guid"); err == nil { + t.Error("unmount of a never-mounted CIM volume should be denied") + return false + } + return true + } + + if err := quick.Check(f, &quick.Config{MaxCount: 5, Rand: testRand}); err != nil { + t.Errorf("Test_Rego_EnforceCIMUnmountPolicy_NotMounted_Denied_Windows: %v", err) + } +} + +// Unmounting the same CIM volume twice is denied: the first unmount removes the +// record, so the second has nothing to match. +func Test_Rego_EnforceCIMUnmountPolicy_DoubleUnmount_Denied_Windows(t *testing.T) { + f := func(p *generatedWindowsConstraints) bool { + if len(p.containers) == 0 { + return true + } + volumeGUID := "aaaabbbb-cccc-dddd-eeee-ffffffffffff" + policy := setupMountedCIMVolume(t, p, volumeGUID) + + if err := policy.EnforceCIMUnmountPolicy(context.Background(), volumeGUID); err != nil { + t.Errorf("first unmount should succeed: %v", err) + return false + } + if err := policy.EnforceCIMUnmountPolicy(context.Background(), volumeGUID); err == nil { + t.Error("second unmount of the same CIM volume should be denied") + return false + } + return true + } + + if err := quick.Check(f, &quick.Config{MaxCount: 5, Rand: testRand}); err != nil { + t.Errorf("Test_Rego_EnforceCIMUnmountPolicy_DoubleUnmount_Denied_Windows: %v", err) + } +} + // -- Capabilities/Mount/Rego version tests are removed -- Add back Rego versions test// func Test_Rego_ExecInContainerPolicy_Windows(t *testing.T) { f := func(p *generatedWindowsConstraints) bool { @@ -1824,7 +1917,7 @@ func Test_Rego_RegistryChanges_NarrowsMatches_Windows(t *testing.T) { t.Run("registry_then_create_denied", func(t *testing.T) { policy := newPolicy(true) cid := testDataGenerator.uniqueContainerID() - if err := policy.EnforceVerifiedCIMsPolicy(ctx, cid, layerHashes, mountedCim); err != nil { + if err := policy.EnforceVerifiedCIMsPolicy(ctx, cid, layerHashes, mountedCim, testCIMVolumeGUID); err != nil { t.Fatalf("mount_cims: %v", err) } kept, err := policy.EnforceRegistryChangesPolicy(ctx, cid, dangerous) @@ -1846,7 +1939,7 @@ func Test_Rego_RegistryChanges_NarrowsMatches_Windows(t *testing.T) { t.Run("create_then_registry_drops", func(t *testing.T) { policy := newPolicy(true) cid := testDataGenerator.uniqueContainerID() - if err := policy.EnforceVerifiedCIMsPolicy(ctx, cid, layerHashes, mountedCim); err != nil { + if err := policy.EnforceVerifiedCIMsPolicy(ctx, cid, layerHashes, mountedCim, testCIMVolumeGUID); err != nil { t.Fatalf("mount_cims: %v", err) } if _, _, _, err := policy.EnforceCreateContainerPolicyV2(ctx, cid, []string{"cmd"}, []string{}, `C:\app`, []oci.Mount{}, user, nil); err != nil { @@ -1865,7 +1958,7 @@ func Test_Rego_RegistryChanges_NarrowsMatches_Windows(t *testing.T) { t.Run("create_ping_then_registry_keeps", func(t *testing.T) { policy := newPolicy(true) cid := testDataGenerator.uniqueContainerID() - if err := policy.EnforceVerifiedCIMsPolicy(ctx, cid, layerHashes, mountedCim); err != nil { + if err := policy.EnforceVerifiedCIMsPolicy(ctx, cid, layerHashes, mountedCim, testCIMVolumeGUID); err != nil { t.Fatalf("mount_cims: %v", err) } if _, _, _, err := policy.EnforceCreateContainerPolicyV2(ctx, cid, []string{"ping"}, []string{}, `C:\app`, []oci.Mount{}, user, nil); err != nil { @@ -1886,7 +1979,7 @@ func Test_Rego_RegistryChanges_NarrowsMatches_Windows(t *testing.T) { t.Run("no_dropping_create_then_registry_denied", func(t *testing.T) { policy := newPolicy(false) cid := testDataGenerator.uniqueContainerID() - if err := policy.EnforceVerifiedCIMsPolicy(ctx, cid, layerHashes, mountedCim); err != nil { + if err := policy.EnforceVerifiedCIMsPolicy(ctx, cid, layerHashes, mountedCim, testCIMVolumeGUID); err != nil { t.Fatalf("mount_cims: %v", err) } if _, _, _, err := policy.EnforceCreateContainerPolicyV2(ctx, cid, []string{"cmd"}, []string{}, `C:\app`, []oci.Mount{}, user, nil); err != nil { @@ -1902,7 +1995,7 @@ func Test_Rego_RegistryChanges_NarrowsMatches_Windows(t *testing.T) { t.Run("no_dropping_create_ping_then_registry_keeps", func(t *testing.T) { policy := newPolicy(false) cid := testDataGenerator.uniqueContainerID() - if err := policy.EnforceVerifiedCIMsPolicy(ctx, cid, layerHashes, mountedCim); err != nil { + if err := policy.EnforceVerifiedCIMsPolicy(ctx, cid, layerHashes, mountedCim, testCIMVolumeGUID); err != nil { t.Fatalf("mount_cims: %v", err) } if _, _, _, err := policy.EnforceCreateContainerPolicyV2(ctx, cid, []string{"ping"}, []string{}, `C:\app`, []oci.Mount{}, user, nil); err != nil { @@ -1961,7 +2054,7 @@ func Test_Rego_RegistryChanges_DeleteKeys_Windows(t *testing.T) { t.Run("registry_delete_then_create_denied", func(t *testing.T) { policy := newPolicy(true) cid := testDataGenerator.uniqueContainerID() - if err := policy.EnforceVerifiedCIMsPolicy(ctx, cid, layerHashes, mountedCim); err != nil { + if err := policy.EnforceVerifiedCIMsPolicy(ctx, cid, layerHashes, mountedCim, testCIMVolumeGUID); err != nil { t.Fatalf("mount_cims: %v", err) } kept, err := policy.EnforceRegistryChangesPolicy(ctx, cid, deleteRequest) @@ -1983,7 +2076,7 @@ func Test_Rego_RegistryChanges_DeleteKeys_Windows(t *testing.T) { t.Run("create_then_registry_delete_drops", func(t *testing.T) { policy := newPolicy(true) cid := testDataGenerator.uniqueContainerID() - if err := policy.EnforceVerifiedCIMsPolicy(ctx, cid, layerHashes, mountedCim); err != nil { + if err := policy.EnforceVerifiedCIMsPolicy(ctx, cid, layerHashes, mountedCim, testCIMVolumeGUID); err != nil { t.Fatalf("mount_cims: %v", err) } if _, _, _, err := policy.EnforceCreateContainerPolicyV2(ctx, cid, []string{"cmd"}, []string{}, `C:\app`, []oci.Mount{}, user, nil); err != nil { @@ -2004,7 +2097,7 @@ func Test_Rego_RegistryChanges_DeleteKeys_Windows(t *testing.T) { t.Run("no_dropping_create_then_delete_denied", func(t *testing.T) { policy := newPolicy(false) cid := testDataGenerator.uniqueContainerID() - if err := policy.EnforceVerifiedCIMsPolicy(ctx, cid, layerHashes, mountedCim); err != nil { + if err := policy.EnforceVerifiedCIMsPolicy(ctx, cid, layerHashes, mountedCim, testCIMVolumeGUID); err != nil { t.Fatalf("mount_cims: %v", err) } if _, _, _, err := policy.EnforceCreateContainerPolicyV2(ctx, cid, []string{"cmd"}, []string{}, `C:\app`, []oci.Mount{}, user, nil); err != nil { @@ -2018,7 +2111,7 @@ func Test_Rego_RegistryChanges_DeleteKeys_Windows(t *testing.T) { t.Run("no_dropping_create_ping_then_delete_keeps", func(t *testing.T) { policy := newPolicy(false) cid := testDataGenerator.uniqueContainerID() - if err := policy.EnforceVerifiedCIMsPolicy(ctx, cid, layerHashes, mountedCim); err != nil { + if err := policy.EnforceVerifiedCIMsPolicy(ctx, cid, layerHashes, mountedCim, testCIMVolumeGUID); err != nil { t.Fatalf("mount_cims: %v", err) } if _, _, _, err := policy.EnforceCreateContainerPolicyV2(ctx, cid, []string{"ping"}, []string{}, `C:\app`, []oci.Mount{}, user, nil); err != nil { diff --git a/pkg/securitypolicy/securitypolicyenforcer.go b/pkg/securitypolicy/securitypolicyenforcer.go index fe8b4717fa..802618680c 100644 --- a/pkg/securitypolicy/securitypolicyenforcer.go +++ b/pkg/securitypolicy/securitypolicyenforcer.go @@ -128,7 +128,8 @@ type SecurityPolicyEnforcer interface { EnforceMappedDirectoryMountPolicy(ctx context.Context, containerPath string, readOnly bool) (err error) EnforceMappedDirectoryUnmountPolicy(ctx context.Context, containerPath string) (err error) GetUserInfo(spec *oci.Process, rootPath string) (IDName, []IDName, string, error) - EnforceVerifiedCIMsPolicy(ctx context.Context, containerID string, layerHashes []string, mountedCim []string) (err error) + EnforceVerifiedCIMsPolicy(ctx context.Context, containerID string, layerHashes []string, mountedCim []string, volumeGUID string) (err error) + EnforceCIMUnmountPolicy(ctx context.Context, volumeGUID string) (err error) EnforceRegistryChangesPolicy(ctx context.Context, containerID string, registryChanges interface{}) (interface{}, error) } @@ -326,7 +327,11 @@ func (OpenDoorSecurityPolicyEnforcer) GetUserInfo(spec *oci.Process, rootPath st return IDName{}, nil, "", nil } -func (OpenDoorSecurityPolicyEnforcer) EnforceVerifiedCIMsPolicy(ctx context.Context, containerID string, layerHashes []string, mountedCim []string) error { +func (OpenDoorSecurityPolicyEnforcer) EnforceVerifiedCIMsPolicy(ctx context.Context, containerID string, layerHashes []string, mountedCim []string, volumeGUID string) error { + return nil +} + +func (OpenDoorSecurityPolicyEnforcer) EnforceCIMUnmountPolicy(ctx context.Context, volumeGUID string) error { return nil } @@ -463,10 +468,14 @@ func (ClosedDoorSecurityPolicyEnforcer) GetUserInfo(spec *oci.Process, rootPath return IDName{}, nil, "", nil } -func (ClosedDoorSecurityPolicyEnforcer) EnforceVerifiedCIMsPolicy(ctx context.Context, containerID string, layerHashes []string, mountedCim []string) error { +func (ClosedDoorSecurityPolicyEnforcer) EnforceVerifiedCIMsPolicy(ctx context.Context, containerID string, layerHashes []string, mountedCim []string, volumeGUID string) error { return nil } +func (ClosedDoorSecurityPolicyEnforcer) EnforceCIMUnmountPolicy(ctx context.Context, volumeGUID string) error { + return errors.New("CIM unmounting is denied by policy") +} + func (ClosedDoorSecurityPolicyEnforcer) EnforceRegistryChangesPolicy(ctx context.Context, containerID string, registryChanges interface{}) (interface{}, error) { return nil, errors.New("registry changes are denied by policy") } diff --git a/pkg/securitypolicy/securitypolicyenforcer_rego.go b/pkg/securitypolicy/securitypolicyenforcer_rego.go index 140da1e730..0e096c62ad 100644 --- a/pkg/securitypolicy/securitypolicyenforcer_rego.go +++ b/pkg/securitypolicy/securitypolicyenforcer_rego.go @@ -1175,18 +1175,29 @@ func (policy *regoEnforcer) EnforceMappedDirectoryUnmountPolicy(ctx context.Cont return err } -func (policy *regoEnforcer) EnforceVerifiedCIMsPolicy(ctx context.Context, containerID string, layerHashes []string, mountedCim []string) error { +func (policy *regoEnforcer) EnforceVerifiedCIMsPolicy(ctx context.Context, containerID string, layerHashes []string, mountedCim []string, volumeGUID string) error { log.G(ctx).Tracef("Enforcing verified cims in securitypolicy pkg %+v", layerHashes) input := inputData{ "containerID": containerID, "layerHashes": layerHashes, "mountedCim": mountedCim, + "volumeGUID": volumeGUID, } _, err := policy.enforce(ctx, "mount_cims", input) return err } +func (policy *regoEnforcer) EnforceCIMUnmountPolicy(ctx context.Context, volumeGUID string) error { + log.G(ctx).Trace("Enforcing CIM unmount policy") + input := inputData{ + "volumeGUID": volumeGUID, + } + + _, err := policy.enforce(ctx, "unmount_cims", input) + return err +} + func (policy *regoEnforcer) EnforceRegistryChangesPolicy(ctx context.Context, containerID string, registryChanges interface{}) (interface{}, error) { log.G(ctx).Trace("Enforcing registry changes policy") From 57e4ed284dd6e40f902eaf96b92c3563c1a2562a Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Tue, 7 Jul 2026 17:19:06 +0100 Subject: [PATCH 33/56] Cross-check the forwarded Container.Storage against values provided in modifysettings request Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 71 ++++++++++++++++++++++++++++++++ internal/gcs-sidecar/host.go | 6 +++ 2 files changed, 77 insertions(+) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index ad0d39018b..79932a3811 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -290,6 +290,12 @@ func (b *Bridge) createContainer(req *request) (err error) { return fmt.Errorf("CreateContainer operation is denied by policy: %w", err) } + // Cross-check the forwarded Container.Storage against the root path and + // block-CIM volume the sidecar recorded for this container during layer setup. + if err := reconcileHostedSystemStorage(b.hostState, containerID, container); err != nil { + return fmt.Errorf("CreateContainer operation is denied by policy: %w", err) + } + // Marshal the original cwcowHostedSystem from the request. That's safe // because we've enforced `spec` above and reconciled the forwarded // MappedDirectories/MappedPipes against it. @@ -470,6 +476,67 @@ func reconcileHostedSystemMounts(mounts []oci.Mount, container *hcsschema.Contai return nil } +// volumeGUIDFromStoragePath extracts the volume GUID from a Container.Storage +// layer path of the form `\\?\Volume{}\` (the volume root, as the host +// writes it in the createContainer document). This differs from +// volumeGUIDFromLayerPath, which parses the `...}\Files` form used in the +// CWCOWCombinedLayers modify request. +func volumeGUIDFromStoragePath(path string) (string, bool) { + if p, ok := strings.CutPrefix(path, `\\?\Volume{`); ok { + if q, ok := strings.CutSuffix(p, `}\`); ok { + return q, true + } + } + return "", false +} + +// reconcileHostedSystemStorage checks that the host-forwarded Container.Storage +// matches the verified handles the sidecar recorded for this container during +// layer setup: +// - Storage.Path must equal the combined-layers root that CWCOWCombinedLayers +// mounted for this container (the scratch that becomes the container root). +// - Storage.Layers must be the single block-CIM volume whose hashes mount_cims +// verified for this container. +// +// The bytes at that volume are already verity-verified, so this does not re-check +// content. It closes a cross-wiring gap: without it a host could forward a create +// document that points the container root at a different (even if separately +// verified) volume than the one enforced for this container. +func reconcileHostedSystemStorage(host *Host, containerID string, container *hcsschema.Container) error { + if container == nil || container.Storage == nil { + return fmt.Errorf("container storage is missing") + } + storage := container.Storage + + wantRootPath, ok := host.containerRootPaths[containerID] + if !ok { + return fmt.Errorf("no container root path recorded for container %s", containerID) + } + if !strings.EqualFold(storage.Path, wantRootPath) { + return fmt.Errorf("storage path %q does not match the enforced container root path %q", storage.Path, wantRootPath) + } + + if len(storage.Layers) != 1 { + return fmt.Errorf("expected exactly one storage layer, got %d", len(storage.Layers)) + } + guidStr, ok := volumeGUIDFromStoragePath(storage.Layers[0].Path) + if !ok { + return fmt.Errorf("storage layer path %q is not a volume path", storage.Layers[0].Path) + } + volGUID, err := guid.FromString(guidStr) + if err != nil { + return fmt.Errorf("invalid storage layer volume GUID %q: %w", guidStr, err) + } + containers, ok := host.blockCIMVolumeContainers[volGUID] + if !ok { + return fmt.Errorf("storage layer volume %s was not verified", volGUID) + } + if _, ok := containers[containerID]; !ok { + return fmt.Errorf("storage layer volume %s was not verified for container %s", volGUID, containerID) + } + return nil +} + // processParamEnvToOCIEnv converts an Environment field from ProcessParameters // (a map from environment variable to value) into an array of environment // variable assignments (where each is in the form "=") which @@ -1381,6 +1448,10 @@ func (b *Bridge) modifySettings(req *request) (err error) { if err := b.hostState.securityOptions.PolicyEnforcer.EnforceScratchMountPolicy(ctx, settings.CombinedLayers.ContainerRootPath, true); err != nil { return fmt.Errorf("scratch mounting denied by policy: %w", err) } + + // Record the container root path so createContainer can cross-check + // the forwarded Storage.Path against it. + b.hostState.containerRootPaths[containerID] = settings.CombinedLayers.ContainerRootPath // The following two folders are expected to be present in the scratch. // But since we have just formatted the scratch we would need to // create them manually. diff --git a/internal/gcs-sidecar/host.go b/internal/gcs-sidecar/host.go index 25b2a79c8e..f9dd336f35 100644 --- a/internal/gcs-sidecar/host.go +++ b/internal/gcs-sidecar/host.go @@ -27,6 +27,10 @@ type Host struct { blockCIMVolumeHashes map[guid.GUID][]string // mapping of volumeGUID to container IDs blockCIMVolumeContainers map[guid.GUID]map[string]struct{} + // mapping of containerID to the ContainerRootPath recorded when + // CWCOWCombinedLayers mounted it, used to validate the createContainer + // Storage.Path. + containerRootPaths map[string]string } type Container struct { @@ -60,6 +64,7 @@ func NewHost(initialEnforcer securitypolicy.SecurityPolicyEnforcer, logWriter io containers: make(map[string]*Container), blockCIMVolumeHashes: make(map[guid.GUID][]string), blockCIMVolumeContainers: make(map[guid.GUID]map[string]struct{}), + containerRootPaths: make(map[string]string), securityOptions: securityPolicyOptions, } } @@ -88,6 +93,7 @@ func (h *Host) RemoveContainer(ctx context.Context, id string) error { } delete(h.containers, id) + delete(h.containerRootPaths, id) return nil } From 72d6a3fd89c123e0a1a20ea2924569824bb234e0 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Tue, 7 Jul 2026 17:35:16 +0100 Subject: [PATCH 34/56] Deny unsupported fields in HostedSystem Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 70 ++++++++++++++++---------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index 79932a3811..120fb6876d 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -78,6 +78,11 @@ func (b *Bridge) createContainer(req *request) (err error) { containerJSON, _ := json.Marshal(container) log.G(ctx).Tracef("rpcCreate: CWCOWHostedSystemConfig {spec: %v, schemaVersion: %v, container: %s}}", string(req.message), schemaVersion, containerJSON) + // Reject HostedSystem Container fields we don't yet support. + if err := denyUnsupportedContainerFields(container); err != nil { + return fmt.Errorf("CreateContainer operation rejected: %w", err) + } + // Enforce registry changes policy. This may drop unauthorized // non-default registry values from the container before forwarding. if container != nil && container.RegistryChanges != nil { @@ -154,41 +159,6 @@ func (b *Bridge) createContainer(req *request) (err error) { return fmt.Errorf("failed to write security context dir: %w", err) } - // TODO!! enforce over various fields in HostedSystem. - /* - type Container struct { - GuestOs *GuestOs `json:"GuestOs,omitempty"` - ->? Storage *Storage `json:"Storage,omitempty"` # Looks like it's scratch. - -> MappedDirectories []MappedDirectory `json:"MappedDirectories,omitempty"` # Used with `mounts` - ->? MappedPipes []MappedPipe `json:"MappedPipes,omitempty"` - Memory *Memory `json:"Memory,omitempty"` # We can't do anything about this. Host can do denial of service attack anyway. - ? Processor *Processor `json:"Processor,omitempty"` - Networking *Networking `json:"Networking,omitempty"` - HvSocket *HvSocket `json:"HvSocket,omitempty"` # At the moment host doesn't pass it (createWindowsContainerDocument internal\hcsoci\hcsdoc_wcow.go). We just should reject any value here? - ContainerCredentialGuard *ContainerCredentialGuardState `json:"ContainerCredentialGuard,omitempty"` # TODO: what's credential guard and can we block it for now? - -> RegistryChanges *RegistryChanges `json:"RegistryChanges,omitempty"` -> It's already enforced by EnforceRegistryChangesPolicy() above. - ->? AssignedDevices []Device `json:"AssignedDevices,omitempty"` # Block these for now. See below for the details. - ->? AdditionalDeviceNamespace *ContainerDefinitionDevice `json:"AdditionalDeviceNamespace,omitempty"` # Block these for now. See below for the details. - } - - For hvsocket, UVMHyperVSocketConfigPrefix annotation seem to be available somehow. TODO: check - - AssignedDevices: Looks like it's exposing VPCI device on L1 to uvm. - host-populated from Spec.Windows.Devices - (parseAssignedDevices, internal/hcsoci/hcsdoc_wcow.go:513,529), only for v2 - argon/xenon (hcsdoc_wcow.go:508). Each device is first VPCI-assigned into the - UVM via handleAssignedDevicesWindows -> devices.AddDevice -> uvm.AssignDevice - (internal/hcsoci/resources_wcow.go:94, internal/hcsoci/devices.go:134, - internal/devices/assigned_devices.go:45). It seems to require VPCI device instance - on L1. TODO: Try it and see if we need an enforcement point here now. - - AdditionalDeviceNamespace: host-populated from getDeviceExtensions(coi.Spec.Annotations) - (internal/hcsoci/hcsdoc_wcow.go:391,395). It's driven purely by the annotation - "io.microsoft.container.wcow.deviceextensions" (pkg/annotations/annotations.go:223). - TODO: What's device extension? Do we need to support it for the first release or - can we just reject for now? - */ - /* Test container.json: @@ -537,6 +507,36 @@ func reconcileHostedSystemStorage(host *Host, containerID string, container *hcs return nil } +// denyUnsupportedContainerFields rejects HostedSystem Container fields that the +// sidecar does not yet enforce a policy over. They may be needed in the future, +// but until we have enforcement for them we block them rather than forward +// host-controlled values unchecked. +// +// Memory, Processor and Networking are deliberately not checked: the host +// controls the UVM's resources and networking regardless, so there is nothing +// we can meaningfully enforce over them here. +func denyUnsupportedContainerFields(container *hcsschema.Container) error { + if container == nil { + return nil + } + if container.GuestOs != nil { + return fmt.Errorf("GuestOs is not supported") + } + if container.HvSocket != nil { + return fmt.Errorf("HvSocket is not supported") + } + if container.ContainerCredentialGuard != nil { + return fmt.Errorf("ContainerCredentialGuard is not supported") + } + if len(container.AssignedDevices) > 0 { + return fmt.Errorf("AssignedDevices is not supported") + } + if container.AdditionalDeviceNamespace != nil { + return fmt.Errorf("AdditionalDeviceNamespace is not supported") + } + return nil +} + // processParamEnvToOCIEnv converts an Environment field from ProcessParameters // (a map from environment variable to value) into an array of environment // variable assignments (where each is in the form "=") which From f4cddc157df238afc3e0073858c6c862961b2fab Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Tue, 7 Jul 2026 17:57:47 +0100 Subject: [PATCH 35/56] Reject MappedVirtualDisk and HvSocket in modifySettings; document network pass-through Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 33 ++++++++++++-------------------- 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index 120fb6876d..0f20957e4b 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -1130,37 +1130,28 @@ func (b *Bridge) modifySettings(req *request) (err error) { return fmt.Errorf("WCOWCombinedLayers is not supported.") case guestresource.ResourceTypeNetworkNamespace: + // Forwarded to inbox GCS without enforcement, by design: the host + // controls the UVM's networking regardless of what is configured here, + // so there is nothing meaningful for the guest to enforce. + // LCOW does the same (see modifyNetwork in internal\guest\runtime\hcsv2\uvm.go). settings := modifyGuestSettingsRequest.Settings.(*hcn.HostComputeNamespace) log.G(ctx).Tracef("HostComputeNamespaces { %v}", settings) - // We don't enforce policy for network namespace. - // TODO: Maybe we could enforce NamespaceType and SchemaVersion? - // What's the justification not to enforce them? - // TODO: see what lcow does case guestresource.ResourceTypeNetwork: + // Forwarded without enforcement for the same reason as + // ResourceTypeNetworkNamespace above: networking is host-controlled. settings := modifyGuestSettingsRequest.Settings.(*guestrequest.NetworkModifyRequest) log.G(ctx).Tracef("NetworkModifyRequest { %v}", settings) - // We don't enforce policy for network setttings. - // There is no field that policy authors can expect a value to be set. - // TODO: see what lcow does case guestresource.ResourceTypeMappedVirtualDisk: - // We don't know if it's used for CWCOW. - // The change is added in case it's used for CWCOW. TODO: to see if it's used or not, maybe try attaching a test VHD through pod.json - wcowMappedVirtualDisk := modifyGuestSettingsRequest.Settings.(*guestresource.WCOWMappedVirtualDisk) - log.G(ctx).Tracef("wcowMappedVirtualDisk { %v}", wcowMappedVirtualDisk) - if wcowMappedVirtualDisk.ContainerPath != "" { - matched, merr := regexp.MatchString(`(?i)^[Cc]:\\mounts\\scsi\\m[0-9]+$`, wcowMappedVirtualDisk.ContainerPath) - if merr != nil || !matched { - return fmt.Errorf("virtual disk mount path %q does not match expected pattern c:\\mounts\\scsi\\m", - wcowMappedVirtualDisk.ContainerPath) - } - } + settings := modifyGuestSettingsRequest.Settings.(*guestresource.WCOWMappedVirtualDisk) + log.G(ctx).Tracef("WCOWMappedVirtualDisk: {%v}", settings) + return fmt.Errorf("MappedVirtualDisk is not supported") case guestresource.ResourceTypeHvSocket: - hvSocketAddress := modifyGuestSettingsRequest.Settings.(*hcsschema.HvSocketAddress) - log.G(ctx).Tracef("hvSocketAddress { %v }", hvSocketAddress) - // If host doesn't use it maybe remove it TODO + settings := modifyGuestSettingsRequest.Settings.(*hcsschema.HvSocketAddress) + log.G(ctx).Tracef("HvSocketAddress { %v }", settings) + return fmt.Errorf("HvSocket is not supported") case guestresource.ResourceTypeMappedDirectory: // We don't have hostpath enforcement because anyway contents of the dir can be changed by the host. From 12c77ba8de3fe6583afccbfcc06a658255fd1aa3 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Tue, 7 Jul 2026 18:03:17 +0100 Subject: [PATCH 36/56] Add comment to explain the example request of createContainer Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 165 ++++++++++++++++--------------- 1 file changed, 84 insertions(+), 81 deletions(-) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index 0f20957e4b..f6316150a9 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -78,87 +78,9 @@ func (b *Bridge) createContainer(req *request) (err error) { containerJSON, _ := json.Marshal(container) log.G(ctx).Tracef("rpcCreate: CWCOWHostedSystemConfig {spec: %v, schemaVersion: %v, container: %s}}", string(req.message), schemaVersion, containerJSON) - // Reject HostedSystem Container fields we don't yet support. - if err := denyUnsupportedContainerFields(container); err != nil { - return fmt.Errorf("CreateContainer operation rejected: %w", err) - } - - // Enforce registry changes policy. This may drop unauthorized - // non-default registry values from the container before forwarding. - if container != nil && container.RegistryChanges != nil { - log.G(ctx).Trace("Container has registry changes, validating against policy") - - // Separate the pre-approved defaults from the changes that must be - // validated against policy (non-default add values plus all delete - // keys). - defaultValues, nonDefaultChanges := splitRegistryChanges(container.RegistryChanges) - - // If there are non-default values or any delete keys, validate them - // against policy. - if len(nonDefaultChanges.AddValues) > 0 || len(nonDefaultChanges.DeleteKeys) > 0 { - log.G(ctx).Tracef("Validating %d registry values and %d delete keys against policy", len(nonDefaultChanges.AddValues), len(nonDefaultChanges.DeleteKeys)) - - keptRaw, err := b.hostState.securityOptions.PolicyEnforcer.EnforceRegistryChangesPolicy(ctx, containerID, nonDefaultChanges) - if err != nil { - log.G(ctx).WithError(err).Warn("Registry changes validation failed - rejecting") - return fmt.Errorf("registry entry operation is denied by policy: %w", err) - } - - // The policy uses dropping semantics: it may authorize only a - // subset of the requested non-default values and delete keys. - // Rebuild the container's registry changes as the pre-approved - // defaults plus the policy-kept non-default values, and the - // policy-kept delete keys, so the guest only applies what policy - // sanctioned. - container.RegistryChanges.AddValues, container.RegistryChanges.DeleteKeys = mergeKeptRegistryChanges(defaultValues, keptRaw) - } - - log.G(ctx).Infof("Registry validation complete: %d total values now applied (%d defaults), %d delete keys", - len(container.RegistryChanges.AddValues), len(defaultValues), len(container.RegistryChanges.DeleteKeys)) - } - - // We enforce `spec`, which is not passed to inbox gcs within this createContainer. - // The result of enforcement is stored in memory and used for executeProcess. - user := securitypolicy.IDName{ - Name: spec.Process.User.Username, - } - envToKeep, _, allowStdio, err := b.hostState.securityOptions.PolicyEnforcer.EnforceCreateContainerPolicyV2(req.ctx, containerID, spec.Process.Args, spec.Process.Env, spec.Process.Cwd, spec.Mounts, user, nil) - - if err != nil { - return fmt.Errorf("CreateContainer operation is denied by policy: %w", err) - } - - if envToKeep != nil { - spec.Process.Env = []string(envToKeep) - } - - commandLine := len(spec.Process.Args) > 0 - c := &Container{ - id: containerID, - spec: spec, - processes: make(map[uint32]*containerProcess), - commandLine: commandLine, - commandLineExec: false, - allowStdio: allowStdio, - } - - log.G(ctx).Tracef("Adding ContainerID: %v", containerID) - if err := b.hostState.AddContainer(req.ctx, containerID, c); err != nil { - log.G(ctx).Tracef("Container exists in the map. containerID: %v", containerID) - return err - } - defer func() { - if err != nil { - if removeErr := b.hostState.RemoveContainer(ctx, containerID); removeErr != nil { - log.G(ctx).WithError(removeErr).Errorf("Failed to remove container: %v", containerID) - } - } - }() - - if err := b.hostState.securityOptions.WriteSecurityContextDir(&spec); err != nil { - return fmt.Errorf("failed to write security context dir: %w", err) - } - + // The block below is a reference example (not executed): a sample CRI + // container.json and the HostedSystem.Container the host derives from it. + // It documents the shapes this handler enforces and forwards. /* Test container.json: @@ -250,6 +172,87 @@ func (b *Bridge) createContainer(req *request) (err error) { } */ + // Reject HostedSystem Container fields we don't yet support. + if err := denyUnsupportedContainerFields(container); err != nil { + return fmt.Errorf("CreateContainer operation rejected: %w", err) + } + + // Enforce registry changes policy. This may drop unauthorized + // non-default registry values from the container before forwarding. + if container != nil && container.RegistryChanges != nil { + log.G(ctx).Trace("Container has registry changes, validating against policy") + + // Separate the pre-approved defaults from the changes that must be + // validated against policy (non-default add values plus all delete + // keys). + defaultValues, nonDefaultChanges := splitRegistryChanges(container.RegistryChanges) + + // If there are non-default values or any delete keys, validate them + // against policy. + if len(nonDefaultChanges.AddValues) > 0 || len(nonDefaultChanges.DeleteKeys) > 0 { + log.G(ctx).Tracef("Validating %d registry values and %d delete keys against policy", len(nonDefaultChanges.AddValues), len(nonDefaultChanges.DeleteKeys)) + + keptRaw, err := b.hostState.securityOptions.PolicyEnforcer.EnforceRegistryChangesPolicy(ctx, containerID, nonDefaultChanges) + if err != nil { + log.G(ctx).WithError(err).Warn("Registry changes validation failed - rejecting") + return fmt.Errorf("registry entry operation is denied by policy: %w", err) + } + + // The policy uses dropping semantics: it may authorize only a + // subset of the requested non-default values and delete keys. + // Rebuild the container's registry changes as the pre-approved + // defaults plus the policy-kept non-default values, and the + // policy-kept delete keys, so the guest only applies what policy + // sanctioned. + container.RegistryChanges.AddValues, container.RegistryChanges.DeleteKeys = mergeKeptRegistryChanges(defaultValues, keptRaw) + } + + log.G(ctx).Infof("Registry validation complete: %d total values now applied (%d defaults), %d delete keys", + len(container.RegistryChanges.AddValues), len(defaultValues), len(container.RegistryChanges.DeleteKeys)) + } + + // We enforce `spec`, which is not passed to inbox gcs within this createContainer. + // The result of enforcement is stored in memory and used for executeProcess. + user := securitypolicy.IDName{ + Name: spec.Process.User.Username, + } + envToKeep, _, allowStdio, err := b.hostState.securityOptions.PolicyEnforcer.EnforceCreateContainerPolicyV2(req.ctx, containerID, spec.Process.Args, spec.Process.Env, spec.Process.Cwd, spec.Mounts, user, nil) + + if err != nil { + return fmt.Errorf("CreateContainer operation is denied by policy: %w", err) + } + + if envToKeep != nil { + spec.Process.Env = []string(envToKeep) + } + + commandLine := len(spec.Process.Args) > 0 + c := &Container{ + id: containerID, + spec: spec, + processes: make(map[uint32]*containerProcess), + commandLine: commandLine, + commandLineExec: false, + allowStdio: allowStdio, + } + + log.G(ctx).Tracef("Adding ContainerID: %v", containerID) + if err := b.hostState.AddContainer(req.ctx, containerID, c); err != nil { + log.G(ctx).Tracef("Container exists in the map. containerID: %v", containerID) + return err + } + defer func() { + if err != nil { + if removeErr := b.hostState.RemoveContainer(ctx, containerID); removeErr != nil { + log.G(ctx).WithError(removeErr).Errorf("Failed to remove container: %v", containerID) + } + } + }() + + if err := b.hostState.securityOptions.WriteSecurityContextDir(&spec); err != nil { + return fmt.Errorf("failed to write security context dir: %w", err) + } + // Reconcile the host-provided HostedSystem mounts against the enforced // spec. spec.Mounts has already been validated against policy by // EnforceCreateContainerPolicyV2 above. Here we make sure the host is From 15b8c29c002a5884a1401485d51d9faccb04dd99 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Tue, 7 Jul 2026 18:39:27 +0100 Subject: [PATCH 37/56] Deny unhandled request types in modifySettings switches Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index f6316150a9..47b87398bd 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -1171,6 +1171,8 @@ func (b *Bridge) modifySettings(req *request) (err error) { ctx, settings.ContainerPath); err != nil { return fmt.Errorf("mapped directory unmount is denied by policy: %w", err) } + default: + return fmt.Errorf("unsupported request type %v for MappedDirectory", modifyGuestSettingsRequest.RequestType) } case guestresource.ResourceTypeSecurityPolicy: @@ -1323,6 +1325,8 @@ func (b *Bridge) modifySettings(req *request) (err error) { // Drop the cached mount state now that the volume is gone. delete(b.hostState.blockCIMVolumeHashes, volGUID) delete(b.hostState.blockCIMVolumeContainers, volGUID) + default: + return fmt.Errorf("unsupported request type %v for WCOWBlockCims", modifyGuestSettingsRequest.RequestType) } // Send response back to shim resp := &prot.ResponseBase{ @@ -1466,6 +1470,8 @@ func (b *Bridge) modifySettings(req *request) (err error) { if err := b.hostState.securityOptions.PolicyEnforcer.EnforceScratchUnmountPolicy(ctx, settings.CombinedLayers.ContainerRootPath); err != nil { return fmt.Errorf("scratch unmounting denied by policy: %w", err) } + default: + return fmt.Errorf("unsupported request type %v for CWCOWCombinedLayers", modifyGuestSettingsRequest.RequestType) } // Reconstruct WCOWCombinedLayers{} req before forwarding to GCS From f9547ff8f7b879fe01cb9d5f11c48d93a72e99e5 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Wed, 8 Jul 2026 11:17:00 +0100 Subject: [PATCH 38/56] Enable log earlier for now Signed-off-by: Takuro Sato --- cmd/gcs-sidecar/main.go | 1 + internal/gcs-sidecar/handlers.go | 40 ++++++++++++++++++++++++++------ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/cmd/gcs-sidecar/main.go b/cmd/gcs-sidecar/main.go index 736ac8ed21..c0871ee6bd 100644 --- a/cmd/gcs-sidecar/main.go +++ b/cmd/gcs-sidecar/main.go @@ -146,6 +146,7 @@ func main() { } defer logFileHandle.Close() + logrus.SetOutput(logFileHandle) logrus.AddHook(shimlog.NewHook()) level, err := logrus.ParseLevel(*logLevel) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index 47b87398bd..47d9ff6d81 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -39,6 +39,12 @@ const ( UVMContainerID = "00000000-0000-0000-0000-000000000000" ) +// allowUnsupportedForDebug, when true, downgrades the "not supported" and +// "unsupported request type" denials in this file to a logged warning plus +// pass-through, so the full RPC flow can be captured in the sidecar log. +// TEMPORARY: must be set back to false before shipping. +var allowUnsupportedForDebug = true + // - Handler functions handle the incoming message requests. It // also enforces security policy for confidential cwcow containers. // - These handler functions may do some additional processing before @@ -174,7 +180,10 @@ func (b *Bridge) createContainer(req *request) (err error) { // Reject HostedSystem Container fields we don't yet support. if err := denyUnsupportedContainerFields(container); err != nil { - return fmt.Errorf("CreateContainer operation rejected: %w", err) + if !allowUnsupportedForDebug { + return fmt.Errorf("CreateContainer operation rejected: %w", err) + } + log.G(ctx).Warnf("DEBUG: allowing unsupported container field: %v", err) } // Enforce registry changes policy. This may drop unauthorized @@ -1130,7 +1139,10 @@ func (b *Bridge) modifySettings(req *request) (err error) { // we don't have a policy enforcer for it. settings := modifyGuestSettingsRequest.Settings.(*guestresource.WCOWCombinedLayers) log.G(ctx).Tracef("WCOWCombinedLayers: {%v}", settings) - return fmt.Errorf("WCOWCombinedLayers is not supported.") + if !allowUnsupportedForDebug { + return fmt.Errorf("WCOWCombinedLayers is not supported.") + } + log.G(ctx).Warn("DEBUG: allowing unsupported ResourceTypeCombinedLayers") case guestresource.ResourceTypeNetworkNamespace: // Forwarded to inbox GCS without enforcement, by design: the host @@ -1149,12 +1161,17 @@ func (b *Bridge) modifySettings(req *request) (err error) { case guestresource.ResourceTypeMappedVirtualDisk: settings := modifyGuestSettingsRequest.Settings.(*guestresource.WCOWMappedVirtualDisk) log.G(ctx).Tracef("WCOWMappedVirtualDisk: {%v}", settings) - return fmt.Errorf("MappedVirtualDisk is not supported") + if !allowUnsupportedForDebug { + return fmt.Errorf("MappedVirtualDisk is not supported") + } + log.G(ctx).Warn("DEBUG: allowing unsupported ResourceTypeMappedVirtualDisk") case guestresource.ResourceTypeHvSocket: + // Forwarded without enforcement: this configures the hvsock relay for + // the external GCS (the sidecar) during UVM setup, so it must be + // allowed. hvsock addressing is host-controlled regardless. settings := modifyGuestSettingsRequest.Settings.(*hcsschema.HvSocketAddress) log.G(ctx).Tracef("HvSocketAddress { %v }", settings) - return fmt.Errorf("HvSocket is not supported") case guestresource.ResourceTypeMappedDirectory: // We don't have hostpath enforcement because anyway contents of the dir can be changed by the host. @@ -1172,7 +1189,10 @@ func (b *Bridge) modifySettings(req *request) (err error) { return fmt.Errorf("mapped directory unmount is denied by policy: %w", err) } default: - return fmt.Errorf("unsupported request type %v for MappedDirectory", modifyGuestSettingsRequest.RequestType) + if !allowUnsupportedForDebug { + return fmt.Errorf("unsupported request type %v for MappedDirectory", modifyGuestSettingsRequest.RequestType) + } + log.G(ctx).Warnf("DEBUG: allowing unsupported request type %v for MappedDirectory", modifyGuestSettingsRequest.RequestType) } case guestresource.ResourceTypeSecurityPolicy: @@ -1326,7 +1346,10 @@ func (b *Bridge) modifySettings(req *request) (err error) { delete(b.hostState.blockCIMVolumeHashes, volGUID) delete(b.hostState.blockCIMVolumeContainers, volGUID) default: - return fmt.Errorf("unsupported request type %v for WCOWBlockCims", modifyGuestSettingsRequest.RequestType) + if !allowUnsupportedForDebug { + return fmt.Errorf("unsupported request type %v for WCOWBlockCims", modifyGuestSettingsRequest.RequestType) + } + log.G(ctx).Warnf("DEBUG: allowing unsupported request type %v for WCOWBlockCims", modifyGuestSettingsRequest.RequestType) } // Send response back to shim resp := &prot.ResponseBase{ @@ -1471,7 +1494,10 @@ func (b *Bridge) modifySettings(req *request) (err error) { return fmt.Errorf("scratch unmounting denied by policy: %w", err) } default: - return fmt.Errorf("unsupported request type %v for CWCOWCombinedLayers", modifyGuestSettingsRequest.RequestType) + if !allowUnsupportedForDebug { + return fmt.Errorf("unsupported request type %v for CWCOWCombinedLayers", modifyGuestSettingsRequest.RequestType) + } + log.G(ctx).Warnf("DEBUG: allowing unsupported request type %v for CWCOWCombinedLayers", modifyGuestSettingsRequest.RequestType) } // Reconstruct WCOWCombinedLayers{} req before forwarding to GCS From c24a641f546429397727fed17e5cdfe191b7f42d Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Wed, 8 Jul 2026 12:01:54 +0100 Subject: [PATCH 39/56] Temporary comment Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index 47d9ff6d81..1812437e2a 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -1170,6 +1170,36 @@ func (b *Bridge) modifySettings(req *request) (err error) { // Forwarded without enforcement: this configures the hvsock relay for // the external GCS (the sidecar) during UVM setup, so it must be // allowed. hvsock addressing is host-controlled regardless. + /* + \"Settings\":{\"LocalAddress\":\"3d9f5488-46b8-5fea-bfc6-e58193392bd5\",\"ParentAddress\":\"894cc2d6-9d79-424f-93fe-42969ae6d8d1\"}}}\n" + + internal\gcs\prot\protocol.go + var WindowsGcsHvHostID = guid.GUID{ + Data1: 0x894cc2d6, + Data2: 0x9d79, + Data3: 0x424f, + Data4: [8]uint8{0x93, 0xfe, 0x42, 0x96, 0x9a, 0xe6, 0xd8, 0xd1}, + } + + + If we block this: + + PS C:\w\readonly-mount\test> .\run.ps1 -Mode snp -PodJson .\pod-hostedsystem.json -ContainerJson .\container-hostedsystem.json + ACR login OK (takurosatodevacr); token suppressed. + Image is up to date for sha256:9abd13771394646ea3d5730f2a018dee30edee5956be43dcdb4df5f671b0e8bf + a9ac1a798a2bb5f77e6c02d949d4a7823b59978cb0e0beec41d645a37be4bf7e + a9ac1a798a2bb5f77e6c02d949d4a7823b59978cb0e0beec41d645a37be4bf7e + Stopped sandbox 5f4a393fd069de788cd9bc211438f1459180fedb138b18283a1ea22babce722c + Removed sandbox 5f4a393fd069de788cd9bc211438f1459180fedb138b18283a1ea22babce722c + Using explicit pod manifest: .\pod-hostedsystem.json + E0707 17:46:22.310248 5692 remote_runtime.go:237] "RunPodSandbox from runtime service failed" err="rpc error: code = Unknown desc = failed to start sandbox \"77485df817152875f0262968ccfb241ea2440445b69c59e69f2f249e2d6abb72\": failed to create containerd task: failed to create shim task: failed to do initial GCS setup: failed to configure HVSOCK for external GCS: guest modify: guest RPC failure: HvSocket is not supported" + time="2026-07-07T17:46:22Z" level=fatal msg="run pod sandbox: rpc error: code = Unknown desc = failed to start sandbox \"77485df817152875f0262968ccfb241ea2440445b69c59e69f2f249e2d6abb72\": failed to create containerd task: failed to create shim task: failed to do initial GCS setup: failed to configure HVSOCK for external GCS: guest modify: guest RPC failure: HvSocket is not supported" + + FAIL: runp.ps1 unexpectedly failed (Expect=ok, podID=''). + + The error comes from configureHvSocketForGCS in start.go + + */ settings := modifyGuestSettingsRequest.Settings.(*hcsschema.HvSocketAddress) log.G(ctx).Tracef("HvSocketAddress { %v }", settings) From 4fc5d27ed0d2ad15640e086947a16f948b0bd3ee Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Thu, 9 Jul 2026 09:14:11 +0100 Subject: [PATCH 40/56] Enforce ContainerRootPath format in CWCOWCombinedLayers Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index 1812437e2a..d4e1b1352e 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -1463,6 +1463,11 @@ func (b *Bridge) modifySettings(req *request) (err error) { log.G(ctx).Tracef("CWCOWCombinedLayers:: ContainerID: %v, ContainerRootPath: %v, Layers: %v, ScratchPath: %v", containerID, settings.CombinedLayers.ContainerRootPath, settings.CombinedLayers.Layers, settings.CombinedLayers.ScratchPath) + if matched, merr := regexp.MatchString(`(?i)^[Cc]:\\mounts\\scsi\\m[0-9]+$`, settings.CombinedLayers.ContainerRootPath); merr != nil || !matched { + return fmt.Errorf("combined-layers container root path %q does not match expected pattern c:\\mounts\\scsi\\m", + settings.CombinedLayers.ContainerRootPath) + } + // The layers size is only one, as this is the volume path if len(settings.CombinedLayers.Layers) != 1 { return fmt.Errorf("expected exactly one layer in CWCOWCombinedLayers, got %d", len(settings.CombinedLayers.Layers)) From 7c8ee5e956c5c587e409f0606f67740e25886543 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Thu, 9 Jul 2026 09:22:23 +0100 Subject: [PATCH 41/56] Revert accidental change of allowUnsupportedForDebug Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 36 ++++++-------------------------- 1 file changed, 6 insertions(+), 30 deletions(-) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index d4e1b1352e..1b67b5128c 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -39,12 +39,6 @@ const ( UVMContainerID = "00000000-0000-0000-0000-000000000000" ) -// allowUnsupportedForDebug, when true, downgrades the "not supported" and -// "unsupported request type" denials in this file to a logged warning plus -// pass-through, so the full RPC flow can be captured in the sidecar log. -// TEMPORARY: must be set back to false before shipping. -var allowUnsupportedForDebug = true - // - Handler functions handle the incoming message requests. It // also enforces security policy for confidential cwcow containers. // - These handler functions may do some additional processing before @@ -180,10 +174,7 @@ func (b *Bridge) createContainer(req *request) (err error) { // Reject HostedSystem Container fields we don't yet support. if err := denyUnsupportedContainerFields(container); err != nil { - if !allowUnsupportedForDebug { - return fmt.Errorf("CreateContainer operation rejected: %w", err) - } - log.G(ctx).Warnf("DEBUG: allowing unsupported container field: %v", err) + return fmt.Errorf("CreateContainer operation rejected: %w", err) } // Enforce registry changes policy. This may drop unauthorized @@ -1139,10 +1130,7 @@ func (b *Bridge) modifySettings(req *request) (err error) { // we don't have a policy enforcer for it. settings := modifyGuestSettingsRequest.Settings.(*guestresource.WCOWCombinedLayers) log.G(ctx).Tracef("WCOWCombinedLayers: {%v}", settings) - if !allowUnsupportedForDebug { - return fmt.Errorf("WCOWCombinedLayers is not supported.") - } - log.G(ctx).Warn("DEBUG: allowing unsupported ResourceTypeCombinedLayers") + return fmt.Errorf("WCOWCombinedLayers is not supported.") case guestresource.ResourceTypeNetworkNamespace: // Forwarded to inbox GCS without enforcement, by design: the host @@ -1161,10 +1149,7 @@ func (b *Bridge) modifySettings(req *request) (err error) { case guestresource.ResourceTypeMappedVirtualDisk: settings := modifyGuestSettingsRequest.Settings.(*guestresource.WCOWMappedVirtualDisk) log.G(ctx).Tracef("WCOWMappedVirtualDisk: {%v}", settings) - if !allowUnsupportedForDebug { - return fmt.Errorf("MappedVirtualDisk is not supported") - } - log.G(ctx).Warn("DEBUG: allowing unsupported ResourceTypeMappedVirtualDisk") + return fmt.Errorf("MappedVirtualDisk is not supported") case guestresource.ResourceTypeHvSocket: // Forwarded without enforcement: this configures the hvsock relay for @@ -1219,10 +1204,7 @@ func (b *Bridge) modifySettings(req *request) (err error) { return fmt.Errorf("mapped directory unmount is denied by policy: %w", err) } default: - if !allowUnsupportedForDebug { - return fmt.Errorf("unsupported request type %v for MappedDirectory", modifyGuestSettingsRequest.RequestType) - } - log.G(ctx).Warnf("DEBUG: allowing unsupported request type %v for MappedDirectory", modifyGuestSettingsRequest.RequestType) + return fmt.Errorf("unsupported request type %v for MappedDirectory", modifyGuestSettingsRequest.RequestType) } case guestresource.ResourceTypeSecurityPolicy: @@ -1376,10 +1358,7 @@ func (b *Bridge) modifySettings(req *request) (err error) { delete(b.hostState.blockCIMVolumeHashes, volGUID) delete(b.hostState.blockCIMVolumeContainers, volGUID) default: - if !allowUnsupportedForDebug { - return fmt.Errorf("unsupported request type %v for WCOWBlockCims", modifyGuestSettingsRequest.RequestType) - } - log.G(ctx).Warnf("DEBUG: allowing unsupported request type %v for WCOWBlockCims", modifyGuestSettingsRequest.RequestType) + return fmt.Errorf("unsupported request type %v for WCOWBlockCims", modifyGuestSettingsRequest.RequestType) } // Send response back to shim resp := &prot.ResponseBase{ @@ -1529,10 +1508,7 @@ func (b *Bridge) modifySettings(req *request) (err error) { return fmt.Errorf("scratch unmounting denied by policy: %w", err) } default: - if !allowUnsupportedForDebug { - return fmt.Errorf("unsupported request type %v for CWCOWCombinedLayers", modifyGuestSettingsRequest.RequestType) - } - log.G(ctx).Warnf("DEBUG: allowing unsupported request type %v for CWCOWCombinedLayers", modifyGuestSettingsRequest.RequestType) + return fmt.Errorf("unsupported request type %v for CWCOWCombinedLayers", modifyGuestSettingsRequest.RequestType) } // Reconstruct WCOWCombinedLayers{} req before forwarding to GCS From ffd54b0f5d1e48f34e0a2326db94b50160784ef2 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Thu, 9 Jul 2026 09:59:50 +0100 Subject: [PATCH 42/56] gcs-sidecar: validate container ID format in createContainer Reject host-supplied container IDs that aren't a containerd-style identifier (alphanumeric segments joined by single ./_/-) before recording or forwarding them, as defense-in-depth against the ID being joined into a filesystem path downstream. Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index 1b67b5128c..1dfc995135 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -75,6 +75,9 @@ func (b *Bridge) createContainer(req *request) (err error) { container := cwcowHostedSystem.Container spec := cwcowHostedSystemConfig.Spec containerID := createContainerRequest.ContainerID + if err := validateContainerID(containerID); err != nil { + return fmt.Errorf("CreateContainer operation is denied by policy: %w", err) + } containerJSON, _ := json.Marshal(container) log.G(ctx).Tracef("rpcCreate: CWCOWHostedSystemConfig {spec: %v, schemaVersion: %v, container: %s}}", string(req.message), schemaVersion, containerJSON) @@ -510,6 +513,21 @@ func reconcileHostedSystemStorage(host *Host, containerID string, container *hcs return nil } +// containerIDRegex matches the identifier format used for container IDs: one +// or more alphanumeric segments joined by single '.', '_' or '-' separators +// (the same shape containerd enforces for identifiers). GUIDs and hex digests +// both satisfy it. It rejects empty strings, path separators, ".." and +// absolute paths, so a host-supplied container ID cannot be used to escape an +// intended directory if it is later joined into a filesystem path. +var containerIDRegex = regexp.MustCompile(`^[a-zA-Z0-9]+(?:[._-][a-zA-Z0-9]+)*$`) + +func validateContainerID(id string) error { + if !containerIDRegex.MatchString(id) { + return fmt.Errorf("invalid container ID %q", id) + } + return nil +} + // denyUnsupportedContainerFields rejects HostedSystem Container fields that the // sidecar does not yet enforce a policy over. They may be needed in the future, // but until we have enforcement for them we block them rather than forward From 4f1ce185b863ae87cc880403ab48a89876b547e5 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Thu, 9 Jul 2026 11:40:17 +0100 Subject: [PATCH 43/56] Update comment for HvSocket in modifySettings Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 35 ++------------------------------ 1 file changed, 2 insertions(+), 33 deletions(-) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index 1dfc995135..17095d6410 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -1170,39 +1170,8 @@ func (b *Bridge) modifySettings(req *request) (err error) { return fmt.Errorf("MappedVirtualDisk is not supported") case guestresource.ResourceTypeHvSocket: - // Forwarded without enforcement: this configures the hvsock relay for - // the external GCS (the sidecar) during UVM setup, so it must be - // allowed. hvsock addressing is host-controlled regardless. - /* - \"Settings\":{\"LocalAddress\":\"3d9f5488-46b8-5fea-bfc6-e58193392bd5\",\"ParentAddress\":\"894cc2d6-9d79-424f-93fe-42969ae6d8d1\"}}}\n" - - internal\gcs\prot\protocol.go - var WindowsGcsHvHostID = guid.GUID{ - Data1: 0x894cc2d6, - Data2: 0x9d79, - Data3: 0x424f, - Data4: [8]uint8{0x93, 0xfe, 0x42, 0x96, 0x9a, 0xe6, 0xd8, 0xd1}, - } - - - If we block this: - - PS C:\w\readonly-mount\test> .\run.ps1 -Mode snp -PodJson .\pod-hostedsystem.json -ContainerJson .\container-hostedsystem.json - ACR login OK (takurosatodevacr); token suppressed. - Image is up to date for sha256:9abd13771394646ea3d5730f2a018dee30edee5956be43dcdb4df5f671b0e8bf - a9ac1a798a2bb5f77e6c02d949d4a7823b59978cb0e0beec41d645a37be4bf7e - a9ac1a798a2bb5f77e6c02d949d4a7823b59978cb0e0beec41d645a37be4bf7e - Stopped sandbox 5f4a393fd069de788cd9bc211438f1459180fedb138b18283a1ea22babce722c - Removed sandbox 5f4a393fd069de788cd9bc211438f1459180fedb138b18283a1ea22babce722c - Using explicit pod manifest: .\pod-hostedsystem.json - E0707 17:46:22.310248 5692 remote_runtime.go:237] "RunPodSandbox from runtime service failed" err="rpc error: code = Unknown desc = failed to start sandbox \"77485df817152875f0262968ccfb241ea2440445b69c59e69f2f249e2d6abb72\": failed to create containerd task: failed to create shim task: failed to do initial GCS setup: failed to configure HVSOCK for external GCS: guest modify: guest RPC failure: HvSocket is not supported" - time="2026-07-07T17:46:22Z" level=fatal msg="run pod sandbox: rpc error: code = Unknown desc = failed to start sandbox \"77485df817152875f0262968ccfb241ea2440445b69c59e69f2f249e2d6abb72\": failed to create containerd task: failed to create shim task: failed to do initial GCS setup: failed to configure HVSOCK for external GCS: guest modify: guest RPC failure: HvSocket is not supported" - - FAIL: runp.ps1 unexpectedly failed (Expect=ok, podID=''). - - The error comes from configureHvSocketForGCS in start.go - - */ + // Forwarded without enforcement: this is just for configuration + // to help guest to resolve hvsocket targets. settings := modifyGuestSettingsRequest.Settings.(*hcsschema.HvSocketAddress) log.G(ctx).Tracef("HvSocketAddress { %v }", settings) From 917ff3c1cbfb0fec9cc3c2121aa55ed9b44b8583 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Thu, 9 Jul 2026 15:36:30 +0100 Subject: [PATCH 44/56] Revert temporary early logs Signed-off-by: Takuro Sato --- cmd/gcs-sidecar/main.go | 1 - 1 file changed, 1 deletion(-) diff --git a/cmd/gcs-sidecar/main.go b/cmd/gcs-sidecar/main.go index c0871ee6bd..736ac8ed21 100644 --- a/cmd/gcs-sidecar/main.go +++ b/cmd/gcs-sidecar/main.go @@ -146,7 +146,6 @@ func main() { } defer logFileHandle.Close() - logrus.SetOutput(logFileHandle) logrus.AddHook(shimlog.NewHook()) level, err := logrus.ParseLevel(*logLevel) From d6d65b78d32929e08e3ef894bce149dd6bbcf603 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Wed, 15 Jul 2026 14:18:34 +0100 Subject: [PATCH 45/56] gcs-sidecar: deny combined-layers unmount while container root is in use Track container termination from the guest container-exit notification and refuse a CWCOWCombinedLayers Remove whose root is still used by a running container (cf. LCOW Host.IsOverlayInUse). Signed-off-by: Takuro Sato --- internal/gcs-sidecar/bridge.go | 18 ++++++++++++++ internal/gcs-sidecar/handlers.go | 6 +++++ internal/gcs-sidecar/handlers_test.go | 36 +++++++++++++++++++++++++++ internal/gcs-sidecar/host.go | 25 +++++++++++++++++++ 4 files changed, 85 insertions(+) diff --git a/internal/gcs-sidecar/bridge.go b/internal/gcs-sidecar/bridge.go index 1923ee3ded..8d32ac160a 100644 --- a/internal/gcs-sidecar/bridge.go +++ b/internal/gcs-sidecar/bridge.go @@ -463,6 +463,24 @@ func (b *Bridge) ListenAndServeShimRequests() error { b.pendingMu.Unlock() } + // If this is a container-exit notification, mark the container + // terminated so a later combined-layers unmount isn't blocked as + // in-use. + const MsgNotifyContainer prot.MsgType = prot.MsgTypeNotify | prot.ComputeSystem | prot.NotifyContainer + + if header.Type == MsgNotifyContainer { + var ntf prot.ContainerNotification + ntf.ResultInfo.Value = &json.RawMessage{} + if uerr := json.Unmarshal(message, &ntf); uerr != nil { + log.G(ctx).WithError(uerr).Error("failed to unmarshal container notification") + } else if c, cerr := b.hostState.GetCreatedContainer(ctx, ntf.ContainerID); cerr == nil { + // A not-found error just means the notification is for + // something we don't track (the UVM itself, or a container + // already deleted). + c.terminated.Store(true) + } + } + // Forward to shim resp := bridgeResponse{ ctx: context.Background(), diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index 17095d6410..e994fbfeb9 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -1491,6 +1491,12 @@ func (b *Bridge) modifySettings(req *request) (err error) { case guestrequest.RequestTypeRemove: log.G(ctx).Tracef("CWCOWCombinedLayers: Remove") + // Refuse to unmount the combined-layers root while a running + // container still uses it as its rootfs, so the host can't swap a + // live container's rootfs (cf. LCOW Host.IsOverlayInUse). + if b.hostState.IsContainerRootInUse(settings.CombinedLayers.ContainerRootPath) { + return fmt.Errorf("combined-layers unmount denied: container root %q is in use by a running container", settings.CombinedLayers.ContainerRootPath) + } if err := b.hostState.securityOptions.PolicyEnforcer.EnforceScratchUnmountPolicy(ctx, settings.CombinedLayers.ContainerRootPath); err != nil { return fmt.Errorf("scratch unmounting denied by policy: %w", err) } diff --git a/internal/gcs-sidecar/handlers_test.go b/internal/gcs-sidecar/handlers_test.go index 3235adb548..ecf4cefbce 100644 --- a/internal/gcs-sidecar/handlers_test.go +++ b/internal/gcs-sidecar/handlers_test.go @@ -967,3 +967,39 @@ func TestExecuteProcess_InitExec_AllowsStdioKeepsPipes(t *testing.T) { t.Errorf("stdio pipes should be preserved when allowed: %+v", gotParams) } } + +// TestIsContainerRootInUse verifies that a container's combined-layers root is +// treated as in-use only while the container is running (not terminated), and +// only for the matching root path (case-insensitive). +func TestIsContainerRootInUse(t *testing.T) { + b := newTestBridge(&securitypolicy.OpenDoorSecurityPolicyEnforcer{}) + host := b.hostState + + const cid = "container-1" + const rootPath = `C:\mounts\scsi\m0` + + c := &Container{id: cid, processes: make(map[uint32]*containerProcess)} + if err := host.AddContainer(context.Background(), cid, c); err != nil { + t.Fatalf("AddContainer: %v", err) + } + host.containerRootPaths[cid] = rootPath + + // Running container: its root is in use. + if !host.IsContainerRootInUse(rootPath) { + t.Errorf("expected root %q to be in use for a running container", rootPath) + } + // Paths compare with EqualFold, so a differently-cased path still matches. + if !host.IsContainerRootInUse(`c:\mounts\scsi\m0`) { + t.Errorf("expected case-insensitive match for %q", rootPath) + } + // An unrelated path is not in use. + if host.IsContainerRootInUse(`C:\mounts\scsi\m1`) { + t.Errorf("did not expect unrelated path to be in use") + } + + // Once the container has exited, its root is no longer in use. + c.terminated.Store(true) + if host.IsContainerRootInUse(rootPath) { + t.Errorf("expected root %q to be free after container terminated", rootPath) + } +} diff --git a/internal/gcs-sidecar/host.go b/internal/gcs-sidecar/host.go index f9dd336f35..fefe6fdacc 100644 --- a/internal/gcs-sidecar/host.go +++ b/internal/gcs-sidecar/host.go @@ -6,7 +6,9 @@ package bridge import ( "context" "io" + "strings" "sync" + "sync/atomic" "github.com/Microsoft/go-winio/pkg/guid" "github.com/Microsoft/hcsshim/internal/bridgeutils/gcserr" @@ -42,6 +44,9 @@ type Container struct { commandLineExec bool // allowStdio is the create-time stdio-access policy decision. allowStdio bool + // terminated is set once the container's init process has exited (via the + // guest container-exit notification). + terminated atomic.Bool } // Process is a struct that defines the lifetime and operations associated with @@ -109,6 +114,26 @@ func (h *Host) GetCreatedContainer(ctx context.Context, id string) (*Container, return c, nil } +// IsContainerRootInUse reports whether a container that has not exited is still +// using the combined-layers root mounted at rootPath as its rootfs, so the +// sidecar can refuse to unmount it. (cf. LCOW Host.IsOverlayInUse in +// internal/guest/runtime/hcsv2/uvm.go; WCOW uses a filesystem filter / combined +// layers rather than an overlayfs.) +func (h *Host) IsContainerRootInUse(rootPath string) bool { + h.containersMutex.Lock() + defer h.containersMutex.Unlock() + + for id, c := range h.containers { + if c.terminated.Load() { + continue + } + if strings.EqualFold(h.containerRootPaths[id], rootPath) { + return true + } + } + return false +} + // GetProcess returns the Process with the matching 'pid'. If the 'pid' does // not exit returns error. func (c *Container) GetProcess(pid uint32) (*containerProcess, error) { From b58d0a47cacfcd3e3050feff393f8c0c1a1be6a6 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Wed, 15 Jul 2026 14:48:44 +0100 Subject: [PATCH 46/56] gcs-sidecar: enforce deleteContainerState (deny running / still-mounted container) Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 22 ++++++++++- internal/gcs-sidecar/handlers_test.go | 57 +++++++++++++++++++++++++++ internal/gcs-sidecar/host.go | 38 ++++++++++++++++++ 3 files changed, 115 insertions(+), 2 deletions(-) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index e994fbfeb9..3d140f5461 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -1007,11 +1007,26 @@ func (b *Bridge) deleteContainerState(req *request) (err error) { if err := commonutils.UnmarshalJSONWithHresult(req.message, &r); err != nil { return fmt.Errorf("failed to unmarshal deleteContainerState: %w", err) } - err = b.hostState.RemoveContainer(req.ctx, r.ContainerID) + + // Refuse to delete the state of a container that is still running, or whose + // combined-layers root is still mounted, so the host can't wipe a live + // container's rootfs (cf. LCOW Host.DeleteContainerState). + c, err := b.hostState.GetCreatedContainer(req.ctx, r.ContainerID) if err != nil { log.G(req.ctx).Tracef("Container not found during deleteContainerState: %v", r.ContainerID) return fmt.Errorf("container not found: %w", err) } + if !c.terminated.Load() { + return fmt.Errorf("deleteContainerState denied: container %s is still running", r.ContainerID) + } + if b.hostState.IsContainerRootMountedForContainer(r.ContainerID) { + return fmt.Errorf("deleteContainerState denied: container %s combined-layers root is still mounted", r.ContainerID) + } + + if err = b.hostState.RemoveContainer(req.ctx, r.ContainerID); err != nil { + log.G(req.ctx).Tracef("Container not found during deleteContainerState: %v", r.ContainerID) + return fmt.Errorf("container not found: %w", err) + } b.forwardRequestToGcs(req) return nil @@ -1472,8 +1487,10 @@ func (b *Bridge) modifySettings(req *request) (err error) { } // Record the container root path so createContainer can cross-check - // the forwarded Storage.Path against it. + // the forwarded Storage.Path against it, and mark the root mounted so + // deleteContainerState can refuse deletion until it's unmounted. b.hostState.containerRootPaths[containerID] = settings.CombinedLayers.ContainerRootPath + b.hostState.SetContainerRootMounted(settings.CombinedLayers.ContainerRootPath, true) // The following two folders are expected to be present in the scratch. // But since we have just formatted the scratch we would need to // create them manually. @@ -1500,6 +1517,7 @@ func (b *Bridge) modifySettings(req *request) (err error) { if err := b.hostState.securityOptions.PolicyEnforcer.EnforceScratchUnmountPolicy(ctx, settings.CombinedLayers.ContainerRootPath); err != nil { return fmt.Errorf("scratch unmounting denied by policy: %w", err) } + b.hostState.SetContainerRootMounted(settings.CombinedLayers.ContainerRootPath, false) default: return fmt.Errorf("unsupported request type %v for CWCOWCombinedLayers", modifyGuestSettingsRequest.RequestType) } diff --git a/internal/gcs-sidecar/handlers_test.go b/internal/gcs-sidecar/handlers_test.go index ecf4cefbce..6959ebf5f2 100644 --- a/internal/gcs-sidecar/handlers_test.go +++ b/internal/gcs-sidecar/handlers_test.go @@ -1003,3 +1003,60 @@ func TestIsContainerRootInUse(t *testing.T) { t.Errorf("expected root %q to be free after container terminated", rootPath) } } + +// TestDeleteContainerState_DeniesRunningOrMounted verifies deleteContainerState +// refuses to delete the state of a container that is still running or whose +// combined-layers root is still mounted, and allows it once terminated and +// unmounted. +func TestDeleteContainerState_DeniesRunningOrMounted(t *testing.T) { + const cid = "container-1" + const rootPath = `C:\mounts\scsi\m0` + + newReq := func() *request { + msg, err := json.Marshal(prot.DeleteContainerStateRequest{ + RequestBase: prot.RequestBase{ContainerID: cid}, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return &request{ + ctx: context.Background(), + header: messageHeader{ + Type: prot.MsgTypeRequest | prot.MsgType(prot.RPCDeleteContainerState), + Size: uint32(len(msg)) + prot.HdrSize, + ID: 1, + }, + message: msg, + } + } + + b := newTestBridge(&securitypolicy.OpenDoorSecurityPolicyEnforcer{}) + c := &Container{id: cid, processes: make(map[uint32]*containerProcess)} + if err := b.hostState.AddContainer(context.Background(), cid, c); err != nil { + t.Fatalf("AddContainer: %v", err) + } + b.hostState.containerRootPaths[cid] = rootPath + b.hostState.SetContainerRootMounted(rootPath, true) + + // Still running -> denied. + if err := b.deleteContainerState(newReq()); err == nil || !strings.Contains(err.Error(), "still running") { + t.Fatalf("expected running denial, got %v", err) + } + + // Terminated but root still mounted -> denied. + c.terminated.Store(true) + if err := b.deleteContainerState(newReq()); err == nil || !strings.Contains(err.Error(), "still mounted") { + t.Fatalf("expected mounted denial, got %v", err) + } + + // Terminated and unmounted -> allowed and forwarded to GCS. + b.hostState.SetContainerRootMounted(rootPath, false) + if err := b.deleteContainerState(newReq()); err != nil { + t.Fatalf("expected allow, got %v", err) + } + select { + case <-b.sendToGCSCh: + case <-time.After(time.Second): + t.Fatal("expected request forwarded to GCS") + } +} diff --git a/internal/gcs-sidecar/host.go b/internal/gcs-sidecar/host.go index fefe6fdacc..ca390fccdf 100644 --- a/internal/gcs-sidecar/host.go +++ b/internal/gcs-sidecar/host.go @@ -33,6 +33,11 @@ type Host struct { // CWCOWCombinedLayers mounted it, used to validate the createContainer // Storage.Path. containerRootPaths map[string]string + // mountedRoots holds the combined-layers container roots that are currently + // mounted (set on CWCOWCombinedLayers Add, cleared on Remove), keyed by the + // lower-cased root path. Used to refuse deleting a container whose root is + // still mounted. + mountedRoots map[string]struct{} } type Container struct { @@ -70,6 +75,7 @@ func NewHost(initialEnforcer securitypolicy.SecurityPolicyEnforcer, logWriter io blockCIMVolumeHashes: make(map[guid.GUID][]string), blockCIMVolumeContainers: make(map[guid.GUID]map[string]struct{}), containerRootPaths: make(map[string]string), + mountedRoots: make(map[string]struct{}), securityOptions: securityPolicyOptions, } } @@ -97,6 +103,9 @@ func (h *Host) RemoveContainer(ctx context.Context, id string) error { return gcserr.NewHresultError(gcserr.HrVmcomputeSystemNotFound) } + if rootPath, ok := h.containerRootPaths[id]; ok { + delete(h.mountedRoots, strings.ToLower(rootPath)) + } delete(h.containers, id) delete(h.containerRootPaths, id) return nil @@ -134,6 +143,35 @@ func (h *Host) IsContainerRootInUse(rootPath string) bool { return false } +// SetContainerRootMounted records (mounted=true) or clears (mounted=false) +// whether the combined-layers root at rootPath is currently mounted. +func (h *Host) SetContainerRootMounted(rootPath string, mounted bool) { + h.containersMutex.Lock() + defer h.containersMutex.Unlock() + + key := strings.ToLower(rootPath) + if mounted { + h.mountedRoots[key] = struct{}{} + } else { + delete(h.mountedRoots, key) + } +} + +// IsContainerRootMountedForContainer reports whether the combined-layers root +// recorded for the given container is still mounted. +// (cf. LCOW hostMounts.HasOverlayMountedAt) +func (h *Host) IsContainerRootMountedForContainer(cid string) bool { + h.containersMutex.Lock() + defer h.containersMutex.Unlock() + + rootPath, ok := h.containerRootPaths[cid] + if !ok { + return false + } + _, mounted := h.mountedRoots[strings.ToLower(rootPath)] + return mounted +} + // GetProcess returns the Process with the matching 'pid'. If the 'pid' does // not exit returns error. func (c *Container) GetProcess(pid uint32) (*containerProcess, error) { From 09645b9886ab8c6f8e09a30e41ee65f8acd46868 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Thu, 16 Jul 2026 17:07:19 +0100 Subject: [PATCH 47/56] Note why Windows create-time mounts keep no state Signed-off-by: Takuro Sato --- pkg/securitypolicy/framework.rego | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pkg/securitypolicy/framework.rego b/pkg/securitypolicy/framework.rego index 68919c671e..53a1a01759 100644 --- a/pkg/securitypolicy/framework.rego +++ b/pkg/securitypolicy/framework.rego @@ -914,6 +914,19 @@ mountConstraint_ok(constraint, mount) { # rely on the destination + options (mirroring the top-level mapped_directories # rule, which matches on container_path + read_only). windows_mount_type_ok # rejects disk/device mount types so they can't pass as a directory. +# +# Note on state: this create-time match (reached via mountList_ok in the Windows +# create_container) records NO per-container mount state - unlike LCOW, where a +# container's mounts are established by separate, independently-unmountable +# modifySettings ops (plan9_mount / scsi / overlay) whose metadata +# create_container then reads via mountSource_ok. We deliberately track nothing +# here, resting on the assumption that there is no operation to "remove mount X +# from container Y" independently of the container: a Windows container's +# create-time mounts live and die with the container (torn down wholesale when +# its combined layers are removed), so no independent unmount could ever consume +# such state - hence there is nothing to track. (The UVM-level mapped directory +# added/removed via mapped_directory_mount / mapped_directory_unmount has a +# separate mechanism that keeps its own state.) mountConstraint_ok(constraint, mount) { is_windows windows_mount_type_ok(mount) From 8c8e2a5567eb8e5900788b514093b1d2bccd2023 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Fri, 17 Jul 2026 10:48:39 +0100 Subject: [PATCH 48/56] gcs-sidecar: fail closed on forwarded mount/unmount failure Watch the inbox GCS response for CWCOWCombinedLayers and MappedDirectory mount/unmount requests. If the inbox reports a failure, mark the UVM inconsistent so all further container creation/deletion and mount/unmount are refused (cf. LCOW setUVMInconsistent). Why fail closed instead of reverting the policy state: the sidecar forwards these operations to the inbox GCS rather than performing them itself, so it cannot observe or cleanly undo their effects. A real revert would need synchronous request/response correlation plus careful undo of the staged rego metadata and sidecar caches. Failing closed is simpler and safe: we never continue on policy state that may be out of sync with what is actually mounted. Signed-off-by: Takuro Sato --- internal/gcs-sidecar/bridge.go | 60 +++++++++ internal/gcs-sidecar/handlers.go | 172 +++++++++++++++++--------- internal/gcs-sidecar/handlers_test.go | 128 +++++++++++++++++++ internal/gcs-sidecar/host.go | 57 +++++++++ 4 files changed, 357 insertions(+), 60 deletions(-) diff --git a/internal/gcs-sidecar/bridge.go b/internal/gcs-sidecar/bridge.go index d94ad9b677..8b061ec809 100644 --- a/internal/gcs-sidecar/bridge.go +++ b/internal/gcs-sidecar/bridge.go @@ -35,6 +35,16 @@ type Bridge struct { pendingMu sync.Mutex pending map[sequenceID]chan *prot.ContainerExecuteProcessResponse + // monitoredMu guards monitoredIDs. + monitoredMu sync.Mutex + // monitoredIDs holds request IDs of forwarded combined-layers / + // mapped-directory mount/unmount operations whose inbox GCS response must be + // watched. The sidecar forwards those operations rather than performing them, + // so it cannot revert the policy state it staged; if the inbox reports a + // failure the UVM is failed closed (see monitorInboxResponse and + // Host.setUVMInconsistent). + monitoredIDs map[sequenceID]struct{} + hostState *Host // List of handlers for handling different rpc message requests. rpcHandlerList map[prot.RPCProc]HandlerFunc @@ -81,6 +91,7 @@ func NewBridge(shimConn io.ReadWriteCloser, inboxGCSConn io.ReadWriteCloser, ini hostState := NewHost(initialEnforcer, logWriter) return &Bridge{ pending: make(map[sequenceID]chan *prot.ContainerExecuteProcessResponse), + monitoredIDs: make(map[sequenceID]struct{}), rpcHandlerList: make(map[prot.RPCProc]HandlerFunc), hostState: hostState, shimConn: shimConn, @@ -220,6 +231,36 @@ func (b *Bridge) forwardRequestToGcs(req *request) { b.sendToGCSCh <- *req } +// monitorInboxResponse records that the inbox GCS response for the given +// request ID must be watched. It is used for forwarded combined-layers and +// mapped-directory mount/unmount operations, whose real work happens in the +// inbox GCS: because the sidecar cannot revert the policy state it staged for +// them, a failure response fails the UVM closed instead (see the receive loop +// and Host.setUVMInconsistent). +func (b *Bridge) monitorInboxResponse(id sequenceID) { + b.monitoredMu.Lock() + b.monitoredIDs[id] = struct{}{} + b.monitoredMu.Unlock() +} + +// responseFailure returns a non-nil error if the inbox GCS response message +// reports the operation failed (non-zero HResult). A response that cannot be +// parsed is treated as success (nil) so a malformed message does not by itself +// fail the UVM closed. +func responseFailure(message []byte) error { + var base prot.ResponseBase + if err := json.Unmarshal(message, &base); err != nil { + return nil + } + if base.Result != 0 { + if base.ErrorMessage != "" { + return errors.New(base.ErrorMessage) + } + return fmt.Errorf("inbox GCS returned HResult 0x%x", uint32(base.Result)) + } + return nil +} + func getContextAndSpan(baseSpanCtx *prot.Ocspancontext) (context.Context, *trace.Span) { var ctx context.Context var span *trace.Span @@ -483,6 +524,25 @@ func (b *Bridge) ListenAndServeShimRequests() error { } } + // If this response correlates to a forwarded mount/unmount + // operation we are monitoring (combined-layers or mapped + // directory) and it reports a failure, the sidecar's policy state + // may now be out of sync with what is actually mounted. Since we + // forwarded rather than performed the operation, we cannot safely + // revert; fail the UVM closed instead so no further container or + // mount operations proceed on possibly-desynced state. + b.monitoredMu.Lock() + _, monitored := b.monitoredIDs[header.ID] + if monitored { + delete(b.monitoredIDs, header.ID) + } + b.monitoredMu.Unlock() + if monitored { + if respErr := responseFailure(message); respErr != nil { + b.hostState.setUVMInconsistent(fmt.Errorf("forwarded mount/unmount operation (request %d) failed in inbox GCS: %w", header.ID, respErr)) + } + } + // Forward to shim resp := bridgeResponse{ ctx: context.Background(), diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index af17c26709..514b950ce8 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -57,6 +57,12 @@ func (b *Bridge) createContainer(req *request) (err error) { defer span.End() defer func() { oc.SetSpanStatus(span, err) }() + // Refuse to create containers once the UVM has been marked inconsistent by a + // failed forwarded mount/unmount (cf. LCOW Host.checkState). + if err := b.hostState.checkState(); err != nil { + return fmt.Errorf("CreateContainer denied: %w", err) + } + var createContainerRequest prot.ContainerCreate var containerConfig json.RawMessage createContainerRequest.ContainerConfig.Value = &containerConfig @@ -1066,6 +1072,12 @@ func (b *Bridge) deleteContainerState(req *request) (err error) { defer span.End() defer func() { oc.SetSpanStatus(span, err) }() + // Refuse to delete container state once the UVM has been marked inconsistent + // by a failed forwarded mount/unmount (cf. LCOW Host.checkState). + if err := b.hostState.checkState(); err != nil { + return fmt.Errorf("deleteContainerState denied: %w", err) + } + var r prot.DeleteContainerStateRequest if err := commonutils.UnmarshalJSONWithHresult(req.message, &r); err != nil { return fmt.Errorf("failed to unmarshal deleteContainerState: %w", err) @@ -1216,6 +1228,20 @@ func (b *Bridge) modifySettings(req *request) (err error) { return fmt.Errorf("invald guestRequestType %v", guestRequestType) } + // If a previously forwarded mount/unmount operation failed in the inbox GCS, + // the sidecar's policy state may be out of sync with what is actually mounted + // and cannot be safely recovered, so refuse all further settings changes + // (cf. LCOW checkState gating in internal/guest/runtime/hcsv2/uvm.go). + if err := b.hostState.checkState(); err != nil { + return fmt.Errorf("modifySettings denied: %w", err) + } + + // monitorResponse is set for forwarded combined-layers / mapped-directory + // operations whose real work happens in the inbox GCS. Their inbox response + // is watched (see monitorInboxResponse) so a failure fails the UVM closed, + // since the sidecar cannot revert the policy state it staged for them. + monitorResponse := false + // Question: should we enforce policy for each type? Maybe just reject if we don't implement policy? if guestResourceType != "" { switch guestResourceType { @@ -1271,6 +1297,10 @@ func (b *Bridge) modifySettings(req *request) (err error) { default: return fmt.Errorf("unsupported request type %v for MappedDirectory", modifyGuestSettingsRequest.RequestType) } + // The sidecar enforced policy here but the actual VSMB mount/unmount + // happens in the inbox GCS, so watch its response and fail closed on + // failure (the staged policy metadata cannot be reverted). + monitorResponse = true case guestresource.ResourceTypeSecurityPolicy: securityPolicyRequest := modifyGuestSettingsRequest.Settings.(*guestresource.ConfidentialOptions) @@ -1377,32 +1407,35 @@ func (b *Bridge) modifySettings(req *request) (err error) { // Volume GUID from request. volGUID := wcowBlockCimMounts.VolumeGUID - err := b.hostState.securityOptions.PolicyEnforcer.EnforceVerifiedCIMsPolicy(req.ctx, containerID, hashesToVerify, mountedCim, volGUID.String()) - if err != nil { - return errors.Wrap(err, "CIM mount is denied by policy") - } - - // Cache hashes along with volGUID - b.hostState.blockCIMVolumeHashes[volGUID] = layerHashes - - // Store the containerID (associated with volGUID) to mark that hashes are verified for this container - if _, ok := b.hostState.blockCIMVolumeContainers[volGUID]; !ok { - b.hostState.blockCIMVolumeContainers[volGUID] = make(map[string]struct{}) - } - b.hostState.blockCIMVolumeContainers[volGUID][containerID] = struct{}{} - - log.G(ctx).Tracef("Cached %d verified CIM layer hashes for volume %s (container %s)", len(hashesToVerify), volGUID, containerID) + // Enforce policy, mount, then record the verified state as a single + // transaction: if the real mount fails after the policy check, + // WithMetadataRollback reverts the policy metadata and we skip the + // sidecar caches, so policy state can't desync from what is mounted. + if rberr := b.hostState.securityOptions.PolicyEnforcer.WithMetadataRollback(func() error { + if err := b.hostState.securityOptions.PolicyEnforcer.EnforceVerifiedCIMsPolicy(req.ctx, containerID, hashesToVerify, mountedCim, volGUID.String()); err != nil { + return errors.Wrap(err, "CIM mount is denied by policy") + } - if len(layerCIMs) > 1 { - _, err = cimfs.MountMergedVerifiedBlockCIMs(layerCIMs[0], layerCIMs[1:], wcowBlockCimMounts.MountFlags, wcowBlockCimMounts.VolumeGUID, layerDigests[0]) - if err != nil { - return fmt.Errorf("error mounting multilayer block cims: %w", err) + if len(layerCIMs) > 1 { + if _, merr := cimfs.MountMergedVerifiedBlockCIMs(layerCIMs[0], layerCIMs[1:], wcowBlockCimMounts.MountFlags, wcowBlockCimMounts.VolumeGUID, layerDigests[0]); merr != nil { + return fmt.Errorf("error mounting multilayer block cims: %w", merr) + } + } else { + if _, merr := cimfs.MountVerifiedBlockCIM(layerCIMs[0], wcowBlockCimMounts.MountFlags, wcowBlockCimMounts.VolumeGUID, layerDigests[0]); merr != nil { + return fmt.Errorf("error mounting verified block cim: %w", merr) + } } - } else { - _, err = cimfs.MountVerifiedBlockCIM(layerCIMs[0], wcowBlockCimMounts.MountFlags, wcowBlockCimMounts.VolumeGUID, layerDigests[0]) - if err != nil { - return fmt.Errorf("error mounting verified block cim: %w", err) + + // Real mount succeeded: record the verified state. + b.hostState.blockCIMVolumeHashes[volGUID] = layerHashes + if _, ok := b.hostState.blockCIMVolumeContainers[volGUID]; !ok { + b.hostState.blockCIMVolumeContainers[volGUID] = make(map[string]struct{}) } + b.hostState.blockCIMVolumeContainers[volGUID][containerID] = struct{}{} + log.G(ctx).Tracef("Cached %d verified CIM layer hashes for volume %s (container %s)", len(hashesToVerify), volGUID, containerID) + return nil + }); rberr != nil { + return rberr } case guestrequest.RequestTypeRemove: @@ -1526,48 +1559,58 @@ func (b *Bridge) modifySettings(req *request) (err error) { if err != nil { return fmt.Errorf("failed to parse volume GUID %s: %w", guidStr, err) } - hashes, haveHashes := b.hostState.blockCIMVolumeHashes[volGUID] - if haveHashes { - // Only do this if the ContainerID is not already seen for this volume - containers := b.hostState.blockCIMVolumeContainers[volGUID] - if _, seen := containers[containerID]; !seen { - // This is a container with similar layers as an existing container, hence already mounted. - // Call EnforceVerifiedCIMsPolicy on this new container. - hashesToVerify := hashes - mountedCim := []string{hashes[0]} - if len(hashes) > 1 { - hashesToVerify = hashes[1:] - } - if err := b.hostState.securityOptions.PolicyEnforcer.EnforceVerifiedCIMsPolicy(ctx, containerID, hashesToVerify, mountedCim, volGUID.String()); err != nil { - return fmt.Errorf("CIM mount is denied by policy for this container: %w", err) + + // Enforce policy and set up the scratch as a single transaction: if a + // later step (e.g. mkdir) fails, WithMetadataRollback reverts the + // policy metadata and we skip the sidecar caches, so policy state + // can't desync from reality. + if rberr := b.hostState.securityOptions.PolicyEnforcer.WithMetadataRollback(func() error { + hashes, haveHashes := b.hostState.blockCIMVolumeHashes[volGUID] + markVolumeContainer := false + if haveHashes { + // Only re-verify if this container hasn't been seen for this volume. + containers := b.hostState.blockCIMVolumeContainers[volGUID] + if _, seen := containers[containerID]; !seen { + hashesToVerify := hashes + mountedCim := []string{hashes[0]} + if len(hashes) > 1 { + hashesToVerify = hashes[1:] + } + if err := b.hostState.securityOptions.PolicyEnforcer.EnforceVerifiedCIMsPolicy(ctx, containerID, hashesToVerify, mountedCim, volGUID.String()); err != nil { + return fmt.Errorf("CIM mount is denied by policy for this container: %w", err) + } + log.G(ctx).Tracef("Verified CIM hashes for reused mount volume %s (container %s)", volGUID.String(), containerID) + markVolumeContainer = true } - log.G(ctx).Tracef("Verified CIM hashes for reused mount volume %s (container %s)", volGUID.String(), containerID) - containers[containerID] = struct{}{} } - } - if err := b.hostState.securityOptions.PolicyEnforcer.EnforceScratchMountPolicy(ctx, settings.CombinedLayers.ContainerRootPath, true); err != nil { - return fmt.Errorf("scratch mounting denied by policy: %w", err) - } + if err := b.hostState.securityOptions.PolicyEnforcer.EnforceScratchMountPolicy(ctx, settings.CombinedLayers.ContainerRootPath, true); err != nil { + return fmt.Errorf("scratch mounting denied by policy: %w", err) + } - // Record the container root path so createContainer can cross-check - // the forwarded Storage.Path against it, and mark the root mounted so - // deleteContainerState can refuse deletion until it's unmounted. - b.hostState.containerRootPaths[containerID] = settings.CombinedLayers.ContainerRootPath - b.hostState.SetContainerRootMounted(settings.CombinedLayers.ContainerRootPath, true) - // The following two folders are expected to be present in the scratch. - // But since we have just formatted the scratch we would need to - // create them manually. - sandboxStateDirectory := filepath.Join(settings.CombinedLayers.ContainerRootPath, sandboxStateDirName) - err = os.Mkdir(sandboxStateDirectory, 0777) - if err != nil { - return fmt.Errorf("failed to create sandboxStateDirectory: %w", err) - } + // The following two folders are expected to be present in the + // scratch. Since we just formatted it, create them manually. + sandboxStateDirectory := filepath.Join(settings.CombinedLayers.ContainerRootPath, sandboxStateDirName) + if err := os.Mkdir(sandboxStateDirectory, 0777); err != nil { + return fmt.Errorf("failed to create sandboxStateDirectory: %w", err) + } + hivesDirectory := filepath.Join(settings.CombinedLayers.ContainerRootPath, hivesDirName) + if err := os.Mkdir(hivesDirectory, 0777); err != nil { + return fmt.Errorf("failed to create hivesDirectory: %w", err) + } - hivesDirectory := filepath.Join(settings.CombinedLayers.ContainerRootPath, hivesDirName) - err = os.Mkdir(hivesDirectory, 0777) - if err != nil { - return fmt.Errorf("failed to create hivesDirectory: %w", err) + // Everything succeeded: record the sidecar state. containerRootPaths + // lets createContainer cross-check the forwarded Storage.Path, and + // the mounted-root flag lets deleteContainerState refuse deletion + // until the root is unmounted. + if markVolumeContainer { + b.hostState.blockCIMVolumeContainers[volGUID][containerID] = struct{}{} + } + b.hostState.containerRootPaths[containerID] = settings.CombinedLayers.ContainerRootPath + b.hostState.SetContainerRootMounted(settings.CombinedLayers.ContainerRootPath, true) + return nil + }); rberr != nil { + return rberr } case guestrequest.RequestTypeRemove: @@ -1586,6 +1629,12 @@ func (b *Bridge) modifySettings(req *request) (err error) { return fmt.Errorf("unsupported request type %v for CWCOWCombinedLayers", modifyGuestSettingsRequest.RequestType) } + // The sidecar enforced policy and staged the scratch here, but the + // actual union mount/unmount happens in the inbox GCS, so watch its + // response and fail closed on failure (the staged policy metadata and + // sidecar caches cannot be reverted). + monitorResponse = true + // Reconstruct WCOWCombinedLayers{} req before forwarding to GCS // as GCS does not understand ResourceTypeCWCOWCombinedLayers modifyGuestSettingsRequest.ResourceType = guestresource.ResourceTypeCombinedLayers @@ -1608,6 +1657,9 @@ func (b *Bridge) modifySettings(req *request) (err error) { } } + if monitorResponse { + b.monitorInboxResponse(req.header.ID) + } b.forwardRequestToGcs(req) return nil } diff --git a/internal/gcs-sidecar/handlers_test.go b/internal/gcs-sidecar/handlers_test.go index 6959ebf5f2..02730a4a09 100644 --- a/internal/gcs-sidecar/handlers_test.go +++ b/internal/gcs-sidecar/handlers_test.go @@ -6,6 +6,7 @@ package bridge import ( "context" "encoding/json" + "errors" "io" "reflect" "strings" @@ -52,6 +53,7 @@ func newTestBridge(enforcer securitypolicy.SecurityPolicyEnforcer) *Bridge { host := NewHost(enforcer, io.Discard) return &Bridge{ pending: make(map[sequenceID]chan *prot.ContainerExecuteProcessResponse), + monitoredIDs: make(map[sequenceID]struct{}), rpcHandlerList: make(map[prot.RPCProc]HandlerFunc), hostState: host, sendToGCSCh: make(chan request, 10), @@ -59,6 +61,132 @@ func newTestBridge(enforcer securitypolicy.SecurityPolicyEnforcer) *Bridge { } } +// TestResponseFailure verifies responseFailure classifies inbox GCS responses: +// a zero Result is success, a non-zero Result is a failure, and an unparseable +// message is treated as success so a malformed message cannot by itself fail +// the UVM closed. +func TestResponseFailure(t *testing.T) { + mustMarshal := func(v interface{}) []byte { + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return b + } + + tests := []struct { + name string + message []byte + wantErr bool + }{ + {name: "success", message: mustMarshal(prot.ResponseBase{Result: 0}), wantErr: false}, + {name: "failure with message", message: mustMarshal(prot.ResponseBase{Result: 1, ErrorMessage: "boom"}), wantErr: true}, + {name: "failure without message", message: mustMarshal(prot.ResponseBase{Result: 1}), wantErr: true}, + {name: "unparseable", message: []byte("not json"), wantErr: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := responseFailure(tt.message) + if (err != nil) != tt.wantErr { + t.Fatalf("responseFailure() err = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +// TestCheckState_BlocksHandlers verifies that once the UVM is marked +// inconsistent, container creation/deletion and settings changes are refused +// (fail-closed), matching the LCOW behavior. +func TestCheckState_BlocksHandlers(t *testing.T) { + b := newTestBridge(&securitypolicy.OpenDoorSecurityPolicyEnforcer{}) + + // Before failing closed, checkState is clear. + if err := b.hostState.checkState(); err != nil { + t.Fatalf("checkState should be nil before setUVMInconsistent, got %v", err) + } + + b.hostState.setUVMInconsistent(errors.New("inbox mount failed")) + + if err := b.hostState.checkState(); err == nil { + t.Fatal("checkState should be non-nil after setUVMInconsistent") + } + + // createContainer refuses before it even parses the request (gate is at the top). + createReq := &request{ + ctx: context.Background(), + header: messageHeader{Type: prot.MsgTypeRequest | prot.MsgType(prot.RPCCreate), ID: 1}, + } + if err := b.createContainer(createReq); err == nil { + t.Error("createContainer should be denied when UVM is inconsistent") + } + + // deleteContainerState refuses similarly. + deleteReq := &request{ + ctx: context.Background(), + header: messageHeader{Type: prot.MsgTypeRequest | prot.MsgType(prot.RPCDeleteContainerState), ID: 2}, + } + if err := b.deleteContainerState(deleteReq); err == nil { + t.Error("deleteContainerState should be denied when UVM is inconsistent") + } + + // modifySettings refuses too (checkState runs after unmarshalling a valid request). + msg := buildModifySettingsRequest(t, + guestresource.ResourceTypeSecurityPolicy, + guestrequest.RequestTypeAdd, + guestresource.ConfidentialOptions{EnforcerType: "rego"}, + ) + modifyReq := &request{ + ctx: context.Background(), + header: messageHeader{Type: prot.MsgTypeRequest | prot.MsgType(prot.RPCModifySettings), Size: uint32(len(msg)) + prot.HdrSize, ID: 3}, + message: msg, + } + if err := b.modifySettings(modifyReq); err == nil { + t.Error("modifySettings should be denied when UVM is inconsistent") + } +} + +// TestModifySettings_MappedDirectory_TagsInboxResponse verifies that a forwarded +// mapped-directory operation registers its request ID for inbox-response +// monitoring and is forwarded to the inbox GCS, so a later failure response can +// fail the UVM closed. +func TestModifySettings_MappedDirectory_TagsInboxResponse(t *testing.T) { + b := newTestBridge(&securitypolicy.OpenDoorSecurityPolicyEnforcer{}) + + msg := buildModifySettingsRequest(t, + guestresource.ResourceTypeMappedDirectory, + guestrequest.RequestTypeAdd, + hcsschema.MappedDirectory{ContainerPath: `C:\mnt\ro`, ReadOnly: true}, + ) + const id sequenceID = 77 + req := &request{ + ctx: context.Background(), + header: messageHeader{Type: prot.MsgTypeRequest | prot.MsgType(prot.RPCModifySettings), Size: uint32(len(msg)) + prot.HdrSize, ID: id}, + message: msg, + } + + if err := b.modifySettings(req); err != nil { + t.Fatalf("modifySettings returned error: %v", err) + } + + // The request ID must be registered for monitoring. + b.monitoredMu.Lock() + _, monitored := b.monitoredIDs[id] + b.monitoredMu.Unlock() + if !monitored { + t.Errorf("mapped-directory request ID %d was not registered for inbox-response monitoring", id) + } + + // And the request must have been forwarded to the inbox GCS. + select { + case got := <-b.sendToGCSCh: + if got.header.ID != id { + t.Errorf("forwarded request ID = %d, want %d", got.header.ID, id) + } + default: + t.Error("mapped-directory request was not forwarded to inbox GCS") + } +} + // TestModifySettings_PolicyFragment_InvalidFragment tests that a PolicyFragment // request with an invalid (non-base64, non-COSE) fragment value returns an error // from the handler. The bridge's main loop converts handler errors into error diff --git a/internal/gcs-sidecar/host.go b/internal/gcs-sidecar/host.go index 9b54b16742..1e289730b4 100644 --- a/internal/gcs-sidecar/host.go +++ b/internal/gcs-sidecar/host.go @@ -5,6 +5,7 @@ package bridge import ( "context" + "fmt" "io" "strings" "sync" @@ -38,6 +39,46 @@ type Host struct { // lower-cased root path. Used to refuse deleting a container whose root is // still mounted. mountedRoots map[string]struct{} + + // uvmError is set when the UVM has entered an inconsistent state from which + // the sidecar cannot safely recover. Once set, checkState makes all further + // container creation/deletion and mount/unmount operations fail (cf. LCOW + // hcsv2 Host.uvmError in internal/guest/runtime/hcsv2/uvm.go). See the + // setUVMInconsistent call sites for the conditions that trigger it. + uvmError uvmConsistencyError +} + +// uvmConsistencyError records that the UVM has entered an inconsistent state +// from which the sidecar cannot safely recover, so it must fail closed. See the +// setUVMInconsistent call sites for what can cause this. +type uvmConsistencyError struct { + mu sync.Mutex + // cause is the error describing why the UVM entered an inconsistent state. + // If nil, Check returns nil. + cause error +} + +// Set records the cause of the inconsistency, keeping the first cause if one is +// already set. +func (u *uvmConsistencyError) Set(cause error) { + u.mu.Lock() + defer u.mu.Unlock() + if u.cause == nil { + u.cause = cause + } +} + +// Check returns a non-nil error if the UVM has been marked inconsistent. +func (u *uvmConsistencyError) Check() error { + u.mu.Lock() + defer u.mu.Unlock() + if u.cause == nil { + return nil + } + return fmt.Errorf( + "mount, unmount, container creation and deletion have been disabled in this UVM due to a previous error: %w", + u.cause, + ) } type Container struct { @@ -81,6 +122,22 @@ func NewHost(initialEnforcer securitypolicy.SecurityPolicyEnforcer, logWriter io } } +// checkState returns an error if the UVM has entered an inconsistent state from +// which the sidecar cannot safely recover, in which case further mount/unmount, +// container creation and deletion must be refused. +func (h *Host) checkState() error { + return h.uvmError.Check() +} + +// setUVMInconsistent records that the UVM has entered an inconsistent state and +// logs the cause. After this, checkState refuses further operations. The caller +// passes the specific cause; see its call sites for the conditions that trigger +// it. +func (h *Host) setUVMInconsistent(cause error) { + h.uvmError.Set(cause) + log.G(context.Background()).WithError(cause).Error("Host marked inconsistent. All further mounts/unmounts, container creation and deletion will fail.") +} + func (h *Host) AddContainer(ctx context.Context, id string, c *Container) error { h.containersMutex.Lock() defer h.containersMutex.Unlock() From a012abc1a66c740811181afa71d6ac8a9fd634ba Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Fri, 17 Jul 2026 13:32:04 +0100 Subject: [PATCH 49/56] Reject duplicate CWCOWCombinedLayers Add Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 8 +++++ internal/gcs-sidecar/handlers_test.go | 47 +++++++++++++++++++++++++++ internal/gcs-sidecar/host.go | 12 +++++++ 3 files changed, 67 insertions(+) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index 514b950ce8..d1c7774057 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -1541,6 +1541,14 @@ func (b *Bridge) modifySettings(req *request) (err error) { log.G(ctx).Tracef("CWCOWCombinedLayers:: ContainerID: %v, ContainerRootPath: %v, Layers: %v, ScratchPath: %v", containerID, settings.CombinedLayers.ContainerRootPath, settings.CombinedLayers.Layers, settings.CombinedLayers.ScratchPath) + // Combined layers are set up once per container. Reject a repeated + // Add for the same container: otherwise a second Add with a + // different root would overwrite containerRootPaths[containerID] + // and leak the previous root's mounted-root entry. + if b.hostState.HasContainerRoot(containerID) { + return fmt.Errorf("combined layers already set up for container %q", containerID) + } + if matched, merr := regexp.MatchString(`(?i)^[Cc]:\\mounts\\scsi\\m[0-9]+$`, settings.CombinedLayers.ContainerRootPath); merr != nil || !matched { return fmt.Errorf("combined-layers container root path %q does not match expected pattern c:\\mounts\\scsi\\m", settings.CombinedLayers.ContainerRootPath) diff --git a/internal/gcs-sidecar/handlers_test.go b/internal/gcs-sidecar/handlers_test.go index 02730a4a09..f1816fc3c0 100644 --- a/internal/gcs-sidecar/handlers_test.go +++ b/internal/gcs-sidecar/handlers_test.go @@ -1132,6 +1132,53 @@ func TestIsContainerRootInUse(t *testing.T) { } } +// TestModifySettings_CombinedLayers_RejectsDuplicateAdd verifies that a second +// CWCOWCombinedLayers Add for a container that already has combined layers set +// up is rejected, so a repeated Add can't overwrite the recorded root path or +// leak the previous root's mounted-root entry. +func TestModifySettings_CombinedLayers_RejectsDuplicateAdd(t *testing.T) { + b := newTestBridge(&securitypolicy.OpenDoorSecurityPolicyEnforcer{}) + + const cid = "container-1" + const rootPath = `C:\mounts\scsi\m0` + + // Pretend combined layers were already set up for this container. + b.hostState.containerRootPaths[cid] = rootPath + b.hostState.SetContainerRootMounted(rootPath, true) + + msg := buildModifySettingsRequest(t, + guestresource.ResourceTypeCWCOWCombinedLayers, + guestrequest.RequestTypeAdd, + guestresource.CWCOWCombinedLayers{ + ContainerID: cid, + CombinedLayers: guestresource.WCOWCombinedLayers{ + ContainerRootPath: `C:\mounts\scsi\m1`, + Layers: []hcsschema.Layer{{Path: rootPath}}, + }, + }, + ) + req := &request{ + ctx: context.Background(), + header: messageHeader{Type: prot.MsgTypeRequest | prot.MsgType(prot.RPCModifySettings), Size: uint32(len(msg)) + prot.HdrSize, ID: 1}, + message: msg, + } + + err := b.modifySettings(req) + if err == nil || !strings.Contains(err.Error(), "already set up") { + t.Fatalf("expected duplicate-add denial, got %v", err) + } + + // The recorded root path must be unchanged and nothing forwarded to GCS. + if got := b.hostState.containerRootPaths[cid]; got != rootPath { + t.Errorf("containerRootPaths[%q] = %q, want %q (unchanged)", cid, got, rootPath) + } + select { + case <-b.sendToGCSCh: + t.Error("duplicate CombinedLayers Add must not be forwarded to inbox GCS") + default: + } +} + // TestDeleteContainerState_DeniesRunningOrMounted verifies deleteContainerState // refuses to delete the state of a container that is still running or whose // combined-layers root is still mounted, and allows it once terminated and diff --git a/internal/gcs-sidecar/host.go b/internal/gcs-sidecar/host.go index 1e289730b4..90d573d632 100644 --- a/internal/gcs-sidecar/host.go +++ b/internal/gcs-sidecar/host.go @@ -215,6 +215,18 @@ func (h *Host) SetContainerRootMounted(rootPath string, mounted bool) { } } +// HasContainerRoot reports whether a combined-layers root has already been +// recorded for the given container. It lets the CWCOWCombinedLayers Add handler +// stay idempotent: a second Add for the same container would otherwise overwrite +// containerRootPaths[cid] and leak the previous root's mounted-root entry. +func (h *Host) HasContainerRoot(cid string) bool { + h.containersMutex.Lock() + defer h.containersMutex.Unlock() + + _, ok := h.containerRootPaths[cid] + return ok +} + // IsContainerRootMountedForContainer reports whether the combined-layers root // recorded for the given container is still mounted. // (cf. LCOW hostMounts.HasOverlayMountedAt) From 6ea1608bf3db2da761d2f8dd07919a6d25c65f05 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Fri, 17 Jul 2026 13:55:02 +0100 Subject: [PATCH 50/56] Roll back policy state on block-CIM unmount failure Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index d1c7774057..a9e28f4df7 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -1443,19 +1443,28 @@ func (b *Bridge) modifySettings(req *request) (err error) { wcowBlockCimMounts := modifyGuestSettingsRequest.Settings.(*guestresource.CWCOWBlockCIMMounts) volGUID := wcowBlockCimMounts.VolumeGUID - if err := b.hostState.securityOptions.PolicyEnforcer.EnforceCIMUnmountPolicy(req.ctx, volGUID.String()); err != nil { - return fmt.Errorf("CIM unmount is denied by policy: %w", err) - } + // Enforce policy, unmount, then drop the cached state as a single + // transaction: unmount_cims removes the mountedCimVolumes record, + // so if the real unmount fails after the policy check, + // WithMetadataRollback restores that record and we skip the cache + // deletes, keeping policy state in sync with what is mounted. + if rberr := b.hostState.securityOptions.PolicyEnforcer.WithMetadataRollback(func() error { + if err := b.hostState.securityOptions.PolicyEnforcer.EnforceCIMUnmountPolicy(req.ctx, volGUID.String()); err != nil { + return fmt.Errorf("CIM unmount is denied by policy: %w", err) + } - volumePath := fmt.Sprintf(cimfs.VolumePathFormat, volGUID.String()) - err := cimfs.Unmount(volumePath) - if err != nil { - return fmt.Errorf("error unmounting block cim: %w", err) - } + volumePath := fmt.Sprintf(cimfs.VolumePathFormat, volGUID.String()) + if err := cimfs.Unmount(volumePath); err != nil { + return fmt.Errorf("error unmounting block cim: %w", err) + } - // Drop the cached mount state now that the volume is gone. - delete(b.hostState.blockCIMVolumeHashes, volGUID) - delete(b.hostState.blockCIMVolumeContainers, volGUID) + // Real unmount succeeded: drop the cached mount state. + delete(b.hostState.blockCIMVolumeHashes, volGUID) + delete(b.hostState.blockCIMVolumeContainers, volGUID) + return nil + }); rberr != nil { + return rberr + } default: return fmt.Errorf("unsupported request type %v for WCOWBlockCims", modifyGuestSettingsRequest.RequestType) } From 25c489dab47659a90140050e80d55bba50b25034 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Wed, 22 Jul 2026 11:27:50 +0100 Subject: [PATCH 51/56] Improve mount_cims errors Signed-off-by: Takuro Sato --- pkg/securitypolicy/framework.rego | 42 +++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/pkg/securitypolicy/framework.rego b/pkg/securitypolicy/framework.rego index 53a1a01759..8c7fd10609 100644 --- a/pkg/securitypolicy/framework.rego +++ b/pkg/securitypolicy/framework.rego @@ -2121,6 +2121,37 @@ errors["no matching containers for overlay"] { not overlay_matches } +default cim_matches := false + +cim_matches { + some container in candidate_containers + layerHashes_ok(container.layers) + input.mountedCim == container.mounted_cim +} + +errors["the container image layers have already been matched by a prior mount_cims"] { + input.rule == "mount_cims" + overlay_exists +} + +errors["no matching containers for CIM mount"] { + input.rule == "mount_cims" + not overlay_exists + not cim_matches +} + +# Actionable hint for the common misconfiguration: a policy that uses CIM +# mounts (mounted_cim) but declares a framework_version older than when CIM +# support was added. In that case check_container reconstructs the container +# without mounted_cim, so cim_matches can never be true and the mount is denied. +errors[cimVersionError] { + input.rule == "mount_cims" + not overlay_exists + not cim_matches + semver.compare(policy_framework_version, "0.5.0") < 0 + cimVersionError := concat(" ", ["policy framework_version", policy_framework_version, "predates CIM mount support (mounted_cim added in 0.5.0); set it to the UVM framework version:", version]) +} + default privileged_matches := false privileged_matches { @@ -2963,6 +2994,7 @@ check_container(raw_container, framework_version) := container { "user": check_user(raw_container, framework_version), "capabilities": check_capabilities(raw_container, framework_version), "seccomp_profile_sha256": check_seccomp_profile_sha256(raw_container, framework_version), + "mounted_cim": check_mounted_cim(raw_container, framework_version), } } @@ -3032,6 +3064,16 @@ check_signals(raw_container, framework_version) := signals { signals := array.concat(raw_container.signals, [9, 15]) } +check_mounted_cim(raw_container, framework_version) := mounted_cim { + semver.compare(framework_version, "0.5.0") >= 0 + mounted_cim := object.get(raw_container, "mounted_cim", []) +} + +check_mounted_cim(raw_container, framework_version) := mounted_cim { + semver.compare(framework_version, "0.5.0") < 0 + mounted_cim := [] +} + check_external_process(raw_process, framework_version) := process { semver.compare(framework_version, version) == 0 process := raw_process From ae95830476e87e176bac7ff1d2065b520eaaa8b8 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Wed, 22 Jul 2026 11:38:54 +0100 Subject: [PATCH 52/56] Allow GuestOs to be set in createContainer Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index a9e28f4df7..40afffc1b4 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -586,24 +586,30 @@ func validateContainerID(id string) error { // Memory, Processor and Networking are deliberately not checked: the host // controls the UVM's resources and networking regardless, so there is nothing // we can meaningfully enforce over them here. +// GuestOs is not checked as it just sets hostname string. func denyUnsupportedContainerFields(container *hcsschema.Container) error { if container == nil { return nil } - if container.GuestOs != nil { - return fmt.Errorf("GuestOs is not supported") - } + + // In case we get any error here, we include entire container JSON + // in the error message for debugging so that we know all the fields + // that need to be enforced by policy. + + // Error is ignored as it's a best-effort debug string. + containerJSON, _ := json.Marshal(container) + if container.HvSocket != nil { - return fmt.Errorf("HvSocket is not supported") + return fmt.Errorf("HvSocket is not supported. Container: %s", containerJSON) } if container.ContainerCredentialGuard != nil { - return fmt.Errorf("ContainerCredentialGuard is not supported") + return fmt.Errorf("ContainerCredentialGuard is not supported. Container: %s", containerJSON) } if len(container.AssignedDevices) > 0 { - return fmt.Errorf("AssignedDevices is not supported") + return fmt.Errorf("AssignedDevices is not supported. Container: %s", containerJSON) } if container.AdditionalDeviceNamespace != nil { - return fmt.Errorf("AdditionalDeviceNamespace is not supported") + return fmt.Errorf("AdditionalDeviceNamespace is not supported. Container: %s", containerJSON) } return nil } @@ -1252,7 +1258,7 @@ func (b *Bridge) modifySettings(req *request) (err error) { // we don't have a policy enforcer for it. settings := modifyGuestSettingsRequest.Settings.(*guestresource.WCOWCombinedLayers) log.G(ctx).Tracef("WCOWCombinedLayers: {%v}", settings) - return fmt.Errorf("WCOWCombinedLayers is not supported.") + return fmt.Errorf("WCOWCombinedLayers is not supported") case guestresource.ResourceTypeNetworkNamespace: // Forwarded to inbox GCS without enforcement, by design: the host @@ -1271,7 +1277,9 @@ func (b *Bridge) modifySettings(req *request) (err error) { case guestresource.ResourceTypeMappedVirtualDisk: settings := modifyGuestSettingsRequest.Settings.(*guestresource.WCOWMappedVirtualDisk) log.G(ctx).Tracef("WCOWMappedVirtualDisk: {%v}", settings) - return fmt.Errorf("MappedVirtualDisk is not supported") + // Error is ignored as it's a best-effort debug string. + settingsJSON, _ := json.Marshal(settings) + return fmt.Errorf("MappedVirtualDisk is not supported. Settings: %s", settingsJSON) case guestresource.ResourceTypeHvSocket: // Forwarded without enforcement: this is just for configuration From 97900450cc766c5325629defd2d58bb7aa9f9b64 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Wed, 22 Jul 2026 13:15:35 +0100 Subject: [PATCH 53/56] Forward MappedVirtualDisk Remove so container scratch can be detached The container scratch disk is added via MappedVirtualDiskForContainerScratch but removed as a plain MappedVirtualDisk; forward the Remove (a harmless detach) so teardown completes, while still rejecting a raw Add. Restores the mount -> unmount -> re-mount lifecycle for the same container root. Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index 40afffc1b4..479c37e817 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -1277,9 +1277,22 @@ func (b *Bridge) modifySettings(req *request) (err error) { case guestresource.ResourceTypeMappedVirtualDisk: settings := modifyGuestSettingsRequest.Settings.(*guestresource.WCOWMappedVirtualDisk) log.G(ctx).Tracef("WCOWMappedVirtualDisk: {%v}", settings) - // Error is ignored as it's a best-effort debug string. - settingsJSON, _ := json.Marshal(settings) - return fmt.Errorf("MappedVirtualDisk is not supported. Settings: %s", settingsJSON) + // The container scratch disk is *added* via + // ResourceTypeMappedVirtualDiskForContainerScratch (which formats it + // and rewrites the request to MappedVirtualDisk before forwarding), + // but it is *removed* as a plain MappedVirtualDisk. So a Remove here + // is the scratch (or other disk) detach on teardown and must be + // forwarded to the inbox GCS: rejecting it leaves the scratch + // attached, which breaks a later re-mount of the same container root. + // Detaching a disk grants no access, so forwarding Remove is safe. A + // raw Add, on the other hand, is the host trying to attach an + // arbitrary disk we don't enforce over, so it stays rejected. + if modifyGuestSettingsRequest.RequestType != guestrequest.RequestTypeRemove { + // Error is ignored as it's a best-effort debug string. + settingsJSON, _ := json.Marshal(settings) + return fmt.Errorf("MappedVirtualDisk Add is not supported. Settings: %s", settingsJSON) + } + // Remove falls through to forwardRequestToGcs below. case guestresource.ResourceTypeHvSocket: // Forwarded without enforcement: this is just for configuration From 498c4b3ec734ad1963bae75905cabe17e376fa64 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Tue, 4 Aug 2026 17:18:37 +0100 Subject: [PATCH 54/56] Fix comment on reconcileHostedSystemMounts Signed-off-by: Takuro Sato --- internal/gcs-sidecar/handlers.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/gcs-sidecar/handlers.go b/internal/gcs-sidecar/handlers.go index 479c37e817..8825df16d5 100644 --- a/internal/gcs-sidecar/handlers.go +++ b/internal/gcs-sidecar/handlers.go @@ -417,8 +417,9 @@ func mountReadOnly(options []string) bool { // this binds the forwarded HostedSystem to that enforced view and rejects any // host-added mount the policy never saw. Note that HostPath is intentionally // not compared: the spec source is a host-side path while the HostedSystem -// HostPath is a VSMB path, so they legitimately differ and the host controls -// both regardless. +// HostPath is the path the host resolved the mount to for the UVM. +// So it legitimately differs from the spec source, +// and the host controls both regardless. func reconcileHostedSystemMounts(mounts []oci.Mount, container *hcsschema.Container) error { if container == nil { return nil From d97d6cf84b1de432fab7198fe1026aeb79a9808b Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Wed, 5 Aug 2026 15:49:07 +0100 Subject: [PATCH 55/56] Convert k (type WindowsRegistryKey) to registryKeyInternal instead of using struct literal --- pkg/securitypolicy/securitypolicy_internal.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/pkg/securitypolicy/securitypolicy_internal.go b/pkg/securitypolicy/securitypolicy_internal.go index 18da25eaf4..1a2d7115ff 100644 --- a/pkg/securitypolicy/securitypolicy_internal.go +++ b/pkg/securitypolicy/securitypolicy_internal.go @@ -392,11 +392,7 @@ func (r WindowsRegistryChanges) toInternal() registryChangesInternal { } deleteKeys := make([]registryKeyInternal, len(r.DeleteKeys)) for i, k := range r.DeleteKeys { - deleteKeys[i] = registryKeyInternal{ - Hive: k.Hive, - Name: k.Name, - Volatile: k.Volatile, - } + deleteKeys[i] = registryKeyInternal(k) } return registryChangesInternal{AddValues: addValues, DeleteKeys: deleteKeys} } From 1090eb882ce4781614029ca184187cb17105bb35 Mon Sep 17 00:00:00 2001 From: Takuro Sato Date: Thu, 6 Aug 2026 19:18:01 +0100 Subject: [PATCH 56/56] Check it's confidential before writing security context dir Signed-off-by: Takuro Sato --- internal/guest/runtime/hcsv2/uvm.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/guest/runtime/hcsv2/uvm.go b/internal/guest/runtime/hcsv2/uvm.go index 1df9d44596..903e98a973 100644 --- a/internal/guest/runtime/hcsv2/uvm.go +++ b/internal/guest/runtime/hcsv2/uvm.go @@ -738,10 +738,12 @@ func (h *Host) CreateContainer(ctx context.Context, id string, settings *prot.VM settings.OCISpecification.Process.Capabilities = capsToKeep } - // The security-context dir must always be written; it must not be gated by - // a host-controlled annotation. - if _, err := h.securityOptions.WriteSecurityContextDir(settings.OCISpecification); err != nil { - return nil, fmt.Errorf("failed to write security context dir: %w", err) + if h.HasSecurityPolicy() { + // The security-context dir must always be written for confidential containers; + // it must not be gated by a host-controlled annotation. + if _, err := h.securityOptions.WriteSecurityContextDir(settings.OCISpecification); err != nil { + return nil, fmt.Errorf("failed to write security context dir: %w", err) + } } // Create the BundlePath