From 575d17136be14eaf94715ad76db4e2fc2f88c0b2 Mon Sep 17 00:00:00 2001 From: Patrick Derks Date: Thu, 16 Apr 2026 16:48:17 +0200 Subject: [PATCH 1/3] feat: harden pod security --- Makefile | 2 +- api/v1/store.go | 8 +-- internal/cronjob/scheduled_task.go | 1 + internal/cronjob/scheduled_task_test.go | 39 ++++++++++++++ internal/deployment/admin.go | 1 + internal/deployment/storefront.go | 1 + internal/deployment/storefront_test.go | 26 +++++++++- internal/deployment/worker.go | 1 + internal/job/command.go | 1 + internal/job/command_test.go | 67 +++++++++++++++++++++++++ internal/job/migration.go | 1 + internal/job/migration_test.go | 28 +++++++++++ internal/job/setup.go | 1 + internal/job/setup_test.go | 28 +++++++++++ internal/job/snapshot.go | 1 + internal/job/snapshot_test.go | 53 +++++++++++++++++++ internal/util/security.go | 16 ++++++ 17 files changed, 270 insertions(+), 5 deletions(-) create mode 100644 internal/cronjob/scheduled_task_test.go create mode 100644 internal/job/snapshot_test.go create mode 100644 internal/util/security.go diff --git a/Makefile b/Makefile index e827c664..72edf5d8 100644 --- a/Makefile +++ b/Makefile @@ -89,7 +89,7 @@ test: manifests generate envtest ## Run tests. KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test ./... -coverprofile cover.out GOLANGCI_LINT = $(shell pwd)/bin/golangci-lint -GOLANGCI_LINT_VERSION ?= v1.54.2 +GOLANGCI_LINT_VERSION ?= v2.10 golangci-lint: @[ -f $(GOLANGCI_LINT) ] || { \ set -e ;\ diff --git a/api/v1/store.go b/api/v1/store.go index 6caa89d8..5097029d 100644 --- a/api/v1/store.go +++ b/api/v1/store.go @@ -242,9 +242,11 @@ type ContainerSpec struct { VolumeMounts []corev1.VolumeMount `json:"volumeMounts,omitempty"` ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets,omitempty"` RestartPolicy corev1.RestartPolicy `json:"restartPolicy,omitempty"` - SecurityContext *corev1.PodSecurityContext `json:"podSecurityContext,omitempty"` - ExtraContainers []corev1.Container `json:"extraContainers,omitempty"` - InitContainers []corev1.Container `json:"initContainers,omitempty"` + + // +kubebuilder:default={"fsGroup":82,"runAsGroup":82,"runAsNonRoot":true,"runAsUser":82,"seccompProfile":{"type":"RuntimeDefault"}} + SecurityContext *corev1.PodSecurityContext `json:"podSecurityContext,omitempty"` + ExtraContainers []corev1.Container `json:"extraContainers,omitempty"` + InitContainers []corev1.Container `json:"initContainers,omitempty"` // +kubebuilder:default=2 Replicas int32 `json:"replicas,omitempty"` diff --git a/internal/cronjob/scheduled_task.go b/internal/cronjob/scheduled_task.go index 53307a5e..a9f9c137 100644 --- a/internal/cronjob/scheduled_task.go +++ b/internal/cronjob/scheduled_task.go @@ -55,6 +55,7 @@ func ScheduledTaskJob(store v1.Store) *batchv1.CronJob { VolumeMounts: store.Spec.Container.VolumeMounts, ImagePullPolicy: store.Spec.Container.ImagePullPolicy, Image: store.Spec.Container.Image, + SecurityContext: util.RestrictedContainerSecurityContext(), Command: []string{"sh", "-c"}, Args: []string{store.Spec.ScheduledTask.Command}, Env: store.GetEnv(), diff --git a/internal/cronjob/scheduled_task_test.go b/internal/cronjob/scheduled_task_test.go new file mode 100644 index 00000000..0f012e45 --- /dev/null +++ b/internal/cronjob/scheduled_task_test.go @@ -0,0 +1,39 @@ +package cronjob_test + +import ( + "testing" + + v1 "github.com/shopware/shopware-operator/api/v1" + "github.com/shopware/shopware-operator/internal/cronjob" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestScheduledTaskJobUsesRestrictedContainerSecurityContext(t *testing.T) { + store := v1.Store{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-store", + Namespace: "test", + }, + Spec: v1.StoreSpec{ + Container: v1.ContainerSpec{ + Image: "shopware:latest", + }, + ScheduledTask: v1.ScheduledTaskSpec{ + Schedule: "0 * * * *", + TimeZone: "Etc/UTC", + Command: "bin/console scheduled-task:run", + }, + }, + } + + result := cronjob.ScheduledTaskJob(store) + container := result.Spec.JobTemplate.Spec.Template.Spec.Containers[0] + + assert.NotNil(t, container.SecurityContext) + assert.NotNil(t, container.SecurityContext.AllowPrivilegeEscalation) + assert.False(t, *container.SecurityContext.AllowPrivilegeEscalation) + assert.NotNil(t, container.SecurityContext.Capabilities) + assert.Equal(t, []corev1.Capability{"ALL"}, container.SecurityContext.Capabilities.Drop) +} diff --git a/internal/deployment/admin.go b/internal/deployment/admin.go index 323e840d..1cac3cff 100644 --- a/internal/deployment/admin.go +++ b/internal/deployment/admin.go @@ -108,6 +108,7 @@ func AdminDeployment(store v1.Store) *appsv1.Deployment { Name: appName, Image: containerSpec.Image, ImagePullPolicy: containerSpec.ImagePullPolicy, + SecurityContext: util.RestrictedContainerSecurityContext(), Env: envs, VolumeMounts: containerSpec.VolumeMounts, Ports: []corev1.ContainerPort{ diff --git a/internal/deployment/storefront.go b/internal/deployment/storefront.go index ba990040..7c206948 100644 --- a/internal/deployment/storefront.go +++ b/internal/deployment/storefront.go @@ -112,6 +112,7 @@ func StorefrontDeployment(store v1.Store) *appsv1.Deployment { }, Image: containerSpec.Image, ImagePullPolicy: containerSpec.ImagePullPolicy, + SecurityContext: util.RestrictedContainerSecurityContext(), Env: envs, VolumeMounts: containerSpec.VolumeMounts, Ports: []corev1.ContainerPort{ diff --git a/internal/deployment/storefront_test.go b/internal/deployment/storefront_test.go index 86f9c046..7b8310db 100644 --- a/internal/deployment/storefront_test.go +++ b/internal/deployment/storefront_test.go @@ -168,12 +168,36 @@ func TestStorefrontDeployment(t *testing.T) { result := deployment.StorefrontDeployment(store) - // Verify security context is overwritten + // Verify pod security context is overwritten. assert.NotNil(t, result.Spec.Template.Spec.SecurityContext) assert.Equal(t, int64(2000), *result.Spec.Template.Spec.SecurityContext.RunAsGroup) assert.Nil(t, result.Spec.Template.Spec.SecurityContext.RunAsUser) }) + t.Run("test storefront container security context is restricted", func(t *testing.T) { + store := v1.Store{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-store", + Namespace: "test", + }, + Spec: v1.StoreSpec{ + Container: v1.ContainerSpec{ + Image: "shopware:latest", + }, + SecretName: "store-secret", + }, + } + + result := deployment.StorefrontDeployment(store) + container := result.Spec.Template.Spec.Containers[0] + + assert.NotNil(t, container.SecurityContext) + assert.NotNil(t, container.SecurityContext.AllowPrivilegeEscalation) + assert.False(t, *container.SecurityContext.AllowPrivilegeEscalation) + assert.NotNil(t, container.SecurityContext.Capabilities) + assert.Equal(t, []corev1.Capability{"ALL"}, container.SecurityContext.Capabilities.Drop) + }) + t.Run("test service account merge", func(t *testing.T) { store := v1.Store{ ObjectMeta: metav1.ObjectMeta{ diff --git a/internal/deployment/worker.go b/internal/deployment/worker.go index 4bffca1d..4cacc2c0 100644 --- a/internal/deployment/worker.go +++ b/internal/deployment/worker.go @@ -83,6 +83,7 @@ func WorkerDeployment(store v1.Store) *appsv1.Deployment { Image: containerSpec.Image, ImagePullPolicy: containerSpec.ImagePullPolicy, Env: envs, + SecurityContext: util.RestrictedContainerSecurityContext(), Command: []string{ "bin/console", }, diff --git a/internal/job/command.go b/internal/job/command.go index 0fb66abc..d5d80e16 100644 --- a/internal/job/command.go +++ b/internal/job/command.go @@ -121,6 +121,7 @@ func getJobSpec(store v1.Store, ex v1.StoreExec, labels map[string]string) batch VolumeMounts: containerSpec.VolumeMounts, ImagePullPolicy: containerSpec.ImagePullPolicy, Image: containerSpec.Image, + SecurityContext: util.RestrictedContainerSecurityContext(), Command: []string{"sh", "-c"}, Args: []string{ex.Spec.Script}, Env: envs, diff --git a/internal/job/command_test.go b/internal/job/command_test.go index 52c26329..b9b173a6 100644 --- a/internal/job/command_test.go +++ b/internal/job/command_test.go @@ -105,6 +105,39 @@ func TestCommandJob(t *testing.T) { assert.Equal(t, []string{"bin/console cache:clear"}, container.Args) assert.Equal(t, "shopware-command", container.Name) }) + + t.Run("test container security context is restricted", func(t *testing.T) { + store := v1.Store{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-store", + Namespace: "test", + }, + Spec: v1.StoreSpec{ + Container: v1.ContainerSpec{ + Image: "shopware:latest", + }, + }, + } + + exec := v1.StoreExec{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-exec", + Namespace: "test", + }, + Spec: v1.StoreExecSpec{ + Script: "echo test", + }, + } + + result := job.CommandJob(store, exec) + container := result.Spec.Template.Spec.Containers[0] + + assert.NotNil(t, container.SecurityContext) + assert.NotNil(t, container.SecurityContext.AllowPrivilegeEscalation) + assert.False(t, *container.SecurityContext.AllowPrivilegeEscalation) + assert.NotNil(t, container.SecurityContext.Capabilities) + assert.Equal(t, []corev1.Capability{"ALL"}, container.SecurityContext.Capabilities.Drop) + }) } func TestCommandCronJob(t *testing.T) { @@ -144,4 +177,38 @@ func TestCommandCronJob(t *testing.T) { assert.Equal(t, "test-exec", result.Name) assert.Equal(t, "test", result.Namespace) }) + + t.Run("test cron container security context is restricted", func(t *testing.T) { + store := v1.Store{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-store", + Namespace: "test", + }, + Spec: v1.StoreSpec{ + Container: v1.ContainerSpec{ + Image: "shopware:latest", + }, + }, + } + + exec := v1.StoreExec{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-exec", + Namespace: "test", + }, + Spec: v1.StoreExecSpec{ + Script: "echo test", + CronSchedule: "*/5 * * * *", + }, + } + + result := job.CommandCronJob(store, exec) + container := result.Spec.JobTemplate.Spec.Template.Spec.Containers[0] + + assert.NotNil(t, container.SecurityContext) + assert.NotNil(t, container.SecurityContext.AllowPrivilegeEscalation) + assert.False(t, *container.SecurityContext.AllowPrivilegeEscalation) + assert.NotNil(t, container.SecurityContext.Capabilities) + assert.Equal(t, []corev1.Capability{"ALL"}, container.SecurityContext.Capabilities.Drop) + }) } diff --git a/internal/job/migration.go b/internal/job/migration.go index 3683e14e..6044c59e 100644 --- a/internal/job/migration.go +++ b/internal/job/migration.go @@ -58,6 +58,7 @@ func MigrationJob(store v1.Store) *batchv1.Job { VolumeMounts: containerSpec.VolumeMounts, ImagePullPolicy: containerSpec.ImagePullPolicy, Image: containerSpec.Image, + SecurityContext: util.RestrictedContainerSecurityContext(), Command: []string{"sh", "-c"}, Args: []string{store.Spec.MigrationScript}, Env: envs, diff --git a/internal/job/migration_test.go b/internal/job/migration_test.go index 6e85fa02..14fd90f2 100644 --- a/internal/job/migration_test.go +++ b/internal/job/migration_test.go @@ -205,4 +205,32 @@ func TestMigrationJob(t *testing.T) { // Verify service account is overwritten assert.Equal(t, "migration-sa", result.Spec.Template.Spec.ServiceAccountName) }) + + t.Run("test container security context is restricted", func(t *testing.T) { + store := v1.Store{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-store", + Namespace: "test", + }, + Spec: v1.StoreSpec{ + Container: v1.ContainerSpec{ + Image: "shopware:latest", + }, + MigrationScript: "/migrate.sh", + SecretName: "store-secret", + }, + Status: v1.StoreStatus{ + CurrentImageTag: "shopware:old", + }, + } + + result := job.MigrationJob(store) + container := result.Spec.Template.Spec.Containers[0] + + assert.NotNil(t, container.SecurityContext) + assert.NotNil(t, container.SecurityContext.AllowPrivilegeEscalation) + assert.False(t, *container.SecurityContext.AllowPrivilegeEscalation) + assert.NotNil(t, container.SecurityContext.Capabilities) + assert.Equal(t, []corev1.Capability{"ALL"}, container.SecurityContext.Capabilities.Drop) + }) } diff --git a/internal/job/setup.go b/internal/job/setup.go index 53456c8b..242c079f 100644 --- a/internal/job/setup.go +++ b/internal/job/setup.go @@ -67,6 +67,7 @@ func SetupJob(store v1.Store) *batchv1.Job { VolumeMounts: containerSpec.VolumeMounts, ImagePullPolicy: containerSpec.ImagePullPolicy, Image: containerSpec.Image, + SecurityContext: util.RestrictedContainerSecurityContext(), Command: []string{"sh", "-c"}, Args: []string{store.Spec.SetupScript}, Env: envs, diff --git a/internal/job/setup_test.go b/internal/job/setup_test.go index a273d04b..2d9c6db4 100644 --- a/internal/job/setup_test.go +++ b/internal/job/setup_test.go @@ -280,4 +280,32 @@ func TestSetupJob(t *testing.T) { assert.NotNil(t, result.Spec.Template.Spec.EnableServiceLinks) assert.False(t, *result.Spec.Template.Spec.EnableServiceLinks) }) + + t.Run("test container security context is restricted", func(t *testing.T) { + store := v1.Store{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-store", + Namespace: "test", + }, + Spec: v1.StoreSpec{ + Container: v1.ContainerSpec{ + Image: "shopware:latest", + }, + SetupScript: "/setup.sh", + AdminCredentials: v1.Credentials{ + Username: "admin", + }, + SecretName: "store-secret", + }, + } + + result := job.SetupJob(store) + container := result.Spec.Template.Spec.Containers[0] + + assert.NotNil(t, container.SecurityContext) + assert.NotNil(t, container.SecurityContext.AllowPrivilegeEscalation) + assert.False(t, *container.SecurityContext.AllowPrivilegeEscalation) + assert.NotNil(t, container.SecurityContext.Capabilities) + assert.Equal(t, []corev1.Capability{"ALL"}, container.SecurityContext.Capabilities.Drop) + }) } diff --git a/internal/job/snapshot.go b/internal/job/snapshot.go index b4f77332..9b535e45 100644 --- a/internal/job/snapshot.go +++ b/internal/job/snapshot.go @@ -117,6 +117,7 @@ func snapshotJob(store v1.Store, meta metav1.ObjectMeta, snapshot v1.StoreSnapsh VolumeMounts: vm, ImagePullPolicy: snapshot.Container.ImagePullPolicy, Image: snapshot.Container.Image, + SecurityContext: util.RestrictedContainerSecurityContext(), Args: args, Env: snapshot.GetEnv(store), Resources: snapshot.Container.Resources, diff --git a/internal/job/snapshot_test.go b/internal/job/snapshot_test.go new file mode 100644 index 00000000..bbce3520 --- /dev/null +++ b/internal/job/snapshot_test.go @@ -0,0 +1,53 @@ +package job_test + +import ( + "testing" + + v1 "github.com/shopware/shopware-operator/api/v1" + "github.com/shopware/shopware-operator/internal/job" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestSnapshotCreateJobUsesRestrictedContainerSecurityContext(t *testing.T) { + store := v1.Store{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-store", + Namespace: "test", + }, + Spec: v1.StoreSpec{ + SecretName: "store-secret", + Database: v1.DatabaseSpec{ + Name: "shopware", + }, + S3Storage: v1.S3Storage{ + EndpointURL: "https://s3.example.com", + PrivateBucketName: "private", + PublicBucketName: "public", + }, + }, + } + + snapshot := v1.StoreSnapshotCreate{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-snapshot", + Namespace: "test", + }, + Spec: v1.StoreSnapshotSpec{ + Path: "/tmp/snapshot.zip", + Container: v1.ContainerSpec{ + Image: "shopware:snapshot", + }, + }, + } + + result := job.SnapshotCreateJob(store, snapshot) + container := result.Spec.Template.Spec.Containers[0] + + assert.NotNil(t, container.SecurityContext) + assert.NotNil(t, container.SecurityContext.AllowPrivilegeEscalation) + assert.False(t, *container.SecurityContext.AllowPrivilegeEscalation) + assert.NotNil(t, container.SecurityContext.Capabilities) + assert.Equal(t, []corev1.Capability{"ALL"}, container.SecurityContext.Capabilities.Drop) +} diff --git a/internal/util/security.go b/internal/util/security.go new file mode 100644 index 00000000..77fb9539 --- /dev/null +++ b/internal/util/security.go @@ -0,0 +1,16 @@ +package util + +import corev1 "k8s.io/api/core/v1" + +func RestrictedContainerSecurityContext() *corev1.SecurityContext { + allowPrivilegeEscalation := false + + return &corev1.SecurityContext{ + AllowPrivilegeEscalation: &allowPrivilegeEscalation, + Capabilities: &corev1.Capabilities{ + Drop: []corev1.Capability{ + "ALL", + }, + }, + } +} From 965475a61f8b7105ec7069b43e70eb18540ef39a Mon Sep 17 00:00:00 2001 From: Tom Bojer Date: Tue, 19 May 2026 08:51:33 +0200 Subject: [PATCH 2/3] feat: ensure generated pods use restricted security defaults --- internal/cronjob/scheduled_task.go | 2 +- internal/deployment/admin.go | 2 +- internal/deployment/storefront.go | 2 +- internal/deployment/worker.go | 2 +- internal/job/command.go | 2 +- internal/job/migration.go | 2 +- internal/job/setup.go | 2 +- internal/job/snapshot.go | 2 +- internal/pod/debug.go | 3 ++- internal/util/security.go | 30 +++++++++++++++++++++++++++ internal/util/security_test.go | 33 ++++++++++++++++++++++++++++++ 11 files changed, 73 insertions(+), 9 deletions(-) create mode 100644 internal/util/security_test.go diff --git a/internal/cronjob/scheduled_task.go b/internal/cronjob/scheduled_task.go index 178da41e..ed0b72aa 100644 --- a/internal/cronjob/scheduled_task.go +++ b/internal/cronjob/scheduled_task.go @@ -112,7 +112,7 @@ func ScheduledTaskJob(store v1.Store) *batchv1.CronJob { EnableServiceLinks: store.Spec.Container.EnableServiceLinks, RestartPolicy: "Never", Containers: containers, - SecurityContext: store.Spec.Container.SecurityContext, + SecurityContext: util.DefaultPodSecurityContext(store.Spec.Container.SecurityContext), ServiceAccountName: sa, InitContainers: store.Spec.Container.InitContainers, }, diff --git a/internal/deployment/admin.go b/internal/deployment/admin.go index d5a831cd..b5427997 100644 --- a/internal/deployment/admin.go +++ b/internal/deployment/admin.go @@ -171,7 +171,7 @@ func AdminDeployment(store v1.Store) *appsv1.Deployment { EnableServiceLinks: containerSpec.EnableServiceLinks, RestartPolicy: containerSpec.RestartPolicy, Containers: containers, - SecurityContext: containerSpec.SecurityContext, + SecurityContext: util.DefaultPodSecurityContext(containerSpec.SecurityContext), InitContainers: containerSpec.InitContainers, }, }, diff --git a/internal/deployment/storefront.go b/internal/deployment/storefront.go index cf433983..80788ac8 100644 --- a/internal/deployment/storefront.go +++ b/internal/deployment/storefront.go @@ -175,7 +175,7 @@ func StorefrontDeployment(store v1.Store) *appsv1.Deployment { EnableServiceLinks: containerSpec.EnableServiceLinks, RestartPolicy: containerSpec.RestartPolicy, Containers: containers, - SecurityContext: containerSpec.SecurityContext, + SecurityContext: util.DefaultPodSecurityContext(containerSpec.SecurityContext), InitContainers: containerSpec.InitContainers, }, }, diff --git a/internal/deployment/worker.go b/internal/deployment/worker.go index 4cacc2c0..ece8159c 100644 --- a/internal/deployment/worker.go +++ b/internal/deployment/worker.go @@ -146,7 +146,7 @@ func WorkerDeployment(store v1.Store) *appsv1.Deployment { EnableServiceLinks: containerSpec.EnableServiceLinks, RestartPolicy: containerSpec.RestartPolicy, Containers: containers, - SecurityContext: containerSpec.SecurityContext, + SecurityContext: util.DefaultPodSecurityContext(containerSpec.SecurityContext), InitContainers: containerSpec.InitContainers, }, }, diff --git a/internal/job/command.go b/internal/job/command.go index d5d80e16..7b2d733a 100644 --- a/internal/job/command.go +++ b/internal/job/command.go @@ -155,7 +155,7 @@ func getJobSpec(store v1.Store, ex v1.StoreExec, labels map[string]string) batch EnableServiceLinks: containerSpec.EnableServiceLinks, RestartPolicy: "Never", Containers: containers, - SecurityContext: containerSpec.SecurityContext, + SecurityContext: util.DefaultPodSecurityContext(containerSpec.SecurityContext), InitContainers: containerSpec.InitContainers, }, }, diff --git a/internal/job/migration.go b/internal/job/migration.go index 6044c59e..a9481c16 100644 --- a/internal/job/migration.go +++ b/internal/job/migration.go @@ -94,7 +94,7 @@ func MigrationJob(store v1.Store) *batchv1.Job { EnableServiceLinks: containerSpec.EnableServiceLinks, RestartPolicy: "Never", Containers: containers, - SecurityContext: containerSpec.SecurityContext, + SecurityContext: util.DefaultPodSecurityContext(containerSpec.SecurityContext), InitContainers: containerSpec.InitContainers, }, }, diff --git a/internal/job/setup.go b/internal/job/setup.go index 242c079f..4a5fa367 100644 --- a/internal/job/setup.go +++ b/internal/job/setup.go @@ -103,7 +103,7 @@ func SetupJob(store v1.Store) *batchv1.Job { EnableServiceLinks: containerSpec.EnableServiceLinks, RestartPolicy: "Never", Containers: containers, - SecurityContext: containerSpec.SecurityContext, + SecurityContext: util.DefaultPodSecurityContext(containerSpec.SecurityContext), InitContainers: containerSpec.InitContainers, }, }, diff --git a/internal/job/snapshot.go b/internal/job/snapshot.go index 9b535e45..f7ae5968 100644 --- a/internal/job/snapshot.go +++ b/internal/job/snapshot.go @@ -167,7 +167,7 @@ func snapshotJob(store v1.Store, meta metav1.ObjectMeta, snapshot v1.StoreSnapsh EnableServiceLinks: snapshot.Container.EnableServiceLinks, RestartPolicy: "Never", Containers: containers, - SecurityContext: snapshot.Container.SecurityContext, + SecurityContext: util.DefaultPodSecurityContext(snapshot.Container.SecurityContext), InitContainers: snapshot.Container.InitContainers, }, }, diff --git a/internal/pod/debug.go b/internal/pod/debug.go index c88b16e0..5371edd8 100644 --- a/internal/pod/debug.go +++ b/internal/pod/debug.go @@ -64,6 +64,7 @@ func DebugPod(store v1.Store, storeDebugInstance v1.StoreDebugInstance) *corev1. VolumeMounts: store.Spec.Container.VolumeMounts, Ports: ports, Resources: store.Spec.Container.Resources, + SecurityContext: util.RestrictedContainerSecurityContext(), }) podSpec.Spec.Containers = containers @@ -72,7 +73,7 @@ func DebugPod(store v1.Store, storeDebugInstance v1.StoreDebugInstance) *corev1. podSpec.Spec.NodeSelector = store.Spec.Container.NodeSelector podSpec.Spec.ImagePullSecrets = store.Spec.Container.ImagePullSecrets podSpec.Spec.EnableServiceLinks = store.Spec.Container.EnableServiceLinks - podSpec.Spec.SecurityContext = store.Spec.Container.SecurityContext + podSpec.Spec.SecurityContext = util.DefaultPodSecurityContext(store.Spec.Container.SecurityContext) podSpec.Spec.InitContainers = store.Spec.Container.InitContainers if store.Spec.ServiceAccountName != "" { diff --git a/internal/util/security.go b/internal/util/security.go index 77fb9539..65db0c46 100644 --- a/internal/util/security.go +++ b/internal/util/security.go @@ -2,6 +2,10 @@ package util import corev1 "k8s.io/api/core/v1" +// RestrictedContainerSecurityContext returns the fixed container-level settings +// required by the restricted Pod Security Standard. The CRD default only covers +// podSecurityContext, but allowPrivilegeEscalation and capabilities live on each +// container's securityContext. func RestrictedContainerSecurityContext() *corev1.SecurityContext { allowPrivilegeEscalation := false @@ -14,3 +18,29 @@ func RestrictedContainerSecurityContext() *corev1.SecurityContext { }, } } + +// RestrictedPodSecurityContext mirrors the podSecurityContext default from the +// CRD for resources that were not API-server defaulted before reconciliation. +func RestrictedPodSecurityContext() *corev1.PodSecurityContext { + runAsNonRoot := true + + return &corev1.PodSecurityContext{ + FSGroup: Int64(82), + RunAsGroup: Int64(82), + RunAsNonRoot: &runAsNonRoot, + RunAsUser: Int64(82), + SeccompProfile: &corev1.SeccompProfile{ + Type: corev1.SeccompProfileTypeRuntimeDefault, + }, + } +} + +// DefaultPodSecurityContext keeps CRD-provided values unchanged and only falls +// back to the restricted defaults when podSecurityContext is missing. +func DefaultPodSecurityContext(securityContext *corev1.PodSecurityContext) *corev1.PodSecurityContext { + if securityContext != nil { + return securityContext + } + + return RestrictedPodSecurityContext() +} diff --git a/internal/util/security_test.go b/internal/util/security_test.go new file mode 100644 index 00000000..390ca412 --- /dev/null +++ b/internal/util/security_test.go @@ -0,0 +1,33 @@ +package util_test + +import ( + "testing" + + "github.com/shopware/shopware-operator/internal/util" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" +) + +func TestDefaultPodSecurityContextUsesRestrictedDefaultsWhenMissing(t *testing.T) { + securityContext := util.DefaultPodSecurityContext(nil) + + assert.NotNil(t, securityContext) + assert.NotNil(t, securityContext.FSGroup) + assert.Equal(t, int64(82), *securityContext.FSGroup) + assert.NotNil(t, securityContext.RunAsGroup) + assert.Equal(t, int64(82), *securityContext.RunAsGroup) + assert.NotNil(t, securityContext.RunAsNonRoot) + assert.True(t, *securityContext.RunAsNonRoot) + assert.NotNil(t, securityContext.RunAsUser) + assert.Equal(t, int64(82), *securityContext.RunAsUser) + assert.NotNil(t, securityContext.SeccompProfile) + assert.Equal(t, corev1.SeccompProfileTypeRuntimeDefault, securityContext.SeccompProfile.Type) +} + +func TestDefaultPodSecurityContextKeepsProvidedContext(t *testing.T) { + securityContext := &corev1.PodSecurityContext{ + RunAsUser: util.Int64(1000), + } + + assert.Same(t, securityContext, util.DefaultPodSecurityContext(securityContext)) +} From ed9e863f966af138a7c2a10bba3d8a1a56aed3f0 Mon Sep 17 00:00:00 2001 From: Tom Bojer Date: Tue, 19 May 2026 10:37:40 +0200 Subject: [PATCH 3/3] fix: add security context also to init containers --- internal/cronjob/scheduled_task.go | 4 ++-- internal/deployment/admin.go | 4 ++-- internal/deployment/storefront.go | 4 ++-- internal/deployment/worker.go | 4 ++-- internal/job/command.go | 4 ++-- internal/job/migration.go | 4 ++-- internal/job/setup.go | 4 ++-- internal/job/snapshot.go | 4 ++-- internal/pod/debug.go | 4 ++-- internal/util/security.go | 18 +++++++++++++++++ internal/util/security_test.go | 31 ++++++++++++++++++++++++++++++ 11 files changed, 67 insertions(+), 18 deletions(-) diff --git a/internal/cronjob/scheduled_task.go b/internal/cronjob/scheduled_task.go index ed0b72aa..e16809cf 100644 --- a/internal/cronjob/scheduled_task.go +++ b/internal/cronjob/scheduled_task.go @@ -59,7 +59,7 @@ func ScheduledTaskJob(store v1.Store) *batchv1.CronJob { annotations := util.GetDefaultContainerAnnotations(CONTAINER_NAME_SCHEDULED_JOB, store, store.Spec.SetupJobContainer.Annotations) - containers := append(store.Spec.Container.ExtraContainers, corev1.Container{ + containers := append(util.DefaultContainerSecurityContexts(store.Spec.Container.ExtraContainers), corev1.Container{ Name: CONTAINER_NAME_SCHEDULED_JOB, VolumeMounts: store.Spec.Container.VolumeMounts, ImagePullPolicy: store.Spec.Container.ImagePullPolicy, @@ -114,7 +114,7 @@ func ScheduledTaskJob(store v1.Store) *batchv1.CronJob { Containers: containers, SecurityContext: util.DefaultPodSecurityContext(store.Spec.Container.SecurityContext), ServiceAccountName: sa, - InitContainers: store.Spec.Container.InitContainers, + InitContainers: util.DefaultContainerSecurityContexts(store.Spec.Container.InitContainers), }, }, }, diff --git a/internal/deployment/admin.go b/internal/deployment/admin.go index b5427997..55628293 100644 --- a/internal/deployment/admin.go +++ b/internal/deployment/admin.go @@ -86,7 +86,7 @@ func AdminDeployment(store v1.Store) *appsv1.Deployment { } } - containers := append(containerSpec.ExtraContainers, corev1.Container{ + containers := append(util.DefaultContainerSecurityContexts(containerSpec.ExtraContainers), corev1.Container{ LivenessProbe: &corev1.Probe{ ProbeHandler: corev1.ProbeHandler{ HTTPGet: &corev1.HTTPGetAction{ @@ -172,7 +172,7 @@ func AdminDeployment(store v1.Store) *appsv1.Deployment { RestartPolicy: containerSpec.RestartPolicy, Containers: containers, SecurityContext: util.DefaultPodSecurityContext(containerSpec.SecurityContext), - InitContainers: containerSpec.InitContainers, + InitContainers: util.DefaultContainerSecurityContexts(containerSpec.InitContainers), }, }, }, diff --git a/internal/deployment/storefront.go b/internal/deployment/storefront.go index 80788ac8..87893f86 100644 --- a/internal/deployment/storefront.go +++ b/internal/deployment/storefront.go @@ -91,7 +91,7 @@ func StorefrontDeployment(store v1.Store) *appsv1.Deployment { } } - containers := append(containerSpec.ExtraContainers, corev1.Container{ + containers := append(util.DefaultContainerSecurityContexts(containerSpec.ExtraContainers), corev1.Container{ Name: DEPLOYMENT_STOREFRONT_CONTAINER_NAME, LivenessProbe: &corev1.Probe{ ProbeHandler: corev1.ProbeHandler{ @@ -176,7 +176,7 @@ func StorefrontDeployment(store v1.Store) *appsv1.Deployment { RestartPolicy: containerSpec.RestartPolicy, Containers: containers, SecurityContext: util.DefaultPodSecurityContext(containerSpec.SecurityContext), - InitContainers: containerSpec.InitContainers, + InitContainers: util.DefaultContainerSecurityContexts(containerSpec.InitContainers), }, }, }, diff --git a/internal/deployment/worker.go b/internal/deployment/worker.go index ece8159c..a213941b 100644 --- a/internal/deployment/worker.go +++ b/internal/deployment/worker.go @@ -78,7 +78,7 @@ func WorkerDeployment(store v1.Store) *appsv1.Deployment { // Merge containerSpec.ExtraEnvs to override with merged values from WorkerDeploymentContainer envs := util.MergeEnv(store.GetEnv(), containerSpec.ExtraEnvs) - containers := append(containerSpec.ExtraContainers, corev1.Container{ + containers := append(util.DefaultContainerSecurityContexts(containerSpec.ExtraContainers), corev1.Container{ Name: appName, Image: containerSpec.Image, ImagePullPolicy: containerSpec.ImagePullPolicy, @@ -147,7 +147,7 @@ func WorkerDeployment(store v1.Store) *appsv1.Deployment { RestartPolicy: containerSpec.RestartPolicy, Containers: containers, SecurityContext: util.DefaultPodSecurityContext(containerSpec.SecurityContext), - InitContainers: containerSpec.InitContainers, + InitContainers: util.DefaultContainerSecurityContexts(containerSpec.InitContainers), }, }, }, diff --git a/internal/job/command.go b/internal/job/command.go index 7b2d733a..088ba4b7 100644 --- a/internal/job/command.go +++ b/internal/job/command.go @@ -116,7 +116,7 @@ func getJobSpec(store v1.Store, ex v1.StoreExec, labels map[string]string) batch envs := util.MergeEnv(store.GetEnv(), ex.Spec.ExtraEnvs) - containers := append(containerSpec.ExtraContainers, corev1.Container{ + containers := append(util.DefaultContainerSecurityContexts(containerSpec.ExtraContainers), corev1.Container{ Name: CONTAINER_NAME_COMMAND, VolumeMounts: containerSpec.VolumeMounts, ImagePullPolicy: containerSpec.ImagePullPolicy, @@ -156,7 +156,7 @@ func getJobSpec(store v1.Store, ex v1.StoreExec, labels map[string]string) batch RestartPolicy: "Never", Containers: containers, SecurityContext: util.DefaultPodSecurityContext(containerSpec.SecurityContext), - InitContainers: containerSpec.InitContainers, + InitContainers: util.DefaultContainerSecurityContexts(containerSpec.InitContainers), }, }, } diff --git a/internal/job/migration.go b/internal/job/migration.go index a9481c16..d3ea1db7 100644 --- a/internal/job/migration.go +++ b/internal/job/migration.go @@ -53,7 +53,7 @@ func MigrationJob(store v1.Store) *batchv1.Job { // Merge containerSpec.ExtraEnvs to override with merged values from MigrationJobContainer envs := util.MergeEnv(store.GetEnv(), containerSpec.ExtraEnvs) - containers := append(containerSpec.ExtraContainers, corev1.Container{ + containers := append(util.DefaultContainerSecurityContexts(containerSpec.ExtraContainers), corev1.Container{ Name: CONTAINER_NAME_MIGRATION_JOB, VolumeMounts: containerSpec.VolumeMounts, ImagePullPolicy: containerSpec.ImagePullPolicy, @@ -95,7 +95,7 @@ func MigrationJob(store v1.Store) *batchv1.Job { RestartPolicy: "Never", Containers: containers, SecurityContext: util.DefaultPodSecurityContext(containerSpec.SecurityContext), - InitContainers: containerSpec.InitContainers, + InitContainers: util.DefaultContainerSecurityContexts(containerSpec.InitContainers), }, }, }, diff --git a/internal/job/setup.go b/internal/job/setup.go index 4a5fa367..9cba1e4f 100644 --- a/internal/job/setup.go +++ b/internal/job/setup.go @@ -62,7 +62,7 @@ func SetupJob(store v1.Store) *batchv1.Job { // Merge containerSpec.ExtraEnvs to override with merged values from SetupJobContainer envs = util.MergeEnv(envs, containerSpec.ExtraEnvs) - containers := append(containerSpec.ExtraContainers, corev1.Container{ + containers := append(util.DefaultContainerSecurityContexts(containerSpec.ExtraContainers), corev1.Container{ Name: CONTAINER_NAME_SETUP_JOB, VolumeMounts: containerSpec.VolumeMounts, ImagePullPolicy: containerSpec.ImagePullPolicy, @@ -104,7 +104,7 @@ func SetupJob(store v1.Store) *batchv1.Job { RestartPolicy: "Never", Containers: containers, SecurityContext: util.DefaultPodSecurityContext(containerSpec.SecurityContext), - InitContainers: containerSpec.InitContainers, + InitContainers: util.DefaultContainerSecurityContexts(containerSpec.InitContainers), }, }, }, diff --git a/internal/job/snapshot.go b/internal/job/snapshot.go index f7ae5968..2ae5cc14 100644 --- a/internal/job/snapshot.go +++ b/internal/job/snapshot.go @@ -112,7 +112,7 @@ func snapshotJob(store v1.Store, meta metav1.ObjectMeta, snapshot v1.StoreSnapsh } } - containers := append(snapshot.Container.ExtraContainers, corev1.Container{ + containers := append(util.DefaultContainerSecurityContexts(snapshot.Container.ExtraContainers), corev1.Container{ Name: CONTAINER_NAME_SNAPSHOT, VolumeMounts: vm, ImagePullPolicy: snapshot.Container.ImagePullPolicy, @@ -168,7 +168,7 @@ func snapshotJob(store v1.Store, meta metav1.ObjectMeta, snapshot v1.StoreSnapsh RestartPolicy: "Never", Containers: containers, SecurityContext: util.DefaultPodSecurityContext(snapshot.Container.SecurityContext), - InitContainers: snapshot.Container.InitContainers, + InitContainers: util.DefaultContainerSecurityContexts(snapshot.Container.InitContainers), }, }, }, diff --git a/internal/pod/debug.go b/internal/pod/debug.go index 5371edd8..f29abbdd 100644 --- a/internal/pod/debug.go +++ b/internal/pod/debug.go @@ -55,7 +55,7 @@ func DebugPod(store v1.Store, storeDebugInstance v1.StoreDebugInstance) *corev1. } ports = append(ports, storeDebugInstance.Spec.ExtraContainerPorts...) - containers := append(store.Spec.Container.ExtraContainers, corev1.Container{ + containers := append(util.DefaultContainerSecurityContexts(store.Spec.Container.ExtraContainers), corev1.Container{ Name: deployment.DEPLOYMENT_STOREFRONT_CONTAINER_NAME, // we don't need the liveness and readiness probe to make sure that the container always starts Image: containerImage, // Use custom image if provided @@ -74,7 +74,7 @@ func DebugPod(store v1.Store, storeDebugInstance v1.StoreDebugInstance) *corev1. podSpec.Spec.ImagePullSecrets = store.Spec.Container.ImagePullSecrets podSpec.Spec.EnableServiceLinks = store.Spec.Container.EnableServiceLinks podSpec.Spec.SecurityContext = util.DefaultPodSecurityContext(store.Spec.Container.SecurityContext) - podSpec.Spec.InitContainers = store.Spec.Container.InitContainers + podSpec.Spec.InitContainers = util.DefaultContainerSecurityContexts(store.Spec.Container.InitContainers) if store.Spec.ServiceAccountName != "" { podSpec.Spec.ServiceAccountName = store.Spec.ServiceAccountName diff --git a/internal/util/security.go b/internal/util/security.go index 65db0c46..54708107 100644 --- a/internal/util/security.go +++ b/internal/util/security.go @@ -19,6 +19,24 @@ func RestrictedContainerSecurityContext() *corev1.SecurityContext { } } +// DefaultContainerSecurityContexts keeps user-provided container security +// contexts unchanged and only fills missing ones with the restricted defaults. +func DefaultContainerSecurityContexts(containers []corev1.Container) []corev1.Container { + if containers == nil { + return nil + } + + defaulted := make([]corev1.Container, len(containers)) + copy(defaulted, containers) + for i := range defaulted { + if defaulted[i].SecurityContext == nil { + defaulted[i].SecurityContext = RestrictedContainerSecurityContext() + } + } + + return defaulted +} + // RestrictedPodSecurityContext mirrors the podSecurityContext default from the // CRD for resources that were not API-server defaulted before reconciliation. func RestrictedPodSecurityContext() *corev1.PodSecurityContext { diff --git a/internal/util/security_test.go b/internal/util/security_test.go index 390ca412..42ac9f09 100644 --- a/internal/util/security_test.go +++ b/internal/util/security_test.go @@ -31,3 +31,34 @@ func TestDefaultPodSecurityContextKeepsProvidedContext(t *testing.T) { assert.Same(t, securityContext, util.DefaultPodSecurityContext(securityContext)) } + +func TestDefaultContainerSecurityContextsUsesRestrictedDefaultsWhenMissing(t *testing.T) { + containers := []corev1.Container{ + {Name: "sidecar"}, + } + + defaulted := util.DefaultContainerSecurityContexts(containers) + + assert.Nil(t, containers[0].SecurityContext) + assert.NotNil(t, defaulted[0].SecurityContext) + assert.NotNil(t, defaulted[0].SecurityContext.AllowPrivilegeEscalation) + assert.False(t, *defaulted[0].SecurityContext.AllowPrivilegeEscalation) + assert.NotNil(t, defaulted[0].SecurityContext.Capabilities) + assert.Equal(t, []corev1.Capability{"ALL"}, defaulted[0].SecurityContext.Capabilities.Drop) +} + +func TestDefaultContainerSecurityContextsKeepsProvidedContext(t *testing.T) { + securityContext := &corev1.SecurityContext{ + RunAsUser: util.Int64(1000), + } + containers := []corev1.Container{ + { + Name: "sidecar", + SecurityContext: securityContext, + }, + } + + defaulted := util.DefaultContainerSecurityContexts(containers) + + assert.Same(t, securityContext, defaulted[0].SecurityContext) +}