diff --git a/pkg/apis/policy/v1alpha1/clusterimagepolicy_conversion.go b/pkg/apis/policy/v1alpha1/clusterimagepolicy_conversion.go index 671fdba33..4d970ade0 100644 --- a/pkg/apis/policy/v1alpha1/clusterimagepolicy_conversion.go +++ b/pkg/apis/policy/v1alpha1/clusterimagepolicy_conversion.go @@ -175,6 +175,9 @@ func (p *Policy) ConvertTo(_ context.Context, sink *v1beta1.Policy) { if p.IncludeTypeMeta != nil { sink.IncludeTypeMeta = ptr.Bool(*p.IncludeTypeMeta) } + if p.NamespaceSelector != "" { + sink.NamespaceSelector = p.NamespaceSelector + } } func (p *Policy) ConvertFrom(_ context.Context, source *v1beta1.Policy) { @@ -205,6 +208,9 @@ func (p *Policy) ConvertFrom(_ context.Context, source *v1beta1.Policy) { if source.IncludeTypeMeta != nil { p.IncludeTypeMeta = ptr.Bool(*source.IncludeTypeMeta) } + if source.NamespaceSelector != "" { + p.NamespaceSelector = source.NamespaceSelector + } } func (key *KeyRef) ConvertTo(_ context.Context, sink *v1beta1.KeyRef) { diff --git a/pkg/apis/policy/v1alpha1/clusterimagepolicy_lifecycle.go b/pkg/apis/policy/v1alpha1/clusterimagepolicy_lifecycle.go index 4bbb9d676..3a7c09088 100644 --- a/pkg/apis/policy/v1alpha1/clusterimagepolicy_lifecycle.go +++ b/pkg/apis/policy/v1alpha1/clusterimagepolicy_lifecycle.go @@ -75,6 +75,12 @@ func (cs *ClusterImagePolicyStatus) MarkInlinePoliciesFailed(msg string) { cipCondSet.Manage(cs).MarkFalse(ClusterImagePolicyConditionPoliciesInlined, inlinePoliciesFailedReason, msg) } +// MarkNamespaceValidationFailed surfaces a failure that we were unable to +// validate the namespace for the CIP. +func (cs *ClusterImagePolicyStatus) MarkNamespaceValidationFailed(msg string) { + cipCondSet.Manage(cs).MarkFalse(ClusterImagePolicyConditionPoliciesInlined, "NamespaceValidationFailed", msg) +} + // MarkInlinePoliciesdOk marks the status saying that the inlining of the // policies had no errors. func (cs *ClusterImagePolicyStatus) MarkInlinePoliciesOk() { diff --git a/pkg/apis/policy/v1alpha1/clusterimagepolicy_types.go b/pkg/apis/policy/v1alpha1/clusterimagepolicy_types.go index 32cf79782..9592125fb 100644 --- a/pkg/apis/policy/v1alpha1/clusterimagepolicy_types.go +++ b/pkg/apis/policy/v1alpha1/clusterimagepolicy_types.go @@ -299,6 +299,11 @@ type Policy struct { // evaluated iff at least one authority matches. // +optional IncludeTypeMeta *bool `json:"includeTypeMeta,omitempty"` + + // NamespaceSelector is a label selector used to filter namespaces for inclusion. + // For example, it allows enforcing that images can only be verified within specific namespaces, + // such as permitting "a" images only in namespace "a" and "b" images only in namespace "b". + NamespaceSelector string `json:"namespaceSelector,omitempty"` } // ConfigMapReference is cut&paste from SecretReference, but for the life of me diff --git a/pkg/apis/policy/v1beta1/clusterimagepolicy_types.go b/pkg/apis/policy/v1beta1/clusterimagepolicy_types.go index 8e1b1b8b5..052a6660c 100644 --- a/pkg/apis/policy/v1beta1/clusterimagepolicy_types.go +++ b/pkg/apis/policy/v1beta1/clusterimagepolicy_types.go @@ -287,6 +287,11 @@ type Policy struct { // evaluated iff at least one authority matches. // +optional IncludeTypeMeta *bool `json:"includeTypeMeta,omitempty"` + + // NamespaceSelector is a label selector used to filter namespaces for inclusion. + // For example, it allows enforcing that images can only be verified within specific namespaces, + // such as permitting "a" images only in namespace "a" and "b" images only in namespace "b". + NamespaceSelector string `json:"namespaceSelector,omitempty"` } // MatchResource allows selecting resources based on its version, group and resource. diff --git a/pkg/reconciler/clusterimagepolicy/clusterimagepolicy.go b/pkg/reconciler/clusterimagepolicy/clusterimagepolicy.go index 55af0935a..4107ce116 100644 --- a/pkg/reconciler/clusterimagepolicy/clusterimagepolicy.go +++ b/pkg/reconciler/clusterimagepolicy/clusterimagepolicy.go @@ -21,6 +21,7 @@ import ( "fmt" "io" "net/http" + "path/filepath" "strings" "github.com/sigstore/policy-controller/pkg/apis/config" @@ -90,6 +91,13 @@ func (r *Reconciler) ReconcileKind(ctx context.Context, cip *v1alpha1.ClusterIma } cip.Status.MarkInlinePoliciesOk() + cipNserr := r.inlineNamespaces(cipCopy) + if cipNserr != nil { + r.handleCIPError(ctx, cip.Name) + cip.Status.MarkNamespaceValidationFailed(cipNserr.Error()) + return cipNserr + } + webhookCIP := webhookcip.ConvertClusterImagePolicyV1alpha1ToWebhook(cipCopy) // See if the CM holding configs exists @@ -299,6 +307,35 @@ func (r *Reconciler) inlinePolicies(ctx context.Context, cip *v1alpha1.ClusterIm return nil } +func (r *Reconciler) inlineNamespaces(cip *v1alpha1.ClusterImagePolicy) error { + var podList *corev1.PodList + podList, err := r.kubeclient.CoreV1().Pods("").List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return fmt.Errorf("failed to list pods: %w", err) + } + + for _, pod := range podList.Items { + for _, container := range pod.Spec.Containers { + image := container.Image + for _, pattern := range cip.Spec.Images { + if pattern.Glob != "" { + matched, err := filepath.Match(pattern.Glob, image) + if err != nil { + return fmt.Errorf("invalid glob pattern: %w", err) + } + if matched { + if cip.Spec.Policy.NamespaceSelector != "" && pod.Namespace != cip.Spec.Policy.NamespaceSelector { + return fmt.Errorf("image %s can only be used in the namespace %s", image, cip.Spec.Policy.NamespaceSelector) + } + return nil // If mage matches, and namespace is correct (or no namespace restriction then dont care about namespace) + } + } + } + } + } + return nil +} + func (r *Reconciler) inlinePolicyURL(ctx context.Context, policyRef *v1alpha1.Policy) error { logging.FromContext(ctx).Infof("inlining policy url %q", policyRef.Remote.URL.String()) resp, err := http.Get(policyRef.Remote.URL.String()) diff --git a/pkg/reconciler/clusterimagepolicy/clusterimagepolicy_test.go b/pkg/reconciler/clusterimagepolicy/clusterimagepolicy_test.go index e2afffcae..15e8846ba 100644 --- a/pkg/reconciler/clusterimagepolicy/clusterimagepolicy_test.go +++ b/pkg/reconciler/clusterimagepolicy/clusterimagepolicy_test.go @@ -25,6 +25,7 @@ import ( "strings" "testing" + k8sfake "k8s.io/client-go/kubernetes/fake" logtesting "knative.dev/pkg/logging/testing" "github.com/sigstore/policy-controller/pkg/apis/config" @@ -1415,7 +1416,8 @@ malformed KMS format, should be prefixed by any of the supported providers: [aws WithMarkInlinePoliciesFailed(invalidSHAMsg), ), }}, - }} + }, + } logger := logtesting.TestLogger(t) table.Test(t, MakeFactory(func(ctx context.Context, listers *Listers, _ configmap.Watcher) controller.Reconciler { @@ -1563,3 +1565,89 @@ func patchRemoveFinalizers(namespace, name string) clientgotesting.PatchActionIm action.Patch = []byte(patch) return action } + +func TestInlineNamespaces(t *testing.T) { + tests := []struct { + name string + cip *v1alpha1.ClusterImagePolicy + pods []corev1.Pod + expectedErr bool + expectedErrMsg string + }{ + { + name: "Image matches pattern and pod is in correct namespace", + cip: &v1alpha1.ClusterImagePolicy{ + Spec: v1alpha1.ClusterImagePolicySpec{ + Images: []v1alpha1.ImagePattern{{ + Glob: "ghcr.io/sigstore/timestamp-server**", + }}, + Policy: &v1alpha1.Policy{ + NamespaceSelector: "namespace-a", + }, + }, + }, + pods: []corev1.Pod{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "namespace-a", + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{ + Image: "ghcr.io/sigstore/timestamp-server:v1", + }}, + }, + }, + }, + expectedErr: false, + expectedErrMsg: "", + }, + { + name: "Image matches pattern but pod is in wrong namespace", + cip: &v1alpha1.ClusterImagePolicy{ + Spec: v1alpha1.ClusterImagePolicySpec{ + Images: []v1alpha1.ImagePattern{{ + Glob: "ghcr.io/sigstore/timestamp-server**", + }}, + Policy: &v1alpha1.Policy{ + NamespaceSelector: "namespace-a", + }, + }, + }, + pods: []corev1.Pod{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "namespace-b", + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{ + Image: "ghcr.io/sigstore/timestamp-server:v1", + }}, + }, + }, + }, + expectedErr: true, + expectedErrMsg: "image ghcr.io/sigstore/timestamp-server:v1 can only be used in the namespace namespace-a", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fakeClient := k8sfake.NewSimpleClientset(&corev1.PodList{Items: tt.pods}) + r := &Reconciler{kubeclient: fakeClient} + + err := r.inlineNamespaces(tt.cip) + + if tt.expectedErr { + if err == nil { + t.Errorf("expected error, got nil") + } else if err.Error() != tt.expectedErrMsg { + t.Errorf("expected error message %q, got %q", tt.expectedErrMsg, err.Error()) + } + } else { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + } + }) + } +}