diff --git a/modules/common/pod/security.go b/modules/common/pod/security.go new file mode 100644 index 00000000..ab8ac69d --- /dev/null +++ b/modules/common/pod/security.go @@ -0,0 +1,53 @@ +/* +Copyright 2026 Red Hat + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package pod + +import ( + corev1 "k8s.io/api/core/v1" + "k8s.io/utils/ptr" +) + +// RestrictiveSecurityContext returns a hardened container SecurityContext +// suitable for unprivileged workloads. It sets RunAsNonRoot, drops all +// capabilities, disables privilege escalation, and applies the RuntimeDefault +// seccomp profile. The provided uid is used for both RunAsUser and RunAsGroup. +// Optional addCapabilities are added back after dropping ALL. +func RestrictiveSecurityContext(uid int64, addCapabilities ...corev1.Capability) *corev1.SecurityContext { + return RestrictiveSecurityContextWithGID(uid, uid, addCapabilities...) +} + +// RestrictiveSecurityContextWithGID is like RestrictiveSecurityContext but +// allows specifying a different GID. +func RestrictiveSecurityContextWithGID(uid, gid int64, addCapabilities ...corev1.Capability) *corev1.SecurityContext { + caps := &corev1.Capabilities{ + Drop: []corev1.Capability{"ALL"}, + } + if len(addCapabilities) > 0 { + caps.Add = addCapabilities + } + return &corev1.SecurityContext{ + RunAsUser: ptr.To(uid), + RunAsGroup: ptr.To(gid), + RunAsNonRoot: ptr.To(true), + ReadOnlyRootFilesystem: ptr.To(true), + AllowPrivilegeEscalation: ptr.To(false), + Capabilities: caps, + SeccompProfile: &corev1.SeccompProfile{ + Type: corev1.SeccompProfileTypeRuntimeDefault, + }, + } +} diff --git a/modules/common/pod/security_test.go b/modules/common/pod/security_test.go new file mode 100644 index 00000000..4f3f7eaf --- /dev/null +++ b/modules/common/pod/security_test.go @@ -0,0 +1,90 @@ +/* +Copyright 2026 Red Hat + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package pod + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" +) + +func TestRestrictiveSecurityContext(t *testing.T) { + var uid int64 = 42457 + sc := RestrictiveSecurityContext(uid) + + if sc.RunAsUser == nil || *sc.RunAsUser != uid { + t.Errorf("expected RunAsUser %d, got %v", uid, sc.RunAsUser) + } + if sc.RunAsGroup == nil || *sc.RunAsGroup != uid { + t.Errorf("expected RunAsGroup %d, got %v", uid, sc.RunAsGroup) + } + if sc.RunAsNonRoot == nil || !*sc.RunAsNonRoot { + t.Error("expected RunAsNonRoot true") + } + if sc.AllowPrivilegeEscalation == nil || *sc.AllowPrivilegeEscalation { + t.Error("expected AllowPrivilegeEscalation false") + } + if sc.Capabilities == nil || len(sc.Capabilities.Drop) != 1 || sc.Capabilities.Drop[0] != "ALL" { + t.Errorf("expected Capabilities.Drop [ALL], got %v", sc.Capabilities) + } + if sc.SeccompProfile == nil || sc.SeccompProfile.Type != corev1.SeccompProfileTypeRuntimeDefault { + t.Errorf("expected SeccompProfile RuntimeDefault, got %v", sc.SeccompProfile) + } + if sc.ReadOnlyRootFilesystem == nil || !*sc.ReadOnlyRootFilesystem { + t.Error("expected ReadOnlyRootFilesystem true") + } +} + +func TestRestrictiveSecurityContextWithGID(t *testing.T) { + var uid int64 = 42415 + var gid int64 = 42416 + sc := RestrictiveSecurityContextWithGID(uid, gid) + + if sc.RunAsUser == nil || *sc.RunAsUser != uid { + t.Errorf("expected RunAsUser %d, got %v", uid, sc.RunAsUser) + } + if sc.RunAsGroup == nil || *sc.RunAsGroup != gid { + t.Errorf("expected RunAsGroup %d, got %v", gid, sc.RunAsGroup) + } + if sc.RunAsNonRoot == nil || !*sc.RunAsNonRoot { + t.Error("expected RunAsNonRoot true") + } + if sc.AllowPrivilegeEscalation == nil || *sc.AllowPrivilegeEscalation { + t.Error("expected AllowPrivilegeEscalation false") + } +} + +func TestRestrictiveSecurityContextNoAddCaps(t *testing.T) { + sc := RestrictiveSecurityContext(42457) + if sc.Capabilities.Add != nil { + t.Errorf("expected no Add capabilities, got %v", sc.Capabilities.Add) + } +} + +func TestRestrictiveSecurityContextWithAddCaps(t *testing.T) { + sc := RestrictiveSecurityContext(42457, "NET_BIND_SERVICE", "CHOWN") + + if len(sc.Capabilities.Drop) != 1 || sc.Capabilities.Drop[0] != "ALL" { + t.Errorf("expected Drop [ALL], got %v", sc.Capabilities.Drop) + } + if len(sc.Capabilities.Add) != 2 { + t.Fatalf("expected 2 Add capabilities, got %d", len(sc.Capabilities.Add)) + } + if sc.Capabilities.Add[0] != "NET_BIND_SERVICE" || sc.Capabilities.Add[1] != "CHOWN" { + t.Errorf("expected Add [NET_BIND_SERVICE CHOWN], got %v", sc.Capabilities.Add) + } +} diff --git a/modules/common/serviceaccount/projected_token.go b/modules/common/serviceaccount/projected_token.go new file mode 100644 index 00000000..004b3062 --- /dev/null +++ b/modules/common/serviceaccount/projected_token.go @@ -0,0 +1,99 @@ +/* +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package serviceaccount + +import ( + corev1 "k8s.io/api/core/v1" + "k8s.io/utils/ptr" +) + +const ( + // KubeAPIAccessVolumeName is the name of the projected volume that + // replaces the default automounted service account token. + KubeAPIAccessVolumeName = "kube-api-access" + + // KubeAPIAccessMountPath is the standard mount path for the projected + // service account token volume, matching the default automount path. + KubeAPIAccessMountPath = "/var/run/secrets/kubernetes.io/serviceaccount" + + // DefaultTokenExpirationSeconds is the default expiration for the + // projected service account token (1 hour). + DefaultTokenExpirationSeconds int64 = 3600 +) + +// KubeAPIAccessVolume returns a projected Volume that provides a +// time-limited service account token, the cluster CA certificate, and +// the pod namespace. Use it together with AutomountServiceAccountToken=false +// on the PodSpec to replace the default long-lived automounted token with a +// short-lived one. An optional expirationSeconds overrides the default +// token lifetime (1 hour). +func KubeAPIAccessVolume(expirationSeconds ...int64) corev1.Volume { + expiration := DefaultTokenExpirationSeconds + if len(expirationSeconds) > 0 && expirationSeconds[0] > 0 { + expiration = expirationSeconds[0] + } + return corev1.Volume{ + Name: KubeAPIAccessVolumeName, + VolumeSource: corev1.VolumeSource{ + Projected: &corev1.ProjectedVolumeSource{ + DefaultMode: ptr.To[int32](0444), + Sources: []corev1.VolumeProjection{ + { + ServiceAccountToken: &corev1.ServiceAccountTokenProjection{ + Path: "token", + ExpirationSeconds: ptr.To(expiration), + }, + }, + { + ConfigMap: &corev1.ConfigMapProjection{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "kube-root-ca.crt", + }, + Items: []corev1.KeyToPath{ + { + Key: "ca.crt", + Path: "ca.crt", + }, + }, + }, + }, + { + DownwardAPI: &corev1.DownwardAPIProjection{ + Items: []corev1.DownwardAPIVolumeFile{ + { + Path: "namespace", + FieldRef: &corev1.ObjectFieldSelector{ + APIVersion: "v1", + FieldPath: "metadata.namespace", + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +// KubeAPIAccessVolumeMount returns a read-only VolumeMount for the +// projected service account token volume at the standard automount path. +func KubeAPIAccessVolumeMount() corev1.VolumeMount { + return corev1.VolumeMount{ + Name: KubeAPIAccessVolumeName, + MountPath: KubeAPIAccessMountPath, + ReadOnly: true, + } +} diff --git a/modules/common/serviceaccount/projected_token_test.go b/modules/common/serviceaccount/projected_token_test.go new file mode 100644 index 00000000..4a41510d --- /dev/null +++ b/modules/common/serviceaccount/projected_token_test.go @@ -0,0 +1,67 @@ +/* +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package serviceaccount + +import ( + "testing" + + . "github.com/onsi/gomega" //revive:disable:dot-imports +) + +func TestKubeAPIAccessVolume(t *testing.T) { + g := NewWithT(t) + + vol := KubeAPIAccessVolume() + + g.Expect(vol.Name).To(Equal(KubeAPIAccessVolumeName)) + g.Expect(vol.Projected).NotTo(BeNil()) + g.Expect(*vol.Projected.DefaultMode).To(Equal(int32(0444))) + + sources := vol.Projected.Sources + g.Expect(sources).To(HaveLen(3)) + + g.Expect(sources[0].ServiceAccountToken).NotTo(BeNil()) + g.Expect(sources[0].ServiceAccountToken.Path).To(Equal("token")) + g.Expect(*sources[0].ServiceAccountToken.ExpirationSeconds).To(Equal(DefaultTokenExpirationSeconds)) + + g.Expect(sources[1].ConfigMap).NotTo(BeNil()) + g.Expect(sources[1].ConfigMap.Name).To(Equal("kube-root-ca.crt")) + g.Expect(sources[1].ConfigMap.Items).To(HaveLen(1)) + g.Expect(sources[1].ConfigMap.Items[0].Key).To(Equal("ca.crt")) + + g.Expect(sources[2].DownwardAPI).NotTo(BeNil()) + g.Expect(sources[2].DownwardAPI.Items).To(HaveLen(1)) + g.Expect(sources[2].DownwardAPI.Items[0].Path).To(Equal("namespace")) + g.Expect(sources[2].DownwardAPI.Items[0].FieldRef.FieldPath).To(Equal("metadata.namespace")) +} + +func TestKubeAPIAccessVolumeCustomExpiration(t *testing.T) { + g := NewWithT(t) + + var customExpiration int64 = 7200 + vol := KubeAPIAccessVolume(customExpiration) + + g.Expect(*vol.Projected.Sources[0].ServiceAccountToken.ExpirationSeconds).To(Equal(customExpiration)) +} + +func TestKubeAPIAccessVolumeMount(t *testing.T) { + g := NewWithT(t) + + mount := KubeAPIAccessVolumeMount() + + g.Expect(mount.Name).To(Equal(KubeAPIAccessVolumeName)) + g.Expect(mount.MountPath).To(Equal(KubeAPIAccessMountPath)) + g.Expect(mount.ReadOnly).To(BeTrue()) +}