diff --git a/openshift/openshift.go b/openshift/openshift.go index 13311a0..e93ddd9 100644 --- a/openshift/openshift.go +++ b/openshift/openshift.go @@ -40,6 +40,10 @@ const ( buildConfigSourceImagesPullSecret = "/spec/source/images/%v/pullSecret" buildConfigSourceImagesFrom = "/spec/source/images/%v/from/name" roleBindingSubject = "/subjects/%d/namespace" + + // SCCNamespaceUIDMin is the minimum UID value for OpenShift SCC-injected namespace UID ranges. + // UIDs >= this value are considered SCC-injected and should be stripped during migration. + SCCNamespaceUIDMin int64 = 1000000000 ) var defaultPullSecrets = []string{"builder-dockercfg-", "default-dockercfg-", "deployer-dockercfg-"} @@ -47,7 +51,7 @@ var defaultPullSecrets = []string{"builder-dockercfg-", "default-dockercfg-", "d func updateBuildConfigImageReference( imgRef v1.ObjectReference, imgPath string, - fields openshiftOptionalFields, + fields OpenshiftOptionalFields, ) (jsonpatch.Patch, error) { patch := jsonpatch.Patch{} var err error @@ -67,7 +71,7 @@ func updateBuildConfigImageReference( return patch, nil } -func UpdateDefaultPullSecrets(u unstructured.Unstructured, fields openshiftOptionalFields) (jsonpatch.Patch, error) { +func UpdateDefaultPullSecrets(u unstructured.Unstructured, fields OpenshiftOptionalFields) (jsonpatch.Patch, error) { return updateSecretsForSlice(getPullSecrets(u), podReplaceImagePullSecret, podRemoveImagePullSecret, fields) } @@ -75,7 +79,7 @@ func updateSecretsForSlice( pullSecrets []v1.LocalObjectReference, replaceOp string, removeOp string, - fields openshiftOptionalFields) (jsonpatch.Patch, error) { + fields OpenshiftOptionalFields) (jsonpatch.Patch, error) { var err error replacePatch := jsonpatch.Patch{} @@ -155,7 +159,7 @@ func UpdateRoleBinding(u unstructured.Unstructured) (jsonpatch.Patch, error) { func updateSecret( pullSecret *v1.LocalObjectReference, secretPath string, - fields openshiftOptionalFields) (jsonpatch.Patch, error) { + fields OpenshiftOptionalFields) (jsonpatch.Patch, error) { var err error patch := jsonpatch.Patch{} @@ -271,7 +275,7 @@ func isDefault(name string) bool { return false } -func UpdateBuildConfig(u unstructured.Unstructured, fields openshiftOptionalFields) (jsonpatch.Patch, error) { +func UpdateBuildConfig(u unstructured.Unstructured, fields OpenshiftOptionalFields) (jsonpatch.Patch, error) { jsonPatch := jsonpatch.Patch{} js, err := u.MarshalJSON() if err != nil { @@ -355,7 +359,7 @@ func UpdateBuildConfig(u unstructured.Unstructured, fields openshiftOptionalFiel return jsonPatch, nil } -func UpdateDeploymentConfig(u unstructured.Unstructured, fields openshiftOptionalFields) (jsonpatch.Patch, error) { +func UpdateDeploymentConfig(u unstructured.Unstructured, fields OpenshiftOptionalFields) (jsonpatch.Patch, error) { js, err := u.MarshalJSON() if err != nil { return nil, err @@ -417,3 +421,272 @@ func getSecretReferencesServiceAccount(u unstructured.Unstructured) []v1.ObjectR return sa.Secrets } + +// sccUID safely extracts a UID value from an interface{}, handling both int64 and float64 +// types. JSON unmarshaling of unstructured.Unstructured produces float64 for numeric values, +// so this helper ensures we can check SCC UID ranges regardless of the source type. +// Returns the int64 value and true if successful, or 0 and false if the type is neither. +func sccUID(v interface{}) (int64, bool) { + switch val := v.(type) { + case int64: + return val, true + case float64: + return int64(val), true + default: + return 0, false + } +} + +// StripSecurityContext removes SCC-injected security context values while preserving +// user-configured values. +// +// Only strips: +// - runAsUser when >= SCCNamespaceUIDMin (SCC-injected namespace UID range) +// - fsGroup when >= SCCNamespaceUIDMin (SCC-injected namespace UID range) +// - seLinuxOptions.level (always SCC-injected) +// +// Preserves all other security context values (capabilities, readOnlyRootFilesystem, etc.) +func StripSecurityContext(u unstructured.Unstructured) (jsonpatch.Patch, error) { + kind := u.GetKind() + + // Only process workload resources + if kind != "Pod" && kind != "Deployment" && kind != "StatefulSet" && + kind != "DaemonSet" && kind != "Job" && kind != "CronJob" && + kind != "ReplicaSet" && kind != "ReplicationController" { + return jsonpatch.Patch{}, nil + } + + // Create a copy to modify + modified := u.DeepCopy() + + // Determine base path for pod spec + var basePath []string + if kind == "Pod" { + basePath = []string{"spec"} + } else if kind == "CronJob" { + basePath = []string{"spec", "jobTemplate", "spec", "template", "spec"} + } else { + basePath = []string{"spec", "template", "spec"} + } + + // Strip SCC-injected values from pod-level securityContext + stripPodSecurityContext := func(scPath ...string) error { + sc, found, _ := unstructured.NestedMap(modified.Object, scPath...) + if !found || sc == nil { + return nil + } + + // Strip runAsUser if >= 1000000000 + if runAsUser, ok := sccUID(sc["runAsUser"]); ok && runAsUser >= SCCNamespaceUIDMin { + delete(sc, "runAsUser") + } + + // Strip fsGroup if >= 1000000000 + if fsGroup, ok := sccUID(sc["fsGroup"]); ok && fsGroup >= SCCNamespaceUIDMin { + delete(sc, "fsGroup") + } + + // Strip seLinuxOptions.level (always SCC-injected) + if seLinuxOpts, ok := sc["seLinuxOptions"].(map[string]interface{}); ok { + delete(seLinuxOpts, "level") + // If seLinuxOptions is now empty, remove it entirely + if len(seLinuxOpts) == 0 { + delete(sc, "seLinuxOptions") + } else { + sc["seLinuxOptions"] = seLinuxOpts + } + } + + // If securityContext is now empty, remove it entirely + if len(sc) == 0 { + unstructured.RemoveNestedField(modified.Object, scPath...) + } else { + if err := unstructured.SetNestedMap(modified.Object, sc, scPath...); err != nil { + return err + } + } + return nil + } + + // Strip pod-level security context + podSecurityContextPath := append(basePath, "securityContext") + if err := stripPodSecurityContext(podSecurityContextPath...); err != nil { + return nil, err + } + + // Helper function to strip container securityContext + stripContainerSecurityContext := func(containersPath ...string) error { + containers, found, _ := unstructured.NestedSlice(modified.Object, containersPath...) + if !found { + return nil + } + + for i, c := range containers { + container, ok := c.(map[string]interface{}) + if !ok { + continue + } + + sc, ok := container["securityContext"].(map[string]interface{}) + if !ok || sc == nil { + continue + } + + // Strip runAsUser if >= 1000000000 + if runAsUser, ok := sccUID(sc["runAsUser"]); ok && runAsUser >= SCCNamespaceUIDMin { + delete(sc, "runAsUser") + } + + // Strip seLinuxOptions.level (always SCC-injected) + if seLinuxOpts, ok := sc["seLinuxOptions"].(map[string]interface{}); ok { + delete(seLinuxOpts, "level") + // If seLinuxOptions is now empty, remove it entirely + if len(seLinuxOpts) == 0 { + delete(sc, "seLinuxOptions") + } else { + sc["seLinuxOptions"] = seLinuxOpts + } + } + + // If securityContext is now empty, remove it entirely + if len(sc) == 0 { + delete(container, "securityContext") + } else { + container["securityContext"] = sc + } + + containers[i] = container + } + if err := unstructured.SetNestedSlice(modified.Object, containers, containersPath...); err != nil { + return err + } + return nil + } + + // Strip container-level securityContext + containersPath := append(basePath, "containers") + if err := stripContainerSecurityContext(containersPath...); err != nil { + return nil, err + } + + // Strip initContainers securityContext + initContainersPath := append(basePath, "initContainers") + if err := stripContainerSecurityContext(initContainersPath...); err != nil { + return nil, err + } + + // Strip ephemeralContainers securityContext (if present) + ephemeralContainersPath := append(basePath, "ephemeralContainers") + if err := stripContainerSecurityContext(ephemeralContainersPath...); err != nil { + return nil, err + } + + // Generate patch by comparing original and modified + // Build JSON patch operations for fields that were removed + var patchOps []string + + // Helper to build path string + buildPath := func(parts ...string) string { + result := "" + for _, part := range parts { + if part != "" { + result += "/" + part + } + } + return result + } + + // Check what changed in pod-level securityContext + origSC, _, _ := unstructured.NestedMap(u.Object, append(basePath, "securityContext")...) + modSC, _, _ := unstructured.NestedMap(modified.Object, append(basePath, "securityContext")...) + + if origSC != nil && modSC != nil { + scPath := buildPath(basePath...) + "/securityContext" + // Check each field + if _, origHas := origSC["runAsUser"]; origHas { + if _, modHas := modSC["runAsUser"]; !modHas { + patchOps = append(patchOps, fmt.Sprintf(`{"op":"remove","path":"%s/runAsUser"}`, scPath)) + } + } + if _, origHas := origSC["fsGroup"]; origHas { + if _, modHas := modSC["fsGroup"]; !modHas { + patchOps = append(patchOps, fmt.Sprintf(`{"op":"remove","path":"%s/fsGroup"}`, scPath)) + } + } + if origOpts, ok := origSC["seLinuxOptions"].(map[string]interface{}); ok { + if modOpts, ok := modSC["seLinuxOptions"].(map[string]interface{}); ok { + if _, origHas := origOpts["level"]; origHas { + if _, modHas := modOpts["level"]; !modHas { + patchOps = append(patchOps, fmt.Sprintf(`{"op":"remove","path":"%s/seLinuxOptions/level"}`, scPath)) + } + } + } else if len(origOpts) > 0 { + // seLinuxOptions was removed entirely + if _, hasLevel := origOpts["level"]; hasLevel && len(origOpts) == 1 { + patchOps = append(patchOps, fmt.Sprintf(`{"op":"remove","path":"%s/seLinuxOptions"}`, scPath)) + } + } + } + } else if origSC != nil && modSC == nil { + // Entire securityContext was removed + patchOps = append(patchOps, fmt.Sprintf(`{"op":"remove","path":"%s"}`, buildPath(basePath...)+"/securityContext")) + } + + // Check container changes + checkContainerChanges := func(containerType string) { + origContainers, _, _ := unstructured.NestedSlice(u.Object, append(basePath, containerType)...) + modContainers, _, _ := unstructured.NestedSlice(modified.Object, append(basePath, containerType)...) + + for i := 0; i < len(origContainers) && i < len(modContainers); i++ { + origC, ok1 := origContainers[i].(map[string]interface{}) + modC, ok2 := modContainers[i].(map[string]interface{}) + if !ok1 || !ok2 { + continue + } + + origSC, _ := origC["securityContext"].(map[string]interface{}) + modSC, ok2 := modC["securityContext"].(map[string]interface{}) + + containerPath := buildPath(basePath...) + "/" + containerType + "/" + fmt.Sprintf("%d", i) + "/securityContext" + + if origSC != nil && modSC != nil { + if _, origHas := origSC["runAsUser"]; origHas { + if _, modHas := modSC["runAsUser"]; !modHas { + patchOps = append(patchOps, fmt.Sprintf(`{"op":"remove","path":"%s/runAsUser"}`, containerPath)) + } + } + if origOpts, ok := origSC["seLinuxOptions"].(map[string]interface{}); ok { + if modOpts, ok := modSC["seLinuxOptions"].(map[string]interface{}); ok { + if _, origHas := origOpts["level"]; origHas { + if _, modHas := modOpts["level"]; !modHas { + patchOps = append(patchOps, fmt.Sprintf(`{"op":"remove","path":"%s/seLinuxOptions/level"}`, containerPath)) + } + } + } else if len(origOpts) > 0 { + if _, hasLevel := origOpts["level"]; hasLevel && len(origOpts) == 1 { + patchOps = append(patchOps, fmt.Sprintf(`{"op":"remove","path":"%s/seLinuxOptions"}`, containerPath)) + } + } + } + } else if origSC != nil && !ok2 { + patchOps = append(patchOps, fmt.Sprintf(`{"op":"remove","path":"%s"}`, containerPath)) + } + } + } + + checkContainerChanges("containers") + checkContainerChanges("initContainers") + checkContainerChanges("ephemeralContainers") + + if len(patchOps) == 0 { + return jsonpatch.Patch{}, nil + } + + patchJSON := "[" + strings.Join(patchOps, ",") + "]" + patch, err := jsonpatch.DecodePatch([]byte(patchJSON)) + if err != nil { + return nil, err + } + + return patch, nil +} diff --git a/openshift/plugin.go b/openshift/plugin.go index d1b29a7..97a64f4 100644 --- a/openshift/plugin.go +++ b/openshift/plugin.go @@ -72,7 +72,7 @@ func (o *OpenShiftTransformPlugin) Run(request transform.PluginRequest) (transfo u := request.Unstructured var patch jsonpatch.Patch whiteOut := false - inputFields, err := parseOptionalFields(request.Extras) + inputFields, err := ParseOptionalFields(request.Extras) if err != nil { return transform.PluginResponse{}, err } @@ -110,7 +110,18 @@ func (o *OpenShiftTransformPlugin) Run(request transform.PluginRequest) (transfo patch, err = UpdateDeploymentConfig(u, inputFields) case "Pod": o.log().Info("found pod, processing update default pull secret") - patch, err = UpdateDefaultPullSecrets(u, inputFields) + pullSecretPatch, err := UpdateDefaultPullSecrets(u, inputFields) + if err != nil { + break + } + securityContextPatch, err := StripSecurityContext(u) + if err != nil { + break + } + patch = append(pullSecretPatch, securityContextPatch...) + case "Deployment", "StatefulSet", "DaemonSet", "Job", "CronJob", "ReplicaSet", "ReplicationController": + o.log().Infof("found %s, stripping SCC-injected security context", u.GetKind()) + patch, err = StripSecurityContext(u) case "Route": o.log().Info("found route, processing") patch, err = UpdateRoute(u) @@ -165,7 +176,8 @@ func (o *OpenShiftTransformPlugin) log() logrus.FieldLogger { return logrus.New() } -type openshiftOptionalFields struct { +// OpenshiftOptionalFields contains the optional configuration fields for OpenShift transformations +type OpenshiftOptionalFields struct { StripDefaultRBAC bool StripDefaultCABundle bool StripDefaultPullSecrets bool @@ -174,8 +186,9 @@ type openshiftOptionalFields struct { RegistryReplacement map[string]string } -func parseOptionalFields(extras map[string]string) (openshiftOptionalFields, error) { - fields := openshiftOptionalFields{ +// ParseOptionalFields parses the extras map into OpenshiftOptionalFields +func ParseOptionalFields(extras map[string]string) (OpenshiftOptionalFields, error) { + fields := OpenshiftOptionalFields{ StripDefaultRBAC: true, StripDefaultCABundle: true, StripDefaultPullSecrets: true, diff --git a/openshift/plugin_test.go b/openshift/plugin_test.go index d0f41a7..defc7be 100644 --- a/openshift/plugin_test.go +++ b/openshift/plugin_test.go @@ -2,6 +2,8 @@ package openshift import ( "bytes" + "encoding/json" + "fmt" "testing" "github.com/konveyor/crane-lib/transform" @@ -580,3 +582,592 @@ func findSubstring(s, substr string) bool { } return false } + +func TestStripSecurityContext(t *testing.T) { + tests := []struct { + name string + resource *unstructured.Unstructured + expectPatch bool + description string + }{ + { + name: "Strip SCC-injected runAsUser (>= 1000000000) from Pod", + resource: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]interface{}{ + "name": "test-pod", + "namespace": "test-ns", + }, + "spec": map[string]interface{}{ + "securityContext": map[string]interface{}{ + "runAsUser": int64(1000560000), + "fsGroup": int64(1000560000), + "seLinuxOptions": map[string]interface{}{ + "level": "s0:c26,c5", + }, + }, + "containers": []interface{}{ + map[string]interface{}{ + "name": "test-container", + "image": "test:latest", + "securityContext": map[string]interface{}{ + "runAsUser": int64(1000560000), + "seLinuxOptions": map[string]interface{}{ + "level": "s0:c26,c5", + }, + }, + }, + }, + }, + }, + }, + expectPatch: true, + description: "Should strip SCC-injected values (runAsUser, fsGroup >= 1000000000, seLinuxOptions.level)", + }, + { + name: "Keep user-configured runAsUser (< 1000000000) in Pod", + resource: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]interface{}{ + "name": "test-pod", + "namespace": "test-ns", + }, + "spec": map[string]interface{}{ + "securityContext": map[string]interface{}{ + "runAsUser": int64(1001), + "fsGroup": int64(2000), + }, + "containers": []interface{}{ + map[string]interface{}{ + "name": "test-container", + "image": "test:latest", + "securityContext": map[string]interface{}{ + "runAsUser": int64(1001), + "capabilities": map[string]interface{}{ + "drop": []interface{}{"ALL"}, + }, + }, + }, + }, + }, + }, + }, + expectPatch: false, + description: "Should keep user-configured runAsUser < 1000000000 and other security settings", + }, + { + name: "Keep readOnlyRootFilesystem and other user settings in Deployment", + resource: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": "test-deployment", + "namespace": "test-ns", + }, + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "securityContext": map[string]interface{}{ + "runAsUser": int64(1000560000), + "fsGroup": int64(2000), + }, + "containers": []interface{}{ + map[string]interface{}{ + "name": "test-container", + "image": "test:latest", + "securityContext": map[string]interface{}{ + "runAsUser": int64(1000560000), + "readOnlyRootFilesystem": true, + "allowPrivilegeEscalation": false, + "capabilities": map[string]interface{}{ + "drop": []interface{}{"ALL"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expectPatch: true, + description: "Should strip SCC-injected runAsUser but keep readOnlyRootFilesystem and other user settings", + }, + { + name: "Strip seLinuxOptions.level but keep other seLinuxOptions", + resource: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]interface{}{ + "name": "test-pod", + "namespace": "test-ns", + }, + "spec": map[string]interface{}{ + "securityContext": map[string]interface{}{ + "seLinuxOptions": map[string]interface{}{ + "level": "s0:c26,c5", + "type": "spc_t", + "user": "system_u", + "role": "system_r", + }, + }, + "containers": []interface{}{ + map[string]interface{}{ + "name": "test-container", + "image": "test:latest", + }, + }, + }, + }, + }, + expectPatch: true, + description: "Should strip seLinuxOptions.level but keep user-configured type, user, role", + }, + { + name: "Handle mixed SCC and user values in StatefulSet", + resource: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "StatefulSet", + "metadata": map[string]interface{}{ + "name": "test-statefulset", + "namespace": "test-ns", + }, + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "securityContext": map[string]interface{}{ + "runAsUser": int64(1000560000), // SCC-injected + "fsGroup": int64(1000560000), // SCC-injected + "runAsNonRoot": true, // user-configured + }, + "containers": []interface{}{ + map[string]interface{}{ + "name": "test-container", + "image": "test:latest", + "securityContext": map[string]interface{}{ + "runAsUser": int64(1000560000), // SCC-injected + "readOnlyRootFilesystem": true, // user-configured + "allowPrivilegeEscalation": false, // user-configured + }, + }, + }, + "initContainers": []interface{}{ + map[string]interface{}{ + "name": "init-container", + "image": "init:latest", + "securityContext": map[string]interface{}{ + "runAsUser": int64(1000560000), // SCC-injected + "seLinuxOptions": map[string]interface{}{ + "level": "s0:c26,c5", // SCC-injected + "type": "init_t", // user-configured + }, + }, + }, + }, + }, + }, + }, + }, + }, + expectPatch: true, + description: "Should handle mixed SCC-injected and user-configured values across containers and initContainers", + }, + { + name: "Handle CronJob with nested template", + resource: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "batch/v1", + "kind": "CronJob", + "metadata": map[string]interface{}{ + "name": "test-cronjob", + "namespace": "test-ns", + }, + "spec": map[string]interface{}{ + "jobTemplate": map[string]interface{}{ + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "securityContext": map[string]interface{}{ + "runAsUser": int64(1000560000), + "fsGroup": int64(1000560000), + }, + "containers": []interface{}{ + map[string]interface{}{ + "name": "test-container", + "image": "test:latest", + "securityContext": map[string]interface{}{ + "runAsUser": int64(1000560000), + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expectPatch: true, + description: "Should handle CronJob's nested jobTemplate structure", + }, + { + name: "Strip SCC-injected values from ephemeralContainers", + resource: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]interface{}{ + "name": "test-pod-with-ephemeral", + "namespace": "test-ns", + }, + "spec": map[string]interface{}{ + "containers": []interface{}{ + map[string]interface{}{ + "name": "main-container", + "image": "main:latest", + }, + }, + "ephemeralContainers": []interface{}{ + map[string]interface{}{ + "name": "debug-container", + "image": "debug:latest", + "securityContext": map[string]interface{}{ + "runAsUser": int64(1000560000), // SCC-injected + "seLinuxOptions": map[string]interface{}{ + "level": "s0:c26,c5", // SCC-injected + "type": "spc_t", // user-configured + }, + "capabilities": map[string]interface{}{ // user-configured + "add": []interface{}{"SYS_PTRACE"}, + }, + }, + }, + }, + }, + }, + }, + expectPatch: true, + description: "Should strip SCC-injected values from ephemeralContainers while preserving user-configured fields", + }, + { + name: "Non-workload resource should not be processed", + resource: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Service", + "metadata": map[string]interface{}{ + "name": "test-service", + "namespace": "test-ns", + }, + "spec": map[string]interface{}{ + "ports": []interface{}{ + map[string]interface{}{ + "port": 80, + }, + }, + }, + }, + }, + expectPatch: false, + description: "Should skip non-workload resources", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := &OpenShiftTransformPlugin{ + Log: logrus.New(), + } + + request := transform.PluginRequest{ + Unstructured: *tt.resource, + Extras: map[string]string{ + StripDefaultPullSecretsFlag: "false", + }, + } + + response, err := plugin.Run(request) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + hasPatch := len(response.Patches) > 0 + if hasPatch != tt.expectPatch { + t.Errorf("%s: expected patch=%v, got %v (patches: %+v)", tt.description, tt.expectPatch, hasPatch, response.Patches) + } + + // Apply patches and verify the result + if tt.expectPatch && len(response.Patches) > 0 { + originalJSON, err := tt.resource.MarshalJSON() + if err != nil { + t.Fatalf("failed to marshal original: %v", err) + } + + modifiedJSON, err := response.Patches.Apply(originalJSON) + if err != nil { + t.Fatalf("failed to apply patches: %v", err) + } + + // Unmarshal to verify structure + var modified map[string]interface{} + err = json.Unmarshal(modifiedJSON, &modified) + if err != nil { + t.Fatalf("failed to unmarshal modified: %v", err) + } + + // Verify SCC-injected values were removed + verifySecurityContext := func(sc interface{}, path string, isPodLevel bool) { + if sc == nil { + return + } + scMap, ok := sc.(map[string]interface{}) + if !ok { + return + } + + // Check runAsUser + if runAsUser, ok := scMap["runAsUser"].(float64); ok { + if int64(runAsUser) >= SCCNamespaceUIDMin { + t.Errorf("%s at %s: SCC-injected runAsUser %d should have been stripped", tt.description, path, int64(runAsUser)) + } + } + + // Check fsGroup (only valid at pod level) + if isPodLevel { + if fsGroup, ok := scMap["fsGroup"].(float64); ok { + if int64(fsGroup) >= SCCNamespaceUIDMin { + t.Errorf("%s at %s: SCC-injected fsGroup %d should have been stripped", tt.description, path, int64(fsGroup)) + } + } + } + + // Check seLinuxOptions.level + if seLinuxOpts, ok := scMap["seLinuxOptions"].(map[string]interface{}); ok { + if _, hasLevel := seLinuxOpts["level"]; hasLevel { + t.Errorf("%s at %s: seLinuxOptions.level should have been stripped", tt.description, path) + } + } + } + + // Get the appropriate spec path + var spec map[string]interface{} + var expectedPath string + if tt.resource.GetKind() == "Pod" { + spec, _ = modified["spec"].(map[string]interface{}) + expectedPath = "spec" + } else if tt.resource.GetKind() == "CronJob" { + jobTemplate, _ := modified["spec"].(map[string]interface{}) + jobSpec, _ := jobTemplate["jobTemplate"].(map[string]interface{}) + templateSpec, _ := jobSpec["spec"].(map[string]interface{}) + template, _ := templateSpec["template"].(map[string]interface{}) + spec, _ = template["spec"].(map[string]interface{}) + expectedPath = "spec.jobTemplate.spec.template.spec" + } else { + templateObj, _ := modified["spec"].(map[string]interface{}) + template, _ := templateObj["template"].(map[string]interface{}) + spec, _ = template["spec"].(map[string]interface{}) + expectedPath = "spec.template.spec" + } + + // Validate that spec was found when patches are expected + if spec == nil && tt.expectPatch { + t.Fatalf("%s: expected to find pod spec at %s for kind %s after patch application, but spec is nil - patch may have failed or test data is incorrect", + tt.description, expectedPath, tt.resource.GetKind()) + } + + if spec != nil { + // Verify pod-level security context + if podSC, ok := spec["securityContext"]; ok { + verifySecurityContext(podSC, "spec.securityContext", true) + } + + // Verify container security contexts + if containers, ok := spec["containers"].([]interface{}); ok { + for i, c := range containers { + container, _ := c.(map[string]interface{}) + if containerSC, ok := container["securityContext"]; ok { + verifySecurityContext(containerSC, fmt.Sprintf("spec.containers[%d].securityContext", i), false) + } + } + } + + // Verify initContainer security contexts + if initContainers, ok := spec["initContainers"].([]interface{}); ok { + for i, c := range initContainers { + container, _ := c.(map[string]interface{}) + if containerSC, ok := container["securityContext"]; ok { + verifySecurityContext(containerSC, fmt.Sprintf("spec.initContainers[%d].securityContext", i), false) + } + } + } + + // Verify ephemeralContainer security contexts + if ephemeralContainers, ok := spec["ephemeralContainers"].([]interface{}); ok { + for i, c := range ephemeralContainers { + container, _ := c.(map[string]interface{}) + if containerSC, ok := container["securityContext"]; ok { + verifySecurityContext(containerSC, fmt.Sprintf("spec.ephemeralContainers[%d].securityContext", i), false) + } + } + } + + // Verify user-configured fields are preserved (positive assertions) + // Check that fields which existed in the original and are not SCC-injected are still present + verifyPreservedFields := func(origSpec, modSpec map[string]interface{}, pathPrefix string) { + // Helper to check if a security context field is preserved. + // modSC can be empty map (when entire securityContext is missing) - this triggers + // proper validation errors for removed user-configured fields. + checkPreserved := func(origSC, modSC map[string]interface{}, field, path string) { + if origVal, hasOrig := origSC[field]; hasOrig { + modVal, hasMod := modSC[field] + if !hasMod { + // Field was removed - check if it should have been preserved + // Skip fields that might be legitimately removed (SCC-injected UIDs, etc.) + switch field { + case "runAsUser", "fsGroup": + // Only complain if the value was < 1000000000 (user-configured) + if val, ok := origVal.(int64); ok && val < SCCNamespaceUIDMin { + t.Errorf("%s at %s: user-configured %s=%v was removed but should be preserved", + tt.description, path, field, origVal) + } + case "seLinuxOptions": + // Check individual seLinuxOptions fields + if origOpts, ok := origVal.(map[string]interface{}); ok { + for optField, optVal := range origOpts { + if optField != "level" { // level is SCC-injected, always removed + t.Errorf("%s at %s: user-configured seLinuxOptions.%s=%v was removed but should be preserved", + tt.description, path, optField, optVal) + } + } + } + default: + // All other fields should be preserved + t.Errorf("%s at %s: user-configured %s=%v was removed but should be preserved", + tt.description, path, field, origVal) + } + return + } + + // Field exists; for seLinuxOptions validate nested preserved keys too. + if field == "seLinuxOptions" { + origOpts, ok1 := origVal.(map[string]interface{}) + modOpts, ok2 := modVal.(map[string]interface{}) + if ok1 && ok2 { + for optField, optVal := range origOpts { + if optField != "level" { + if _, hasModOpt := modOpts[optField]; !hasModOpt { + t.Errorf("%s at %s: user-configured seLinuxOptions.%s=%v was removed but should be preserved", + tt.description, path, optField, optVal) + } + } + } + } + } + } + } + + // Check pod-level security context + if origPodSC, ok := origSpec["securityContext"].(map[string]interface{}); ok { + if modPodSC, ok := modSpec["securityContext"].(map[string]interface{}); ok { + for field := range origPodSC { + checkPreserved(origPodSC, modPodSC, field, pathPrefix+".securityContext") + } + } else { + // modPodSC is missing entirely - check if origPodSC had user-configured fields + for field := range origPodSC { + checkPreserved(origPodSC, map[string]interface{}{}, field, pathPrefix+".securityContext") + } + } + } + + // Check container security contexts + origContainers, _ := origSpec["containers"].([]interface{}) + modContainers, _ := modSpec["containers"].([]interface{}) + for i := 0; i < len(origContainers) && i < len(modContainers); i++ { + origCont, _ := origContainers[i].(map[string]interface{}) + modCont, _ := modContainers[i].(map[string]interface{}) + if origSC, ok := origCont["securityContext"].(map[string]interface{}); ok { + if modSC, ok := modCont["securityContext"].(map[string]interface{}); ok { + for field := range origSC { + checkPreserved(origSC, modSC, field, fmt.Sprintf("%s.containers[%d].securityContext", pathPrefix, i)) + } + } else { + // modSC is missing entirely - check if origSC had user-configured fields + for field := range origSC { + checkPreserved(origSC, map[string]interface{}{}, field, fmt.Sprintf("%s.containers[%d].securityContext", pathPrefix, i)) + } + } + } + } + + // Check initContainer security contexts + origInitContainers, _ := origSpec["initContainers"].([]interface{}) + modInitContainers, _ := modSpec["initContainers"].([]interface{}) + for i := 0; i < len(origInitContainers) && i < len(modInitContainers); i++ { + origCont, _ := origInitContainers[i].(map[string]interface{}) + modCont, _ := modInitContainers[i].(map[string]interface{}) + if origSC, ok := origCont["securityContext"].(map[string]interface{}); ok { + if modSC, ok := modCont["securityContext"].(map[string]interface{}); ok { + for field := range origSC { + checkPreserved(origSC, modSC, field, fmt.Sprintf("%s.initContainers[%d].securityContext", pathPrefix, i)) + } + } else { + // modSC is missing entirely - check if origSC had user-configured fields + for field := range origSC { + checkPreserved(origSC, map[string]interface{}{}, field, fmt.Sprintf("%s.initContainers[%d].securityContext", pathPrefix, i)) + } + } + } + } + + // Check ephemeralContainer security contexts + origEphemeralContainers, _ := origSpec["ephemeralContainers"].([]interface{}) + modEphemeralContainers, _ := modSpec["ephemeralContainers"].([]interface{}) + for i := 0; i < len(origEphemeralContainers) && i < len(modEphemeralContainers); i++ { + origCont, _ := origEphemeralContainers[i].(map[string]interface{}) + modCont, _ := modEphemeralContainers[i].(map[string]interface{}) + if origSC, ok := origCont["securityContext"].(map[string]interface{}); ok { + if modSC, ok := modCont["securityContext"].(map[string]interface{}); ok { + for field := range origSC { + checkPreserved(origSC, modSC, field, fmt.Sprintf("%s.ephemeralContainers[%d].securityContext", pathPrefix, i)) + } + } else { + // modSC is missing entirely - check if origSC had user-configured fields + for field := range origSC { + checkPreserved(origSC, map[string]interface{}{}, field, fmt.Sprintf("%s.ephemeralContainers[%d].securityContext", pathPrefix, i)) + } + } + } + } + } + + // Get original spec for comparison + var origSpec map[string]interface{} + if tt.resource.GetKind() == "Pod" { + origSpec, _ = tt.resource.Object["spec"].(map[string]interface{}) + verifyPreservedFields(origSpec, spec, "spec") + } else if tt.resource.GetKind() == "CronJob" { + jobTemplate, _ := tt.resource.Object["spec"].(map[string]interface{}) + jobSpec, _ := jobTemplate["jobTemplate"].(map[string]interface{}) + templateSpec, _ := jobSpec["spec"].(map[string]interface{}) + template, _ := templateSpec["template"].(map[string]interface{}) + origSpec, _ = template["spec"].(map[string]interface{}) + verifyPreservedFields(origSpec, spec, "spec.jobTemplate.spec.template.spec") + } else { + templateObj, _ := tt.resource.Object["spec"].(map[string]interface{}) + template, _ := templateObj["template"].(map[string]interface{}) + origSpec, _ = template["spec"].(map[string]interface{}) + verifyPreservedFields(origSpec, spec, "spec.template.spec") + } + } + } + }) + } +}