From ed3e4ad33f166f967866b6309f21a76aad58f4c7 Mon Sep 17 00:00:00 2001 From: Sachin Sampras M Date: Mon, 21 Sep 2026 12:40:39 +0100 Subject: [PATCH 1/4] fix: force ReadOnly on volume mounts copied into validation containers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The webhook copies all volume mounts from application containers into the injected validation container. These were inherited with their original read/write permissions, violating least-privilege — the validation agent only reads model files and signatures. Signed-off-by: Sachin Sampras M --- internal/webhooks/pod_webhook.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/webhooks/pod_webhook.go b/internal/webhooks/pod_webhook.go index f63cf1b9..784c9832 100644 --- a/internal/webhooks/pod_webhook.go +++ b/internal/webhooks/pod_webhook.go @@ -143,7 +143,10 @@ func (p *podInterceptor) Handle(ctx context.Context, req admission.Request) (res vm := []corev1.VolumeMount{} for _, c := range pod.Spec.Containers { - vm = append(vm, c.VolumeMounts...) + for _, m := range c.VolumeMounts { + m.ReadOnly = true + vm = append(vm, m) + } } continuousEnabled := mv.Spec.ContinuousValidation != nil && mv.Spec.ContinuousValidation.Enabled From 47021261159f77d3c4157475c25aa65ffd300bb1 Mon Sep 17 00:00:00 2001 From: Sachin Sampras M Date: Mon, 21 Sep 2026 20:17:54 +0100 Subject: [PATCH 2/4] fix: filter volume mounts to only model-relevant paths Instead of copying all volume mounts from all app containers into the validation container, only mount volumes whose mountPath is a prefix of a path the agent actually needs (model path, signature path, CA cert, public key). Prevents read access to unrelated secrets, tokens, and TLS keys if the validation-agent image is compromised. Signed-off-by: Sachin Sampras M --- internal/webhooks/pod_webhook.go | 46 +++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/internal/webhooks/pod_webhook.go b/internal/webhooks/pod_webhook.go index 784c9832..9b27ff41 100644 --- a/internal/webhooks/pod_webhook.go +++ b/internal/webhooks/pod_webhook.go @@ -141,13 +141,8 @@ func (p *podInterceptor) Handle(ctx context.Context, req admission.Request) (res logger.Error(err, "failed to find TelemetryConfig, proceeding without telemetry") } - vm := []corev1.VolumeMount{} - for _, c := range pod.Spec.Containers { - for _, m := range c.VolumeMounts { - m.ReadOnly = true - vm = append(vm, m) - } - } + neededPaths := collectNeededPaths(mergedModel, mv.Spec.Config) + vm := filterVolumeMounts(pod.Spec.Containers, neededPaths) continuousEnabled := mv.Spec.ContinuousValidation != nil && mv.Spec.ContinuousValidation.Enabled useLegacySidecar := continuousEnabled && !p.nativeSidecarSupport @@ -476,6 +471,43 @@ func mergeModelWithAnnotations(logger logr.Logger, model v1alpha1.Model, annotat return *merged } +// collectNeededPaths returns file paths the validation agent needs access to. +func collectNeededPaths(model v1alpha1.Model, cfg v1alpha1.ValidationConfig) []string { + paths := []string{model.Path} + if model.SignaturePath != "" { + paths = append(paths, model.SignaturePath) + } + if cfg.PkiConfig != nil && cfg.PkiConfig.CertificateAuthority != "" { + paths = append(paths, cfg.PkiConfig.CertificateAuthority) + } + if cfg.PublicKeyConfig != nil && cfg.PublicKeyConfig.KeyPath != "" { + paths = append(paths, cfg.PublicKeyConfig.KeyPath) + } + return paths +} + +// filterVolumeMounts returns only the mounts whose mountPath is a prefix of a needed path, all forced read-only. +func filterVolumeMounts(containers []corev1.Container, neededPaths []string) []corev1.VolumeMount { + seen := make(map[string]bool) + var out []corev1.VolumeMount + for _, c := range containers { + for _, m := range c.VolumeMounts { + if seen[m.MountPath] { + continue + } + for _, p := range neededPaths { + if strings.HasPrefix(p, m.MountPath) { + m.ReadOnly = true + out = append(out, m) + seen[m.MountPath] = true + break + } + } + } + } + return out +} + func webhookResult(resp admission.Response) string { if resp.Result == nil { return "success" From a5ccb0c4e817c143e41d86c96b08b9b4304bdd4c Mon Sep 17 00:00:00 2001 From: Sachin Sampras M Date: Fri, 25 Sep 2026 11:50:07 +0100 Subject: [PATCH 3/4] fix: add writable TUF cache and trust config path to validation containers The security hardening in 9a4ffeb set readOnlyRootFilesystem on injected validation containers, which broke sigstore verification in two ways: 1. The sigstore-go TUF client needs to write to /.sigstore for trust root metadata. Inject an emptyDir volume at /.sigstore when sigstoreConfig is used. 2. collectNeededPaths did not include clientTrustConfig.trustConfigPath, so filterVolumeMounts never copied the trust config volume into the init container. Add it to the needed paths. Signed-off-by: Sachin Sampras M --- internal/webhooks/pod_webhook.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/internal/webhooks/pod_webhook.go b/internal/webhooks/pod_webhook.go index 9b27ff41..8b631d60 100644 --- a/internal/webhooks/pod_webhook.go +++ b/internal/webhooks/pod_webhook.go @@ -151,6 +151,15 @@ func (p *podInterceptor) Handle(ctx context.Context, req admission.Request) (res logger.Info("Using legacy sidecar for continuous validation (native sidecars not supported)") } + if mv.Spec.Config.SigstoreConfig != nil { + const tufVolName = "sigstore-tuf-cache" + pp.Spec.Volumes = append(pp.Spec.Volumes, corev1.Volume{ + Name: tufVolName, + VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}, + }) + vm = append(vm, corev1.VolumeMount{Name: tufVolName, MountPath: "/.sigstore"}) + } + container := buildValidationContainer(mv, args, vm, pp, tc, p.nativeSidecarSupport) pp.Spec.InitContainers = append(pp.Spec.InitContainers, container) @@ -483,6 +492,9 @@ func collectNeededPaths(model v1alpha1.Model, cfg v1alpha1.ValidationConfig) []s if cfg.PublicKeyConfig != nil && cfg.PublicKeyConfig.KeyPath != "" { paths = append(paths, cfg.PublicKeyConfig.KeyPath) } + if cfg.ClientTrustConfig != nil && cfg.ClientTrustConfig.TrustConfigPath != "" { + paths = append(paths, cfg.ClientTrustConfig.TrustConfigPath) + } return paths } From a0716bf1bc2280709ed4ad53cbf6b1ab6e8a3c5c Mon Sep 17 00:00:00 2001 From: Sachin Sampras M Date: Fri, 25 Sep 2026 15:48:22 +0100 Subject: [PATCH 4/4] fix: add sizeLimit to TUF cache emptyDir and reject root mount path 1. Set a 10Mi sizeLimit on the sigstore-tuf-cache emptyDir volume to prevent a compromised agent from filling the node's ephemeral storage. 2. Reject mountPath "/" in filterVolumeMounts and require proper directory prefix matching (trailing slash). A container with mountPath "/" would previously match every neededPath, leaking access to the entire volume into the validation container. Add unit tests for filterVolumeMounts covering prefix matching, root path rejection, partial directory name rejection, exact path matching, deduplication, and multi-path scenarios. Signed-off-by: Sachin Sampras M --- internal/webhooks/pod_webhook.go | 18 +++++-- internal/webhooks/pod_webhook_test.go | 75 +++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/internal/webhooks/pod_webhook.go b/internal/webhooks/pod_webhook.go index 8b631d60..33f0087a 100644 --- a/internal/webhooks/pod_webhook.go +++ b/internal/webhooks/pod_webhook.go @@ -154,8 +154,10 @@ func (p *podInterceptor) Handle(ctx context.Context, req admission.Request) (res if mv.Spec.Config.SigstoreConfig != nil { const tufVolName = "sigstore-tuf-cache" pp.Spec.Volumes = append(pp.Spec.Volumes, corev1.Volume{ - Name: tufVolName, - VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}, + Name: tufVolName, + VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{ + SizeLimit: ptr.To(resource.MustParse("10Mi")), + }}, }) vm = append(vm, corev1.VolumeMount{Name: tufVolName, MountPath: "/.sigstore"}) } @@ -498,17 +500,23 @@ func collectNeededPaths(model v1alpha1.Model, cfg v1alpha1.ValidationConfig) []s return paths } -// filterVolumeMounts returns only the mounts whose mountPath is a prefix of a needed path, all forced read-only. +// filterVolumeMounts returns only the mounts whose mountPath is a proper directory +// prefix of a needed path, all forced read-only. Mounts at "/" are excluded to +// prevent leaking the entire root filesystem into the validation container. func filterVolumeMounts(containers []corev1.Container, neededPaths []string) []corev1.VolumeMount { seen := make(map[string]bool) var out []corev1.VolumeMount for _, c := range containers { for _, m := range c.VolumeMounts { - if seen[m.MountPath] { + if seen[m.MountPath] || m.MountPath == "/" { continue } + prefix := m.MountPath + if !strings.HasSuffix(prefix, "/") { + prefix += "/" + } for _, p := range neededPaths { - if strings.HasPrefix(p, m.MountPath) { + if strings.HasPrefix(p, prefix) || p == m.MountPath { m.ReadOnly = true out = append(out, m) seen[m.MountPath] = true diff --git a/internal/webhooks/pod_webhook_test.go b/internal/webhooks/pod_webhook_test.go index ed821a7c..58464eba 100644 --- a/internal/webhooks/pod_webhook_test.go +++ b/internal/webhooks/pod_webhook_test.go @@ -955,4 +955,79 @@ var _ = Describe("Pod webhook", func() { _ = k8sClient.Delete(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: contSecCtxTestNamespace}}) }) }) + + Context("filterVolumeMounts", func() { + containers := func(mounts ...corev1.VolumeMount) []corev1.Container { + return []corev1.Container{{VolumeMounts: mounts}} + } + mount := func(name, path string) corev1.VolumeMount { + return corev1.VolumeMount{Name: name, MountPath: path} + } + + It("should match mounts whose path is a prefix of a needed path", func() { + result := filterVolumeMounts( + containers(mount("data", "/data"), mount("config", "/config")), + []string{"/data/model.onnx"}, + ) + Expect(result).To(HaveLen(1)) + Expect(result[0].Name).To(Equal("data")) + Expect(result[0].ReadOnly).To(BeTrue()) + }) + + It("should reject root mountPath /", func() { + result := filterVolumeMounts( + containers(mount("root-vol", "/"), mount("data", "/data")), + []string{"/data/model.onnx"}, + ) + Expect(result).To(HaveLen(1)) + Expect(result[0].Name).To(Equal("data")) + }) + + It("should not match partial directory names", func() { + result := filterVolumeMounts( + containers(mount("dat", "/dat")), + []string{"/data/model.onnx"}, + ) + Expect(result).To(BeEmpty()) + }) + + It("should match exact mountPath equal to needed path", func() { + result := filterVolumeMounts( + containers(mount("model", "/data")), + []string{"/data"}, + ) + Expect(result).To(HaveLen(1)) + Expect(result[0].Name).To(Equal("model")) + }) + + It("should deduplicate mounts across containers", func() { + result := filterVolumeMounts( + []corev1.Container{ + {VolumeMounts: []corev1.VolumeMount{mount("a", "/data")}}, + {VolumeMounts: []corev1.VolumeMount{mount("b", "/data")}}, + }, + []string{"/data/model.onnx"}, + ) + Expect(result).To(HaveLen(1)) + Expect(result[0].Name).To(Equal("a")) + }) + + It("should return empty for no matching paths", func() { + result := filterVolumeMounts( + containers(mount("logs", "/var/log")), + []string{"/data/model.onnx"}, + ) + Expect(result).To(BeEmpty()) + }) + + It("should match multiple needed paths to multiple mounts", func() { + result := filterVolumeMounts( + containers(mount("data", "/data"), mount("trust", "/trust"), mount("logs", "/var/log")), + []string{"/data/model.onnx", "/trust/config.json"}, + ) + Expect(result).To(HaveLen(2)) + names := []string{result[0].Name, result[1].Name} + Expect(names).To(ContainElements("data", "trust")) + }) + }) })