From 5aed7a01d5941d2fe5f2fab849aed5b916438865 Mon Sep 17 00:00:00 2001 From: Dario Tranchitella Date: Tue, 24 Nov 2020 00:40:40 +0100 Subject: [PATCH] Enforcing container registry via list or regex (#142) Adding also NamespaceSelector to specific webhooks in order to decrease the chance ov breaking other critical Namespaces in case of Capsule failures. --- api/v1alpha1/domain/registry.go | 83 +++++++++++++ api/v1alpha1/domain/registry_test.go | 78 ++++++++++++ api/v1alpha1/registry_class_list.go | 43 +++++++ api/v1alpha1/tenant_types.go | 14 ++- api/v1alpha1/zz_generated.deepcopy.go | 44 +++++++ .../crd/bases/capsule.clastix.io_tenants.yaml | 14 +++ config/samples/capsule_v1alpha1_tenant.yaml | 4 + config/webhook/kustomization.yaml | 8 ++ config/webhook/manifests.yaml | 17 +++ config/webhook/patch_ns_selector.yaml | 30 +++++ main.go | 4 +- pkg/webhook/registry/errors.go | 49 ++++++++ pkg/webhook/registry/validating.go | 111 ++++++++++++++++++ use_cases.md | 52 +++++++- 14 files changed, 546 insertions(+), 5 deletions(-) create mode 100644 api/v1alpha1/domain/registry.go create mode 100644 api/v1alpha1/domain/registry_test.go create mode 100644 api/v1alpha1/registry_class_list.go create mode 100644 config/webhook/patch_ns_selector.yaml create mode 100644 pkg/webhook/registry/errors.go create mode 100644 pkg/webhook/registry/validating.go diff --git a/api/v1alpha1/domain/registry.go b/api/v1alpha1/domain/registry.go new file mode 100644 index 00000000..70524224 --- /dev/null +++ b/api/v1alpha1/domain/registry.go @@ -0,0 +1,83 @@ +/* +Copyright 2020 Clastix Labs. + +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 domain + +import ( + "regexp" +) + +type registry map[string]string + +func (r registry) Registry() string { + res, ok := r["registry"] + if !ok { + return "" + } + if len(res) == 0 { + return "docker.io" + } + return res +} + +func (r registry) Repository() string { + res, ok := r["repository"] + if !ok { + return "" + } + if res == "docker.io" { + return "" + } + return res +} + +func (r registry) Image() string { + res, ok := r["image"] + if !ok { + return "" + } + return res +} + +func (r registry) Tag() string { + res, ok := r["tag"] + if !ok { + return "" + } + if len(res) == 0 { + res = "latest" + } + return res +} + +func NewRegistry(value string) Registry { + registry := make(registry) + r := regexp.MustCompile(`(((?P[a-zA-Z0-9-.]+)\/)?((?P[a-zA-Z0-9-.]+)\/))?(?P[a-zA-Z0-9-.]+)(:(?P[a-zA-Z0-9-.]+))?`) + match := r.FindStringSubmatch(value) + for i, name := range r.SubexpNames() { + if i > 0 && i <= len(match) { + registry[name] = match[i] + } + } + return registry +} + +type Registry interface { + Registry() string + Repository() string + Image() string + Tag() string +} diff --git a/api/v1alpha1/domain/registry_test.go b/api/v1alpha1/domain/registry_test.go new file mode 100644 index 00000000..d904d872 --- /dev/null +++ b/api/v1alpha1/domain/registry_test.go @@ -0,0 +1,78 @@ +/* +Copyright 2020 Clastix Labs. + +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 domain + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewRegistry(t *testing.T) { + type tc struct { + registry string + repo string + image string + tag string + } + for name, tc := range map[string]tc{ + "docker.io/my-org/my-repo:v0.0.1": { + registry: "docker.io", + repo: "my-org", + image: "my-repo", + tag: "v0.0.1", + }, + "unnamed/repository:1.2.3": { + registry: "docker.io", + repo: "unnamed", + image: "repository", + tag: "1.2.3", + }, + "quay.io/clastix/capsule:v1.0.0": { + registry: "quay.io", + repo: "clastix", + image: "capsule", + tag: "v1.0.0", + }, + "docker.io/redis:alpine": { + registry: "docker.io", + repo: "", + image: "redis", + tag: "alpine", + }, + "nginx:alpine": { + registry: "docker.io", + repo: "", + image: "nginx", + tag: "alpine", + }, + "nginx": { + registry: "docker.io", + repo: "", + image: "nginx", + tag: "latest", + }, + } { + t.Run(name, func(t *testing.T) { + r := NewRegistry(name) + assert.Equal(t, tc.registry, r.Registry()) + assert.Equal(t, tc.repo, r.Repository()) + assert.Equal(t, tc.image, r.Image()) + assert.Equal(t, tc.tag, r.Tag()) + }) + } +} diff --git a/api/v1alpha1/registry_class_list.go b/api/v1alpha1/registry_class_list.go new file mode 100644 index 00000000..590a08b1 --- /dev/null +++ b/api/v1alpha1/registry_class_list.go @@ -0,0 +1,43 @@ +/* +Copyright 2020 Clastix Labs. + +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 v1alpha1 + +import ( + "sort" + "strings" +) + +type RegistryList []string + +func (in RegistryList) Len() int { + return len(in) +} + +func (in RegistryList) Swap(i, j int) { + in[i], in[j] = in[j], in[i] +} + +func (in RegistryList) Less(i, j int) bool { + return strings.ToLower(in[i]) < strings.ToLower(in[j]) +} + +func (in RegistryList) IsStringInList(value string) (ok bool) { + sort.Sort(in) + i := sort.SearchStrings(in, value) + ok = i < in.Len() && in[i] == value + return +} diff --git a/api/v1alpha1/tenant_types.go b/api/v1alpha1/tenant_types.go index 353a5772..f12a6b1b 100644 --- a/api/v1alpha1/tenant_types.go +++ b/api/v1alpha1/tenant_types.go @@ -47,15 +47,23 @@ type IngressClassesSpec struct { AllowedRegex string `json:"allowedRegex"` } +type ContainerRegistriesSpec struct { + // +nullable + Allowed RegistryList `json:"allowed"` + // +nullable + AllowedRegex string `json:"allowedRegex"` +} + // TenantSpec defines the desired state of Tenant type TenantSpec struct { Owner OwnerSpec `json:"owner"` // +kubebuilder:validation:Optional NamespacesMetadata AdditionalMetadata `json:"namespacesMetadata"` // +kubebuilder:validation:Optional - ServicesMetadata AdditionalMetadata `json:"servicesMetadata"` - StorageClasses StorageClassesSpec `json:"storageClasses"` - IngressClasses IngressClassesSpec `json:"ingressClasses"` + ServicesMetadata AdditionalMetadata `json:"servicesMetadata"` + StorageClasses StorageClassesSpec `json:"storageClasses"` + IngressClasses IngressClassesSpec `json:"ingressClasses"` + ContainerRegistries *ContainerRegistriesSpec `json:"containerRegistries,omitempty"` // +kubebuilder:validation:Optional NodeSelector map[string]string `json:"nodeSelector"` NamespaceQuota NamespaceQuota `json:"namespaceQuota"` diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 82d80b20..e257b95f 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -76,6 +76,26 @@ func (in *AdditionalRoleBindings) DeepCopy() *AdditionalRoleBindings { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ContainerRegistriesSpec) DeepCopyInto(out *ContainerRegistriesSpec) { + *out = *in + if in.Allowed != nil { + in, out := &in.Allowed, &out.Allowed + *out = make(RegistryList, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ContainerRegistriesSpec. +func (in *ContainerRegistriesSpec) DeepCopy() *ContainerRegistriesSpec { + if in == nil { + return nil + } + out := new(ContainerRegistriesSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in IngressClassList) DeepCopyInto(out *IngressClassList) { { @@ -149,6 +169,25 @@ func (in *OwnerSpec) DeepCopy() *OwnerSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in RegistryList) DeepCopyInto(out *RegistryList) { + { + in := &in + *out = make(RegistryList, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RegistryList. +func (in RegistryList) DeepCopy() RegistryList { + if in == nil { + return nil + } + out := new(RegistryList) + in.DeepCopyInto(out) + return *out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in StorageClassList) DeepCopyInto(out *StorageClassList) { { @@ -255,6 +294,11 @@ func (in *TenantSpec) DeepCopyInto(out *TenantSpec) { in.ServicesMetadata.DeepCopyInto(&out.ServicesMetadata) in.StorageClasses.DeepCopyInto(&out.StorageClasses) in.IngressClasses.DeepCopyInto(&out.IngressClasses) + if in.ContainerRegistries != nil { + in, out := &in.ContainerRegistries, &out.ContainerRegistries + *out = new(ContainerRegistriesSpec) + (*in).DeepCopyInto(*out) + } if in.NodeSelector != nil { in, out := &in.NodeSelector, &out.NodeSelector *out = make(map[string]string, len(*in)) diff --git a/config/crd/bases/capsule.clastix.io_tenants.yaml b/config/crd/bases/capsule.clastix.io_tenants.yaml index b1889d3d..52f600e3 100644 --- a/config/crd/bases/capsule.clastix.io_tenants.yaml +++ b/config/crd/bases/capsule.clastix.io_tenants.yaml @@ -106,6 +106,20 @@ spec: - subjects type: object type: array + containerRegistries: + properties: + allowed: + items: + type: string + nullable: true + type: array + allowedRegex: + nullable: true + type: string + required: + - allowed + - allowedRegex + type: object ingressClasses: properties: allowed: diff --git a/config/samples/capsule_v1alpha1_tenant.yaml b/config/samples/capsule_v1alpha1_tenant.yaml index cf2c412a..1dd86ad3 100644 --- a/config/samples/capsule_v1alpha1_tenant.yaml +++ b/config/samples/capsule_v1alpha1_tenant.yaml @@ -90,3 +90,7 @@ spec: allowed: - default allowedRegex: "" + containerRegistries: + allowed: + - docker.io + allowedRegex: "" diff --git a/config/webhook/kustomization.yaml b/config/webhook/kustomization.yaml index 9cf26134..ea131874 100644 --- a/config/webhook/kustomization.yaml +++ b/config/webhook/kustomization.yaml @@ -2,5 +2,13 @@ resources: - manifests.yaml - service.yaml +patchesJson6902: +- target: + group: admissionregistration.k8s.io + kind: ValidatingWebhookConfiguration + name: validating-webhook-configuration + version: v1beta1 + path: patch_ns_selector.yaml + configurations: - kustomizeconfig.yaml diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml index 7369a25c..bbbbc4f4 100644 --- a/config/webhook/manifests.yaml +++ b/config/webhook/manifests.yaml @@ -121,6 +121,23 @@ webhooks: - CREATE resources: - persistentvolumeclaims +- clientConfig: + caBundle: Cg== + service: + name: webhook-service + namespace: system + path: /validating-v1-registry + failurePolicy: Ignore + name: pod.capsule.clastix.io + rules: + - apiGroups: + - "" + apiVersions: + - v1 + operations: + - CREATE + resources: + - pods - clientConfig: caBundle: Cg== service: diff --git a/config/webhook/patch_ns_selector.yaml b/config/webhook/patch_ns_selector.yaml new file mode 100644 index 00000000..abd90125 --- /dev/null +++ b/config/webhook/patch_ns_selector.yaml @@ -0,0 +1,30 @@ +- op: add + path: /webhooks/0/namespaceSelector + value: + matchExpressions: + - key: capsule.clastix.io/tenant + operator: Exists +- op: add + path: /webhooks/1/namespaceSelector + value: + matchExpressions: + - key: capsule.clastix.io/tenant + operator: Exists +- op: add + path: /webhooks/3/namespaceSelector + value: + matchExpressions: + - key: capsule.clastix.io/tenant + operator: Exists +- op: add + path: /webhooks/4/namespaceSelector + value: + matchExpressions: + - key: capsule.clastix.io/tenant + operator: Exists +- op: add + path: /webhooks/5/namespaceSelector + value: + matchExpressions: + - key: capsule.clastix.io/tenant + operator: Exists diff --git a/main.go b/main.go index dd763a6c..b2958684 100644 --- a/main.go +++ b/main.go @@ -44,6 +44,7 @@ import ( "github.com/clastix/capsule/pkg/webhook/network_policies" "github.com/clastix/capsule/pkg/webhook/owner_reference" "github.com/clastix/capsule/pkg/webhook/pvc" + "github.com/clastix/capsule/pkg/webhook/registry" "github.com/clastix/capsule/pkg/webhook/tenant" "github.com/clastix/capsule/pkg/webhook/tenant_prefix" "github.com/clastix/capsule/pkg/webhook/utils" @@ -152,11 +153,12 @@ func main() { } // +kubebuilder:scaffold:builder - // webhooks + // webhooks: the order matters, don't change it and just append wl := append( make([]webhook.Webhook, 0), ingress.Webhook(ingress.Handler()), pvc.Webhook(pvc.Handler()), + registry.Webhook(registry.Handler()), owner_reference.Webhook(utils.InCapsuleGroup(capsuleGroup, owner_reference.Handler(forceTenantPrefix))), namespace_quota.Webhook(utils.InCapsuleGroup(capsuleGroup, namespace_quota.Handler())), network_policies.Webhook(utils.InCapsuleGroup(capsuleGroup, network_policies.Handler())), diff --git a/pkg/webhook/registry/errors.go b/pkg/webhook/registry/errors.go new file mode 100644 index 00000000..efe2c428 --- /dev/null +++ b/pkg/webhook/registry/errors.go @@ -0,0 +1,49 @@ +/* +Copyright 2020 Clastix Labs. + +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 registry + +import ( + "fmt" + "strings" + + "github.com/clastix/capsule/api/v1alpha1" +) + +type registryClassForbidden struct { + fqdi string + spec v1alpha1.ContainerRegistriesSpec +} + +func NewContainerRegistryForbidden(image string, spec v1alpha1.ContainerRegistriesSpec) error { + return ®istryClassForbidden{ + fqdi: image, + spec: spec, + } +} + +func (f registryClassForbidden) Error() (err string) { + err = fmt.Sprintf("Container image %s registry is forbidden for the current Tenant: ", f.fqdi) + var extra []string + if len(f.spec.Allowed) > 0 { + extra = append(extra, fmt.Sprintf("use one from the following list (%s)", strings.Join(f.spec.Allowed, ", "))) + } + if len(f.spec.AllowedRegex) > 0 { + extra = append(extra, fmt.Sprintf(" use one matching the following regex (%s)", f.spec.AllowedRegex)) + } + err += strings.Join(extra, " or ") + return +} diff --git a/pkg/webhook/registry/validating.go b/pkg/webhook/registry/validating.go new file mode 100644 index 00000000..df5d52be --- /dev/null +++ b/pkg/webhook/registry/validating.go @@ -0,0 +1,111 @@ +/* +Copyright 2020 Clastix Labs. + +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 registry + +import ( + "context" + "net/http" + "regexp" + + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/fields" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + capsulev1alpha1 "github.com/clastix/capsule/api/v1alpha1" + "github.com/clastix/capsule/api/v1alpha1/domain" + capsulewebhook "github.com/clastix/capsule/pkg/webhook" +) + +// +kubebuilder:webhook:path=/validating-v1-registry,mutating=false,failurePolicy=ignore,groups="",resources=pods,verbs=create,versions=v1,name=pod.capsule.clastix.io + +type webhook struct { + handler capsulewebhook.Handler +} + +func Webhook(handler capsulewebhook.Handler) capsulewebhook.Webhook { + return &webhook{handler: handler} +} + +func (w *webhook) GetName() string { + return "registry" +} + +func (w *webhook) GetPath() string { + return "/validating-v1-registry" +} + +func (w *webhook) GetHandler() capsulewebhook.Handler { + return w.handler +} + +type handler struct { +} + +func Handler() capsulewebhook.Handler { + return &handler{} +} + +func (h *handler) OnCreate(c client.Client, decoder *admission.Decoder) capsulewebhook.Func { + return func(ctx context.Context, req admission.Request) admission.Response { + pod := &v1.Pod{} + if err := decoder.Decode(req, pod); err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + tl := &capsulev1alpha1.TenantList{} + if err := c.List(ctx, tl, client.MatchingFieldsSelector{ + Selector: fields.OneTermEqualSelector(".status.namespaces", pod.Namespace), + }); err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + if len(tl.Items) == 0 { + return admission.Allowed("") + } + + tnt := tl.Items[0] + + if tnt.Spec.ContainerRegistries != nil { + var valid, matched bool + regex := regexp.MustCompile(tnt.Spec.ContainerRegistries.AllowedRegex) + for _, container := range pod.Spec.Containers { + r := domain.NewRegistry(container.Image) + valid = tnt.Spec.ContainerRegistries.Allowed.IsStringInList(r.Registry()) + if len(tnt.Spec.ContainerRegistries.AllowedRegex) > 0 { + matched = regex.MatchString(r.Registry()) + } + if !valid && !matched { + return admission.Errored(http.StatusBadRequest, NewContainerRegistryForbidden(container.Image, *tnt.Spec.ContainerRegistries)) + } + } + } + + return admission.Allowed("") + } +} + +func (h *handler) OnDelete(client client.Client, decoder *admission.Decoder) capsulewebhook.Func { + return func(ctx context.Context, req admission.Request) admission.Response { + return admission.Allowed("") + } +} + +func (h *handler) OnUpdate(client client.Client, decoder *admission.Decoder) capsulewebhook.Func { + return func(ctx context.Context, req admission.Request) admission.Response { + return admission.Allowed("") + } +} diff --git a/use_cases.md b/use_cases.md index 80016d0c..de7e8660 100644 --- a/use_cases.md +++ b/use_cases.md @@ -33,7 +33,7 @@ Acme Corp. can use Capsule to address the following scenarios: * [Control the Ingress selector in the tenant](#control-the-ingress-selector-in-the-tenant) * [Assign Storage classes in the tenant](#assign-storage-classes-in-the-tenant) * [Set network policies in the tenant](#set-network-policies-in-the-tenant) - +* [Enforce Pod running images provided by a set of trusted registries](#enforce-pod-running-images-provided-by-a-set-of-trusted-registries) ### Onboarding of a new customer Bill receives a new request from the CaaS onboarding system that a new @@ -963,3 +963,53 @@ the given subjects. > With the following example, Capsule is forbidding to any authenticated user > to run privileged pods and let them to performs privilege escalation as > declared by the Cluster Role `psp:privileged`. + +# Enforce Pod running images provided by a set of trusted registries + +Let's say you have a strict policy on the ownership running in a certain +Tenant: you'd like to allow running just images hosted on a list of specific +container registries. + +The spec `containerRegistries` addresses this task and can provide combination +with hard enforcement using a list of allowed values as a valid regular +expression for a maximum flexibility. + +## Allowing using a regex + +This can be useful if you want to allow run images from any registry in your +organization or for any other particular use case. + +```yaml +apiVersion: capsule.clastix.io/v1alpha1 +kind: Tenant +spec: + containerRegistries: + allowed: [] + regex: "internal.registry.\\w.tld" +``` + +A Pod running `internal.registry.foo.tld` as registry will be allowed, as well +`internal.registry.bar.tld` since these are matching the regular expression. + +> You can also set a catch-all as .* to allow every kind of registry, +> that would be the same result of unsetting `containerRegistries` at all + +## Allowing from a list of registries + +For more strict requirements, you can specify an array of string. + +```yaml +apiVersion: capsule.clastix.io/v1alpha1 +kind: Tenant +spec: + containerRegistries: + allowed: + - docker.io + - quay.io + regex: "" +``` + +> In case of naked and official images hosted on Docker Hub, Capsule is going +> to retrieve the registry even if it's not explicit: a `busybox:latest` Pod +> running on a Tenant allowing `docker.io` will not blocked, even if the image +> field is not explicit as `docker.io/busybox:latest`.